mirror of https://github.com/grafana/grafana
Scopes: Refactor scopes to plain React and move it to runtime (#97176)
* Scopes: Refactor scopes * Scopes: Refactor scopes * Scopes: Refactor scopes * Revert data changes * Refactorings * Refactorings * Refactorings * Remove unused * Refactorings * Refactors * Fixes * Update scenes * Rebase * Update .betterer.results * Fix tests * Move scopes selector to appchrome * More fixes * Fix tests * Remove ScopesFacade * use latest canary scenes version to pass tests * Make fields private * Update scenes version --------- Co-authored-by: Tobias Skarhed <tobias.skarhed@gmail.com> Co-authored-by: Victor Marin <victor.marin@grafana.com> Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>pull/101947/head
parent
210c886bb7
commit
312d80e0e1
@ -1,48 +0,0 @@ |
|||||||
import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes'; |
|
||||||
import { ScopesFacade } from 'app/features/scopes'; |
|
||||||
|
|
||||||
import { getDashboardSceneFor } from '../utils/utils'; |
|
||||||
|
|
||||||
import { convertScopesToAdHocFilters } from './convertScopesToAdHocFilters'; |
|
||||||
|
|
||||||
export interface DashboardScopesFacadeState { |
|
||||||
reloadOnParamsChange?: boolean; |
|
||||||
uid?: string; |
|
||||||
} |
|
||||||
|
|
||||||
export class DashboardScopesFacade extends ScopesFacade { |
|
||||||
constructor({ reloadOnParamsChange, uid }: DashboardScopesFacadeState) { |
|
||||||
super({ |
|
||||||
handler: (facade) => { |
|
||||||
if (!reloadOnParamsChange || !uid) { |
|
||||||
sceneGraph.getTimeRange(facade).onRefresh(); |
|
||||||
} |
|
||||||
|
|
||||||
// push filters as soon as they come
|
|
||||||
this.pushScopeFiltersToAdHocVariable(); |
|
||||||
}, |
|
||||||
}); |
|
||||||
|
|
||||||
this.addActivationHandler(() => { |
|
||||||
// also try to push filters on activation, for
|
|
||||||
// when the dashboard is changed
|
|
||||||
this.pushScopeFiltersToAdHocVariable(); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
private pushScopeFiltersToAdHocVariable() { |
|
||||||
const dashboard = getDashboardSceneFor(this); |
|
||||||
|
|
||||||
const adhoc = dashboard.state.$variables?.state.variables.find((v) => v instanceof AdHocFiltersVariable); |
|
||||||
|
|
||||||
if (!adhoc) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
const filters = convertScopesToAdHocFilters(this.value); |
|
||||||
|
|
||||||
adhoc.setState({ |
|
||||||
baseFilters: filters, |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,13 @@ |
|||||||
|
import { ReactNode } from 'react'; |
||||||
|
|
||||||
|
import { ScopesContext } from '@grafana/runtime'; |
||||||
|
|
||||||
|
import { ScopesService } from './ScopesService'; |
||||||
|
|
||||||
|
interface ScopesContextProviderProps { |
||||||
|
children: ReactNode; |
||||||
|
} |
||||||
|
|
||||||
|
export const ScopesContextProvider = ({ children }: ScopesContextProviderProps) => { |
||||||
|
return <ScopesContext.Provider value={ScopesService.instance}>{children}</ScopesContext.Provider>; |
||||||
|
}; |
@ -1,9 +0,0 @@ |
|||||||
import { scopesDashboardsScene } from './instance'; |
|
||||||
|
|
||||||
export function ScopesDashboards() { |
|
||||||
if (!scopesDashboardsScene) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
return <scopesDashboardsScene.Component model={scopesDashboardsScene} />; |
|
||||||
} |
|
@ -1,87 +0,0 @@ |
|||||||
import { isEqual } from 'lodash'; |
|
||||||
|
|
||||||
import { |
|
||||||
SceneObjectBase, |
|
||||||
SceneObjectState, |
|
||||||
SceneObjectUrlSyncConfig, |
|
||||||
SceneObjectUrlValues, |
|
||||||
SceneObjectWithUrlSync, |
|
||||||
} from '@grafana/scenes'; |
|
||||||
|
|
||||||
import { scopesSelectorScene } from './instance'; |
|
||||||
import { disableScopes, enableScopes, enterScopesReadOnly, exitScopesReadOnly, getSelectedScopes } from './utils'; |
|
||||||
|
|
||||||
interface ScopesFacadeState extends SceneObjectState { |
|
||||||
// A callback that will be executed when new scopes are set
|
|
||||||
handler?: (facade: ScopesFacade) => void; |
|
||||||
// The render count is a workaround to force the URL sync manager to update the URL with the latest scopes
|
|
||||||
// Basically it starts at 0, and it is increased with every scopes value update
|
|
||||||
renderCount?: number; |
|
||||||
} |
|
||||||
|
|
||||||
export class ScopesFacade extends SceneObjectBase<ScopesFacadeState> implements SceneObjectWithUrlSync { |
|
||||||
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scopes'] }); |
|
||||||
|
|
||||||
public constructor(state: ScopesFacadeState) { |
|
||||||
super({ |
|
||||||
...state, |
|
||||||
renderCount: 0, |
|
||||||
}); |
|
||||||
|
|
||||||
this.addActivationHandler(this._activationHandler); |
|
||||||
} |
|
||||||
|
|
||||||
public getUrlState() { |
|
||||||
return { |
|
||||||
scopes: this.value.map(({ metadata }) => metadata.name), |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
public updateFromUrl(values: SceneObjectUrlValues) { |
|
||||||
if (!values.scopes && !scopesSelectorScene?.state.isEnabled) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
let scopeNames = values.scopes ?? []; |
|
||||||
scopeNames = Array.isArray(scopeNames) ? scopeNames : [scopeNames]; |
|
||||||
|
|
||||||
scopesSelectorScene?.updateScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] }))); |
|
||||||
} |
|
||||||
|
|
||||||
private _activationHandler = () => { |
|
||||||
this.enable(); |
|
||||||
|
|
||||||
this._subs.add( |
|
||||||
scopesSelectorScene?.subscribeToState((newState, prevState) => { |
|
||||||
if (!newState.isLoadingScopes && (prevState.isLoadingScopes || !isEqual(newState.scopes, prevState.scopes))) { |
|
||||||
this.setState({ renderCount: (this.state.renderCount ?? 0) + 1 }); |
|
||||||
this.state.handler?.(this); |
|
||||||
} |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
return () => { |
|
||||||
this.disable(); |
|
||||||
}; |
|
||||||
}; |
|
||||||
|
|
||||||
public get value() { |
|
||||||
return getSelectedScopes(); |
|
||||||
} |
|
||||||
|
|
||||||
public enable() { |
|
||||||
enableScopes(); |
|
||||||
} |
|
||||||
|
|
||||||
public disable() { |
|
||||||
disableScopes(); |
|
||||||
} |
|
||||||
|
|
||||||
public enterReadOnly() { |
|
||||||
enterScopesReadOnly(); |
|
||||||
} |
|
||||||
|
|
||||||
public exitReadOnly() { |
|
||||||
exitScopesReadOnly(); |
|
||||||
} |
|
||||||
} |
|
@ -1,9 +0,0 @@ |
|||||||
import { scopesSelectorScene } from './instance'; |
|
||||||
|
|
||||||
export function ScopesSelector() { |
|
||||||
if (!scopesSelectorScene) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
return <scopesSelectorScene.Component model={scopesSelectorScene} />; |
|
||||||
} |
|
@ -0,0 +1,63 @@ |
|||||||
|
import { Scope } from '@grafana/data'; |
||||||
|
import { config, ScopesContextValue, ScopesContextValueState } from '@grafana/runtime'; |
||||||
|
|
||||||
|
import { ScopesServiceBase } from './ScopesServiceBase'; |
||||||
|
import { ScopesSelectorService } from './selector/ScopesSelectorService'; |
||||||
|
|
||||||
|
export class ScopesService extends ScopesServiceBase<ScopesContextValueState> implements ScopesContextValue { |
||||||
|
static #instance: ScopesService | undefined = undefined; |
||||||
|
|
||||||
|
private constructor() { |
||||||
|
super({ |
||||||
|
drawerOpened: false, |
||||||
|
enabled: false, |
||||||
|
loading: false, |
||||||
|
readOnly: false, |
||||||
|
value: [], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public static get instance(): ScopesService | undefined { |
||||||
|
if (!ScopesService.#instance && config.featureToggles.scopeFilters) { |
||||||
|
ScopesService.#instance = new ScopesService(); |
||||||
|
} |
||||||
|
|
||||||
|
return ScopesService.#instance; |
||||||
|
} |
||||||
|
|
||||||
|
public changeScopes = (scopeNames: string[]) => ScopesSelectorService.instance?.changeScopes(scopeNames); |
||||||
|
|
||||||
|
public setReadOnly = (readOnly: boolean) => { |
||||||
|
if (this.state.readOnly !== readOnly) { |
||||||
|
this.updateState({ readOnly }); |
||||||
|
} |
||||||
|
|
||||||
|
if (readOnly && ScopesSelectorService.instance?.state.opened) { |
||||||
|
ScopesSelectorService.instance?.closeAndReset(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
public setEnabled = (enabled: boolean) => { |
||||||
|
if (this.state.enabled !== enabled) { |
||||||
|
this.updateState({ enabled }); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
public setScopes = (scopes: Scope[]) => this.updateState({ value: scopes }); |
||||||
|
|
||||||
|
public setLoading = (loading: boolean) => { |
||||||
|
if (this.state.loading !== loading) { |
||||||
|
this.updateState({ loading }); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
public setDrawerOpened = (drawerOpened: boolean) => { |
||||||
|
if (this.state.drawerOpened !== drawerOpened) { |
||||||
|
this.updateState({ drawerOpened }); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
public reset = () => { |
||||||
|
ScopesService.#instance = undefined; |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,31 @@ |
|||||||
|
import { BehaviorSubject, Observable, pairwise, Subscription } from 'rxjs'; |
||||||
|
|
||||||
|
import { getAPINamespace } from '../../api/utils'; |
||||||
|
|
||||||
|
export abstract class ScopesServiceBase<T> { |
||||||
|
private _state: BehaviorSubject<T>; |
||||||
|
protected _fetchSub: Subscription | undefined; |
||||||
|
protected _apiGroup = 'scope.grafana.app'; |
||||||
|
protected _apiVersion = 'v0alpha1'; |
||||||
|
protected _apiNamespace = getAPINamespace(); |
||||||
|
|
||||||
|
protected constructor(initialState: T) { |
||||||
|
this._state = new BehaviorSubject<T>(Object.freeze(initialState)); |
||||||
|
} |
||||||
|
|
||||||
|
public get state(): T { |
||||||
|
return this._state.getValue(); |
||||||
|
} |
||||||
|
|
||||||
|
public get stateObservable(): Observable<T> { |
||||||
|
return this._state.asObservable(); |
||||||
|
} |
||||||
|
|
||||||
|
public subscribeToState = (cb: (newState: T, prevState: T) => void): Subscription => { |
||||||
|
return this._state.pipe(pairwise()).subscribe(([prevState, newState]) => cb(newState, prevState)); |
||||||
|
}; |
||||||
|
|
||||||
|
protected updateState = (newState: Partial<T>) => { |
||||||
|
this._state.next(Object.freeze({ ...this.state, ...newState })); |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,114 @@ |
|||||||
|
import { css, cx } from '@emotion/css'; |
||||||
|
import { useObservable } from 'react-use'; |
||||||
|
import { Observable } from 'rxjs'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { useScopes } from '@grafana/runtime'; |
||||||
|
import { Button, LoadingPlaceholder, ScrollContainer, useStyles2 } from '@grafana/ui'; |
||||||
|
import { t, Trans } from 'app/core/internationalization'; |
||||||
|
|
||||||
|
import { ScopesDashboardsService } from './ScopesDashboardsService'; |
||||||
|
import { ScopesDashboardsTree } from './ScopesDashboardsTree'; |
||||||
|
import { ScopesDashboardsTreeSearch } from './ScopesDashboardsTreeSearch'; |
||||||
|
|
||||||
|
export function ScopesDashboards() { |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
const scopes = useScopes(); |
||||||
|
|
||||||
|
const scopesDashboardsService = ScopesDashboardsService.instance; |
||||||
|
|
||||||
|
useObservable(scopesDashboardsService?.stateObservable ?? new Observable(), scopesDashboardsService?.state); |
||||||
|
|
||||||
|
if ( |
||||||
|
!scopes || |
||||||
|
!scopesDashboardsService || |
||||||
|
!scopes.state.enabled || |
||||||
|
!scopes.state.drawerOpened || |
||||||
|
scopes.state.readOnly |
||||||
|
) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const { loading, forScopeNames, dashboards, searchQuery, filteredFolders } = scopesDashboardsService.state; |
||||||
|
const { changeSearchQuery, updateFolder, clearSearchQuery } = scopesDashboardsService; |
||||||
|
|
||||||
|
if (!loading) { |
||||||
|
if (forScopeNames.length === 0) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cx(styles.container, styles.noResultsContainer)} |
||||||
|
data-testid="scopes-dashboards-notFoundNoScopes" |
||||||
|
> |
||||||
|
<Trans i18nKey="scopes.dashboards.noResultsNoScopes">No scopes selected</Trans> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} else if (dashboards.length === 0) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cx(styles.container, styles.noResultsContainer)} |
||||||
|
data-testid="scopes-dashboards-notFoundForScope" |
||||||
|
> |
||||||
|
<Trans i18nKey="scopes.dashboards.noResultsForScopes">No dashboards found for the selected scopes</Trans> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.container} data-testid="scopes-dashboards-container"> |
||||||
|
<ScopesDashboardsTreeSearch disabled={loading} query={searchQuery} onChange={changeSearchQuery} /> |
||||||
|
|
||||||
|
{loading ? ( |
||||||
|
<LoadingPlaceholder |
||||||
|
className={styles.loadingIndicator} |
||||||
|
text={t('scopes.dashboards.loading', 'Loading dashboards')} |
||||||
|
data-testid="scopes-dashboards-loading" |
||||||
|
/> |
||||||
|
) : filteredFolders[''] ? ( |
||||||
|
<ScrollContainer> |
||||||
|
<ScopesDashboardsTree folders={filteredFolders} folderPath={['']} onFolderUpdate={updateFolder} /> |
||||||
|
</ScrollContainer> |
||||||
|
) : ( |
||||||
|
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter"> |
||||||
|
<Trans i18nKey="scopes.dashboards.noResultsForFilter">No results found for your query</Trans> |
||||||
|
|
||||||
|
<Button |
||||||
|
variant="secondary" |
||||||
|
onClick={clearSearchQuery} |
||||||
|
data-testid="scopes-dashboards-notFoundForFilter-clear" |
||||||
|
> |
||||||
|
<Trans i18nKey="scopes.dashboards.noResultsForFilterClear">Clear search</Trans> |
||||||
|
</Button> |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => { |
||||||
|
return { |
||||||
|
container: css({ |
||||||
|
backgroundColor: theme.colors.background.primary, |
||||||
|
borderRight: `1px solid ${theme.colors.border.weak}`, |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
height: '100%', |
||||||
|
gap: theme.spacing(1), |
||||||
|
padding: theme.spacing(2), |
||||||
|
width: theme.spacing(37.5), |
||||||
|
}), |
||||||
|
noResultsContainer: css({ |
||||||
|
alignItems: 'center', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: theme.spacing(1), |
||||||
|
height: '100%', |
||||||
|
justifyContent: 'center', |
||||||
|
margin: 0, |
||||||
|
textAlign: 'center', |
||||||
|
}), |
||||||
|
loadingIndicator: css({ |
||||||
|
alignSelf: 'center', |
||||||
|
}), |
||||||
|
}; |
||||||
|
}; |
@ -0,0 +1,217 @@ |
|||||||
|
import { isEqual } from 'lodash'; |
||||||
|
import { finalize, from } from 'rxjs'; |
||||||
|
|
||||||
|
import { ScopeDashboardBinding } from '@grafana/data'; |
||||||
|
import { config, getBackendSrv } from '@grafana/runtime'; |
||||||
|
|
||||||
|
import { ScopesService } from '../ScopesService'; |
||||||
|
import { ScopesServiceBase } from '../ScopesServiceBase'; |
||||||
|
|
||||||
|
import { SuggestedDashboardsFoldersMap } from './types'; |
||||||
|
|
||||||
|
interface ScopesDashboardsServiceState { |
||||||
|
// by keeping a track of the raw response, it's much easier to check if we got any dashboards for the currently selected scopes
|
||||||
|
dashboards: ScopeDashboardBinding[]; |
||||||
|
// a filtered version of the `folders` property. this prevents a lot of unnecessary parsings in React renders
|
||||||
|
filteredFolders: SuggestedDashboardsFoldersMap; |
||||||
|
// this is a grouping in folders of the `dashboards` property. it is used for filtering the dashboards and folders when the search query changes
|
||||||
|
folders: SuggestedDashboardsFoldersMap; |
||||||
|
forScopeNames: string[]; |
||||||
|
loading: boolean; |
||||||
|
searchQuery: string; |
||||||
|
} |
||||||
|
|
||||||
|
export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsServiceState> { |
||||||
|
static #instance: ScopesDashboardsService | undefined = undefined; |
||||||
|
|
||||||
|
private constructor() { |
||||||
|
super({ |
||||||
|
dashboards: [], |
||||||
|
filteredFolders: {}, |
||||||
|
folders: {}, |
||||||
|
forScopeNames: [], |
||||||
|
loading: false, |
||||||
|
searchQuery: '', |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public static get instance(): ScopesDashboardsService | undefined { |
||||||
|
if (!ScopesDashboardsService.#instance && config.featureToggles.scopeFilters) { |
||||||
|
ScopesDashboardsService.#instance = new ScopesDashboardsService(); |
||||||
|
} |
||||||
|
|
||||||
|
return ScopesDashboardsService.#instance; |
||||||
|
} |
||||||
|
|
||||||
|
public updateFolder = (path: string[], expanded: boolean) => { |
||||||
|
let folders = { ...this.state.folders }; |
||||||
|
let filteredFolders = { ...this.state.filteredFolders }; |
||||||
|
let currentLevelFolders: SuggestedDashboardsFoldersMap = folders; |
||||||
|
let currentLevelFilteredFolders: SuggestedDashboardsFoldersMap = filteredFolders; |
||||||
|
|
||||||
|
for (let idx = 0; idx < path.length - 1; idx++) { |
||||||
|
currentLevelFolders = currentLevelFolders[path[idx]].folders; |
||||||
|
currentLevelFilteredFolders = currentLevelFilteredFolders[path[idx]].folders; |
||||||
|
} |
||||||
|
|
||||||
|
const name = path[path.length - 1]; |
||||||
|
const currentFolder = currentLevelFolders[name]; |
||||||
|
const currentFilteredFolder = currentLevelFilteredFolders[name]; |
||||||
|
|
||||||
|
currentFolder.expanded = expanded; |
||||||
|
currentFilteredFolder.expanded = expanded; |
||||||
|
|
||||||
|
this.updateState({ folders, filteredFolders }); |
||||||
|
}; |
||||||
|
|
||||||
|
public changeSearchQuery = (searchQuery: string) => { |
||||||
|
searchQuery = searchQuery ?? ''; |
||||||
|
|
||||||
|
const filteredFolders = this.filterFolders(this.state.folders, searchQuery); |
||||||
|
|
||||||
|
this.updateState({ filteredFolders, searchQuery }); |
||||||
|
}; |
||||||
|
|
||||||
|
public clearSearchQuery = () => { |
||||||
|
this.changeSearchQuery(''); |
||||||
|
}; |
||||||
|
|
||||||
|
public fetchDashboards = async (forScopeNames: string[]) => { |
||||||
|
if (isEqual(this.state.forScopeNames, forScopeNames)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this._fetchSub?.unsubscribe(); |
||||||
|
|
||||||
|
if (forScopeNames.length === 0) { |
||||||
|
this.updateState({ |
||||||
|
dashboards: [], |
||||||
|
filteredFolders: {}, |
||||||
|
folders: {}, |
||||||
|
forScopeNames: [], |
||||||
|
loading: false, |
||||||
|
}); |
||||||
|
|
||||||
|
ScopesService.instance?.setDrawerOpened(false); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.updateState({ forScopeNames, loading: true }); |
||||||
|
|
||||||
|
this._fetchSub = from(this.fetchDashboardsApi(forScopeNames)) |
||||||
|
.pipe( |
||||||
|
finalize(() => { |
||||||
|
this.updateState({ loading: false }); |
||||||
|
}) |
||||||
|
) |
||||||
|
.subscribe((dashboards) => { |
||||||
|
const folders = this.groupDashboards(dashboards); |
||||||
|
const filteredFolders = this.filterFolders(folders, this.state.searchQuery); |
||||||
|
|
||||||
|
this.updateState({ dashboards, filteredFolders, folders, loading: false }); |
||||||
|
|
||||||
|
ScopesService.instance?.setDrawerOpened(dashboards.length > 0); |
||||||
|
|
||||||
|
this._fetchSub?.unsubscribe(); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
public groupDashboards = (dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap => { |
||||||
|
return dashboards.reduce<SuggestedDashboardsFoldersMap>( |
||||||
|
(acc, dashboard) => { |
||||||
|
const rootNode = acc['']; |
||||||
|
const groups = dashboard.status.groups ?? []; |
||||||
|
|
||||||
|
groups.forEach((group) => { |
||||||
|
if (group && !rootNode.folders[group]) { |
||||||
|
rootNode.folders[group] = { |
||||||
|
title: group, |
||||||
|
expanded: false, |
||||||
|
folders: {}, |
||||||
|
dashboards: {}, |
||||||
|
}; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
const targets = |
||||||
|
groups.length > 0 |
||||||
|
? groups.map((group) => (group === '' ? rootNode.dashboards : rootNode.folders[group].dashboards)) |
||||||
|
: [rootNode.dashboards]; |
||||||
|
|
||||||
|
targets.forEach((target) => { |
||||||
|
if (!target[dashboard.spec.dashboard]) { |
||||||
|
target[dashboard.spec.dashboard] = { |
||||||
|
dashboard: dashboard.spec.dashboard, |
||||||
|
dashboardTitle: dashboard.status.dashboardTitle, |
||||||
|
items: [], |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
target[dashboard.spec.dashboard].items.push(dashboard); |
||||||
|
}); |
||||||
|
|
||||||
|
return acc; |
||||||
|
}, |
||||||
|
{ |
||||||
|
'': { |
||||||
|
title: '', |
||||||
|
expanded: true, |
||||||
|
folders: {}, |
||||||
|
dashboards: {}, |
||||||
|
}, |
||||||
|
} |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
public filterFolders = (folders: SuggestedDashboardsFoldersMap, query: string): SuggestedDashboardsFoldersMap => { |
||||||
|
query = (query ?? '').toLowerCase(); |
||||||
|
|
||||||
|
return Object.entries(folders).reduce<SuggestedDashboardsFoldersMap>((acc, [folderId, folder]) => { |
||||||
|
// If folder matches the query, we show everything inside
|
||||||
|
if (folder.title.toLowerCase().includes(query)) { |
||||||
|
acc[folderId] = { |
||||||
|
...folder, |
||||||
|
expanded: true, |
||||||
|
}; |
||||||
|
|
||||||
|
return acc; |
||||||
|
} |
||||||
|
|
||||||
|
const filteredFolders = this.filterFolders(folder.folders, query); |
||||||
|
const filteredDashboards = Object.entries(folder.dashboards).filter(([_, dashboard]) => |
||||||
|
dashboard.dashboardTitle.toLowerCase().includes(query) |
||||||
|
); |
||||||
|
|
||||||
|
if (Object.keys(filteredFolders).length > 0 || filteredDashboards.length > 0) { |
||||||
|
acc[folderId] = { |
||||||
|
...folder, |
||||||
|
expanded: true, |
||||||
|
folders: filteredFolders, |
||||||
|
dashboards: Object.fromEntries(filteredDashboards), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return acc; |
||||||
|
}, {}); |
||||||
|
}; |
||||||
|
|
||||||
|
public fetchDashboardsApi = async (scopeNames: string[]): Promise<ScopeDashboardBinding[]> => { |
||||||
|
try { |
||||||
|
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>( |
||||||
|
`/apis/${this._apiGroup}/${this._apiVersion}/namespaces/${this._apiNamespace}/find/scope_dashboard_bindings`, |
||||||
|
{ |
||||||
|
scope: scopeNames, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
return response?.items ?? []; |
||||||
|
} catch (err) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
public reset = () => { |
||||||
|
ScopesDashboardsService.#instance = undefined; |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
import { ScopeDashboardBinding } from '@grafana/data'; |
||||||
|
|
||||||
|
export interface SuggestedDashboard { |
||||||
|
dashboard: string; |
||||||
|
dashboardTitle: string; |
||||||
|
items: ScopeDashboardBinding[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface SuggestedDashboardsFolder { |
||||||
|
title: string; |
||||||
|
expanded: boolean; |
||||||
|
folders: SuggestedDashboardsFoldersMap; |
||||||
|
dashboards: SuggestedDashboardsMap; |
||||||
|
} |
||||||
|
|
||||||
|
export type SuggestedDashboardsMap = Record<string, SuggestedDashboard>; |
||||||
|
export type SuggestedDashboardsFoldersMap = Record<string, SuggestedDashboardsFolder>; |
||||||
|
|
||||||
|
export type OnFolderUpdate = (path: string[], expanded: boolean) => void; |
@ -1,15 +0,0 @@ |
|||||||
export { initializeScopes } from './instance'; |
|
||||||
export { ScopesDashboards } from './ScopesDashboards'; |
|
||||||
/* eslint-disable */ |
|
||||||
export { ScopesFacade } from './ScopesFacadeScene'; |
|
||||||
export { ScopesSelector } from './ScopesSelector'; |
|
||||||
export { useScopesDashboardsState } from './useScopesDashboardsState'; |
|
||||||
export { |
|
||||||
disableScopes, |
|
||||||
enableScopes, |
|
||||||
enterScopesReadOnly, |
|
||||||
exitScopesReadOnly, |
|
||||||
getClosestScopesFacade, |
|
||||||
getSelectedScopes, |
|
||||||
getSelectedScopesNames, |
|
||||||
} from './utils'; |
|
@ -1,17 +0,0 @@ |
|||||||
import { config } from '@grafana/runtime'; |
|
||||||
|
|
||||||
import { ScopesDashboardsScene } from './internal/ScopesDashboardsScene'; |
|
||||||
import { ScopesSelectorScene } from './internal/ScopesSelectorScene'; |
|
||||||
|
|
||||||
export let scopesDashboardsScene: ScopesDashboardsScene | null = null; |
|
||||||
export let scopesSelectorScene: ScopesSelectorScene | null = null; |
|
||||||
|
|
||||||
export function initializeScopes() { |
|
||||||
if (config.featureToggles.scopeFilters) { |
|
||||||
scopesSelectorScene = new ScopesSelectorScene(); |
|
||||||
scopesDashboardsScene = new ScopesDashboardsScene(); |
|
||||||
|
|
||||||
scopesSelectorScene.setState({ dashboards: scopesDashboardsScene.getRef() }); |
|
||||||
scopesDashboardsScene.setState({ selector: scopesSelectorScene.getRef() }); |
|
||||||
} |
|
||||||
} |
|
@ -1,275 +0,0 @@ |
|||||||
import { css, cx } from '@emotion/css'; |
|
||||||
import { isEqual } from 'lodash'; |
|
||||||
import { finalize, from, Subscription } from 'rxjs'; |
|
||||||
|
|
||||||
import { GrafanaTheme2, ScopeDashboardBinding } from '@grafana/data'; |
|
||||||
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes'; |
|
||||||
import { Button, LoadingPlaceholder, ScrollContainer, useStyles2 } from '@grafana/ui'; |
|
||||||
import { t, Trans } from 'app/core/internationalization'; |
|
||||||
|
|
||||||
import { ScopesDashboardsTree } from './ScopesDashboardsTree'; |
|
||||||
import { ScopesDashboardsTreeSearch } from './ScopesDashboardsTreeSearch'; |
|
||||||
import { ScopesSelectorScene } from './ScopesSelectorScene'; |
|
||||||
import { fetchDashboards } from './api'; |
|
||||||
import { SuggestedDashboardsFoldersMap } from './types'; |
|
||||||
import { filterFolders, groupDashboards } from './utils'; |
|
||||||
|
|
||||||
export interface ScopesDashboardsSceneState extends SceneObjectState { |
|
||||||
selector: SceneObjectRef<ScopesSelectorScene> | null; |
|
||||||
// by keeping a track of the raw response, it's much easier to check if we got any dashboards for the currently selected scopes
|
|
||||||
dashboards: ScopeDashboardBinding[]; |
|
||||||
// this is a grouping in folders of the `dashboards` property. it is used for filtering the dashboards and folders when the search query changes
|
|
||||||
folders: SuggestedDashboardsFoldersMap; |
|
||||||
// a filtered version of the `folders` property. this prevents a lot of unnecessary parsings in React renders
|
|
||||||
filteredFolders: SuggestedDashboardsFoldersMap; |
|
||||||
forScopeNames: string[]; |
|
||||||
isLoading: boolean; |
|
||||||
isPanelOpened: boolean; |
|
||||||
isEnabled: boolean; |
|
||||||
isReadOnly: boolean; |
|
||||||
searchQuery: string; |
|
||||||
} |
|
||||||
|
|
||||||
export const getInitialDashboardsState: () => Omit<ScopesDashboardsSceneState, 'selector'> = () => ({ |
|
||||||
dashboards: [], |
|
||||||
folders: {}, |
|
||||||
filteredFolders: {}, |
|
||||||
forScopeNames: [], |
|
||||||
isLoading: false, |
|
||||||
isPanelOpened: false, |
|
||||||
isEnabled: false, |
|
||||||
isReadOnly: false, |
|
||||||
searchQuery: '', |
|
||||||
}); |
|
||||||
|
|
||||||
export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsSceneState> { |
|
||||||
static Component = ScopesDashboardsSceneRenderer; |
|
||||||
|
|
||||||
private dashboardsFetchingSub: Subscription | undefined; |
|
||||||
|
|
||||||
constructor() { |
|
||||||
super({ |
|
||||||
selector: null, |
|
||||||
...getInitialDashboardsState(), |
|
||||||
}); |
|
||||||
|
|
||||||
this.addActivationHandler(() => { |
|
||||||
return () => { |
|
||||||
this.dashboardsFetchingSub?.unsubscribe(); |
|
||||||
}; |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
public async fetchDashboards(scopeNames: string[]) { |
|
||||||
if (isEqual(this.state.forScopeNames, scopeNames)) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
this.dashboardsFetchingSub?.unsubscribe(); |
|
||||||
|
|
||||||
this.setState({ forScopeNames: scopeNames }); |
|
||||||
|
|
||||||
if (scopeNames.length === 0) { |
|
||||||
return this.setState({ |
|
||||||
dashboards: [], |
|
||||||
folders: {}, |
|
||||||
filteredFolders: {}, |
|
||||||
forScopeNames: [], |
|
||||||
isLoading: false, |
|
||||||
isPanelOpened: false, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
this.setState({ isLoading: true }); |
|
||||||
|
|
||||||
this.dashboardsFetchingSub = from(fetchDashboards(scopeNames)) |
|
||||||
.pipe( |
|
||||||
finalize(() => { |
|
||||||
this.setState({ isLoading: false }); |
|
||||||
}) |
|
||||||
) |
|
||||||
.subscribe((dashboards) => { |
|
||||||
const folders = groupDashboards(dashboards); |
|
||||||
const filteredFolders = filterFolders(folders, this.state.searchQuery); |
|
||||||
|
|
||||||
this.setState({ |
|
||||||
dashboards, |
|
||||||
folders, |
|
||||||
filteredFolders, |
|
||||||
isLoading: false, |
|
||||||
isPanelOpened: scopeNames.length > 0, |
|
||||||
}); |
|
||||||
|
|
||||||
this.dashboardsFetchingSub?.unsubscribe(); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
public changeSearchQuery(searchQuery: string) { |
|
||||||
searchQuery = searchQuery ?? ''; |
|
||||||
|
|
||||||
this.setState({ |
|
||||||
filteredFolders: filterFolders(this.state.folders, searchQuery), |
|
||||||
searchQuery, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
public updateFolder(path: string[], isExpanded: boolean) { |
|
||||||
let folders = { ...this.state.folders }; |
|
||||||
let filteredFolders = { ...this.state.filteredFolders }; |
|
||||||
let currentLevelFolders: SuggestedDashboardsFoldersMap = folders; |
|
||||||
let currentLevelFilteredFolders: SuggestedDashboardsFoldersMap = filteredFolders; |
|
||||||
|
|
||||||
for (let idx = 0; idx < path.length - 1; idx++) { |
|
||||||
currentLevelFolders = currentLevelFolders[path[idx]].folders; |
|
||||||
currentLevelFilteredFolders = currentLevelFilteredFolders[path[idx]].folders; |
|
||||||
} |
|
||||||
|
|
||||||
const name = path[path.length - 1]; |
|
||||||
const currentFolder = currentLevelFolders[name]; |
|
||||||
const currentFilteredFolder = currentLevelFilteredFolders[name]; |
|
||||||
|
|
||||||
currentFolder.isExpanded = isExpanded; |
|
||||||
currentFilteredFolder.isExpanded = isExpanded; |
|
||||||
|
|
||||||
this.setState({ folders, filteredFolders }); |
|
||||||
} |
|
||||||
|
|
||||||
public togglePanel() { |
|
||||||
if (this.state.isPanelOpened) { |
|
||||||
this.closePanel(); |
|
||||||
} else { |
|
||||||
this.openPanel(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public openPanel() { |
|
||||||
if (this.state.isPanelOpened) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
this.setState({ isPanelOpened: true }); |
|
||||||
} |
|
||||||
|
|
||||||
public closePanel() { |
|
||||||
if (!this.state.isPanelOpened) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
this.setState({ isPanelOpened: false }); |
|
||||||
} |
|
||||||
|
|
||||||
public enable() { |
|
||||||
this.setState({ isEnabled: true }); |
|
||||||
} |
|
||||||
|
|
||||||
public disable() { |
|
||||||
this.setState({ isEnabled: false }); |
|
||||||
} |
|
||||||
|
|
||||||
public enterReadOnly() { |
|
||||||
this.setState({ isReadOnly: true }); |
|
||||||
} |
|
||||||
|
|
||||||
public exitReadOnly() { |
|
||||||
this.setState({ isReadOnly: false }); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<ScopesDashboardsScene>) { |
|
||||||
const { dashboards, filteredFolders, forScopeNames, isLoading, isPanelOpened, isEnabled, isReadOnly, searchQuery } = |
|
||||||
model.useState(); |
|
||||||
|
|
||||||
const styles = useStyles2(getStyles); |
|
||||||
|
|
||||||
if (!isEnabled || !isPanelOpened || isReadOnly) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
if (!isLoading) { |
|
||||||
if (forScopeNames.length === 0) { |
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={cx(styles.container, styles.noResultsContainer)} |
|
||||||
data-testid="scopes-dashboards-notFoundNoScopes" |
|
||||||
> |
|
||||||
<Trans i18nKey="scopes.dashboards.noResultsNoScopes">No scopes selected</Trans> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} else if (dashboards.length === 0) { |
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={cx(styles.container, styles.noResultsContainer)} |
|
||||||
data-testid="scopes-dashboards-notFoundForScope" |
|
||||||
> |
|
||||||
<Trans i18nKey="scopes.dashboards.noResultsForScopes">No dashboards found for the selected scopes</Trans> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={styles.container} data-testid="scopes-dashboards-container"> |
|
||||||
<ScopesDashboardsTreeSearch |
|
||||||
disabled={isLoading} |
|
||||||
query={searchQuery} |
|
||||||
onChange={(value) => model.changeSearchQuery(value)} |
|
||||||
/> |
|
||||||
|
|
||||||
{isLoading ? ( |
|
||||||
<LoadingPlaceholder |
|
||||||
className={styles.loadingIndicator} |
|
||||||
text={t('scopes.dashboards.loading', 'Loading dashboards')} |
|
||||||
data-testid="scopes-dashboards-loading" |
|
||||||
/> |
|
||||||
) : filteredFolders[''] ? ( |
|
||||||
<ScrollContainer> |
|
||||||
<ScopesDashboardsTree |
|
||||||
folders={filteredFolders} |
|
||||||
folderPath={['']} |
|
||||||
onFolderUpdate={(path, isExpanded) => model.updateFolder(path, isExpanded)} |
|
||||||
/> |
|
||||||
</ScrollContainer> |
|
||||||
) : ( |
|
||||||
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter"> |
|
||||||
<Trans i18nKey="scopes.dashboards.noResultsForFilter">No results found for your query</Trans> |
|
||||||
|
|
||||||
<Button |
|
||||||
variant="secondary" |
|
||||||
onClick={() => model.changeSearchQuery('')} |
|
||||||
data-testid="scopes-dashboards-notFoundForFilter-clear" |
|
||||||
> |
|
||||||
<Trans i18nKey="scopes.dashboards.noResultsForFilterClear">Clear search</Trans> |
|
||||||
</Button> |
|
||||||
</p> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => { |
|
||||||
return { |
|
||||||
container: css({ |
|
||||||
backgroundColor: theme.colors.background.primary, |
|
||||||
borderRight: `1px solid ${theme.colors.border.weak}`, |
|
||||||
display: 'flex', |
|
||||||
flexDirection: 'column', |
|
||||||
height: '100%', |
|
||||||
gap: theme.spacing(1), |
|
||||||
padding: theme.spacing(2), |
|
||||||
width: theme.spacing(37.5), |
|
||||||
}), |
|
||||||
noResultsContainer: css({ |
|
||||||
alignItems: 'center', |
|
||||||
display: 'flex', |
|
||||||
flexDirection: 'column', |
|
||||||
gap: theme.spacing(1), |
|
||||||
height: '100%', |
|
||||||
justifyContent: 'center', |
|
||||||
margin: 0, |
|
||||||
textAlign: 'center', |
|
||||||
}), |
|
||||||
loadingIndicator: css({ |
|
||||||
alignSelf: 'center', |
|
||||||
}), |
|
||||||
}; |
|
||||||
}; |
|
@ -1,410 +0,0 @@ |
|||||||
import { css } from '@emotion/css'; |
|
||||||
import { isEqual } from 'lodash'; |
|
||||||
import { finalize, from, Subscription } from 'rxjs'; |
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data'; |
|
||||||
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes'; |
|
||||||
import { Button, Drawer, IconButton, Spinner, useStyles2 } from '@grafana/ui'; |
|
||||||
import { useGrafana } from 'app/core/context/GrafanaContext'; |
|
||||||
import { t, Trans } from 'app/core/internationalization'; |
|
||||||
|
|
||||||
import { ScopesDashboardsScene } from './ScopesDashboardsScene'; |
|
||||||
import { ScopesInput } from './ScopesInput'; |
|
||||||
import { ScopesTree } from './ScopesTree'; |
|
||||||
import { fetchNodes, fetchScope, fetchSelectedScopes } from './api'; |
|
||||||
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types'; |
|
||||||
import { getBasicScope, getScopesAndTreeScopesWithPaths, getTreeScopesFromSelectedScopes } from './utils'; |
|
||||||
|
|
||||||
export interface ScopesSelectorSceneState extends SceneObjectState { |
|
||||||
dashboards: SceneObjectRef<ScopesDashboardsScene> | null; |
|
||||||
nodes: NodesMap; |
|
||||||
loadingNodeName: string | undefined; |
|
||||||
scopes: SelectedScope[]; |
|
||||||
treeScopes: TreeScope[]; |
|
||||||
isReadOnly: boolean; |
|
||||||
isLoadingScopes: boolean; |
|
||||||
isPickerOpened: boolean; |
|
||||||
isEnabled: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
export const initialSelectorState: Omit<ScopesSelectorSceneState, 'dashboards'> = { |
|
||||||
nodes: { |
|
||||||
'': { |
|
||||||
name: '', |
|
||||||
reason: NodeReason.Result, |
|
||||||
nodeType: 'container', |
|
||||||
title: '', |
|
||||||
isExpandable: true, |
|
||||||
isSelectable: false, |
|
||||||
isExpanded: true, |
|
||||||
query: '', |
|
||||||
nodes: {}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
loadingNodeName: undefined, |
|
||||||
scopes: [], |
|
||||||
treeScopes: [], |
|
||||||
isReadOnly: false, |
|
||||||
isLoadingScopes: false, |
|
||||||
isPickerOpened: false, |
|
||||||
isEnabled: false, |
|
||||||
}; |
|
||||||
|
|
||||||
export class ScopesSelectorScene extends SceneObjectBase<ScopesSelectorSceneState> { |
|
||||||
static Component = ScopesSelectorSceneRenderer; |
|
||||||
|
|
||||||
private nodesFetchingSub: Subscription | undefined; |
|
||||||
|
|
||||||
constructor() { |
|
||||||
super({ |
|
||||||
dashboards: null, |
|
||||||
...initialSelectorState, |
|
||||||
}); |
|
||||||
|
|
||||||
this.addActivationHandler(() => { |
|
||||||
// Only fetch base nodes on activation when there are no nodes fetched
|
|
||||||
// This prevents an issue where base nodes are overwritten upon re-activations
|
|
||||||
if (Object.keys(this.state.nodes[''].nodes).length === 0) { |
|
||||||
this.fetchBaseNodes(); |
|
||||||
} |
|
||||||
|
|
||||||
return () => { |
|
||||||
this.nodesFetchingSub?.unsubscribe(); |
|
||||||
}; |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
public fetchBaseNodes() { |
|
||||||
return this.updateNode([''], true, ''); |
|
||||||
} |
|
||||||
|
|
||||||
public async updateNode(path: string[], isExpanded: boolean, query: string) { |
|
||||||
this.nodesFetchingSub?.unsubscribe(); |
|
||||||
|
|
||||||
let nodes = { ...this.state.nodes }; |
|
||||||
let currentLevel: NodesMap = nodes; |
|
||||||
|
|
||||||
for (let idx = 0; idx < path.length - 1; idx++) { |
|
||||||
currentLevel = currentLevel[path[idx]].nodes; |
|
||||||
} |
|
||||||
|
|
||||||
const name = path[path.length - 1]; |
|
||||||
const currentNode = currentLevel[name]; |
|
||||||
|
|
||||||
const isDifferentQuery = currentNode.query !== query; |
|
||||||
|
|
||||||
currentNode.isExpanded = isExpanded; |
|
||||||
currentNode.query = query; |
|
||||||
|
|
||||||
if (isExpanded || isDifferentQuery) { |
|
||||||
this.setState({ nodes, loadingNodeName: name }); |
|
||||||
|
|
||||||
this.nodesFetchingSub = from(fetchNodes(name, query)) |
|
||||||
.pipe( |
|
||||||
finalize(() => { |
|
||||||
this.setState({ loadingNodeName: undefined }); |
|
||||||
}) |
|
||||||
) |
|
||||||
.subscribe((childNodes) => { |
|
||||||
const [scopes, treeScopes] = getScopesAndTreeScopesWithPaths( |
|
||||||
this.state.scopes, |
|
||||||
this.state.treeScopes, |
|
||||||
path, |
|
||||||
childNodes |
|
||||||
); |
|
||||||
|
|
||||||
const persistedNodes = treeScopes |
|
||||||
.map(({ path }) => path[path.length - 1]) |
|
||||||
.filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes)) |
|
||||||
.reduce<NodesMap>((acc, nodeName) => { |
|
||||||
acc[nodeName] = { |
|
||||||
...currentNode.nodes[nodeName], |
|
||||||
reason: NodeReason.Persisted, |
|
||||||
}; |
|
||||||
|
|
||||||
return acc; |
|
||||||
}, {}); |
|
||||||
|
|
||||||
currentNode.nodes = { ...persistedNodes, ...childNodes }; |
|
||||||
|
|
||||||
this.setState({ nodes, scopes, treeScopes }); |
|
||||||
|
|
||||||
this.nodesFetchingSub?.unsubscribe(); |
|
||||||
}); |
|
||||||
} else { |
|
||||||
this.setState({ nodes, loadingNodeName: undefined }); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public toggleNodeSelect(path: string[]) { |
|
||||||
let treeScopes = [...this.state.treeScopes]; |
|
||||||
|
|
||||||
let parentNode = this.state.nodes['']; |
|
||||||
|
|
||||||
for (let idx = 1; idx < path.length - 1; idx++) { |
|
||||||
parentNode = parentNode.nodes[path[idx]]; |
|
||||||
} |
|
||||||
|
|
||||||
const nodeName = path[path.length - 1]; |
|
||||||
const { linkId } = parentNode.nodes[nodeName]; |
|
||||||
|
|
||||||
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId); |
|
||||||
|
|
||||||
if (selectedIdx === -1) { |
|
||||||
fetchScope(linkId!); |
|
||||||
|
|
||||||
const selectedFromSameNode = |
|
||||||
treeScopes.length === 0 || |
|
||||||
Object.values(parentNode.nodes).some(({ linkId }) => linkId === treeScopes[0].scopeName); |
|
||||||
|
|
||||||
const treeScope = { |
|
||||||
scopeName: linkId!, |
|
||||||
path, |
|
||||||
}; |
|
||||||
|
|
||||||
this.setState({ |
|
||||||
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope], |
|
||||||
}); |
|
||||||
} else { |
|
||||||
treeScopes.splice(selectedIdx, 1); |
|
||||||
|
|
||||||
this.setState({ treeScopes }); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public openPicker() { |
|
||||||
if (!this.state.isReadOnly) { |
|
||||||
let nodes = { ...this.state.nodes }; |
|
||||||
|
|
||||||
// First close all nodes
|
|
||||||
nodes = this.closeNodes(nodes); |
|
||||||
|
|
||||||
// Extract the path of a scope
|
|
||||||
let path = [...(this.state.scopes[0]?.path ?? ['', ''])]; |
|
||||||
path.splice(path.length - 1, 1); |
|
||||||
|
|
||||||
// Expand the nodes to the selected scope
|
|
||||||
nodes = this.expandNodes(nodes, path); |
|
||||||
|
|
||||||
this.setState({ isPickerOpened: true, nodes }); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public closePicker() { |
|
||||||
this.setState({ isPickerOpened: false }); |
|
||||||
} |
|
||||||
|
|
||||||
public async updateScopes(treeScopes = this.state.treeScopes) { |
|
||||||
if (isEqual(treeScopes, getTreeScopesFromSelectedScopes(this.state.scopes))) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
this.setState({ |
|
||||||
// Update the scopes with the basic scopes otherwise they'd be lost between URL syncs
|
|
||||||
scopes: treeScopes.map(({ scopeName, path }) => ({ scope: getBasicScope(scopeName), path })), |
|
||||||
treeScopes, |
|
||||||
isLoadingScopes: true, |
|
||||||
}); |
|
||||||
|
|
||||||
this.state.dashboards?.resolve().fetchDashboards(treeScopes.map(({ scopeName }) => scopeName)); |
|
||||||
|
|
||||||
const scopes = await fetchSelectedScopes(treeScopes); |
|
||||||
|
|
||||||
this.setState({ scopes, isLoadingScopes: false }); |
|
||||||
} |
|
||||||
|
|
||||||
public resetDirtyScopeNames() { |
|
||||||
this.setState({ treeScopes: getTreeScopesFromSelectedScopes(this.state.scopes) }); |
|
||||||
} |
|
||||||
|
|
||||||
public async removeAllScopes() { |
|
||||||
return this.updateScopes([]); |
|
||||||
} |
|
||||||
|
|
||||||
public enterReadOnly() { |
|
||||||
this.setState({ isReadOnly: true, isPickerOpened: false }); |
|
||||||
} |
|
||||||
|
|
||||||
public exitReadOnly() { |
|
||||||
this.setState({ isReadOnly: false }); |
|
||||||
} |
|
||||||
|
|
||||||
public enable() { |
|
||||||
this.setState({ isEnabled: true }); |
|
||||||
} |
|
||||||
|
|
||||||
public disable() { |
|
||||||
this.setState({ isEnabled: false }); |
|
||||||
} |
|
||||||
|
|
||||||
private closeNodes(nodes: NodesMap): NodesMap { |
|
||||||
return Object.entries(nodes).reduce<NodesMap>((acc, [id, node]) => { |
|
||||||
acc[id] = { |
|
||||||
...node, |
|
||||||
isExpanded: false, |
|
||||||
nodes: this.closeNodes(node.nodes), |
|
||||||
}; |
|
||||||
|
|
||||||
return acc; |
|
||||||
}, {}); |
|
||||||
} |
|
||||||
|
|
||||||
private expandNodes(nodes: NodesMap, path: string[]): NodesMap { |
|
||||||
nodes = { ...nodes }; |
|
||||||
let currentNodes = nodes; |
|
||||||
|
|
||||||
for (let i = 0; i < path.length; i++) { |
|
||||||
const nodeId = path[i]; |
|
||||||
|
|
||||||
currentNodes[nodeId] = { |
|
||||||
...currentNodes[nodeId], |
|
||||||
isExpanded: true, |
|
||||||
}; |
|
||||||
currentNodes = currentNodes[nodeId].nodes; |
|
||||||
} |
|
||||||
|
|
||||||
return nodes; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export function ScopesSelectorSceneRenderer({ model }: SceneComponentProps<ScopesSelectorScene>) { |
|
||||||
const { chrome } = useGrafana(); |
|
||||||
const state = chrome.useState(); |
|
||||||
const menuDockedAndOpen = !state.chromeless && state.megaMenuDocked && state.megaMenuOpen; |
|
||||||
const styles = useStyles2(getStyles, menuDockedAndOpen); |
|
||||||
const { |
|
||||||
dashboards: dashboardsRef, |
|
||||||
nodes, |
|
||||||
loadingNodeName, |
|
||||||
scopes, |
|
||||||
treeScopes, |
|
||||||
isReadOnly, |
|
||||||
isLoadingScopes, |
|
||||||
isPickerOpened, |
|
||||||
isEnabled, |
|
||||||
} = model.useState(); |
|
||||||
|
|
||||||
const dashboards = dashboardsRef?.resolve(); |
|
||||||
|
|
||||||
const { isPanelOpened: isDashboardsPanelOpened } = dashboards?.useState() ?? {}; |
|
||||||
|
|
||||||
if (!isEnabled) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
const dashboardsIconLabel = isReadOnly |
|
||||||
? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode') |
|
||||||
: isDashboardsPanelOpened |
|
||||||
? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list') |
|
||||||
: t('scopes.dashboards.toggle..expand', 'Expand suggested dashboards list'); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={styles.container}> |
|
||||||
<IconButton |
|
||||||
name="web-section-alt" |
|
||||||
className={styles.dashboards} |
|
||||||
aria-label={dashboardsIconLabel} |
|
||||||
tooltip={dashboardsIconLabel} |
|
||||||
data-testid="scopes-dashboards-expand" |
|
||||||
disabled={isReadOnly} |
|
||||||
onClick={() => dashboards?.togglePanel()} |
|
||||||
/> |
|
||||||
|
|
||||||
<ScopesInput |
|
||||||
nodes={nodes} |
|
||||||
scopes={scopes} |
|
||||||
isDisabled={isReadOnly} |
|
||||||
isLoading={isLoadingScopes} |
|
||||||
onInputClick={() => model.openPicker()} |
|
||||||
onRemoveAllClick={() => model.removeAllScopes()} |
|
||||||
/> |
|
||||||
|
|
||||||
{isPickerOpened && ( |
|
||||||
<Drawer |
|
||||||
title={t('scopes.selector.title', 'Select scopes')} |
|
||||||
size="sm" |
|
||||||
onClose={() => { |
|
||||||
model.closePicker(); |
|
||||||
model.resetDirtyScopeNames(); |
|
||||||
}} |
|
||||||
> |
|
||||||
<div className={styles.drawerContainer}> |
|
||||||
<div className={styles.treeContainer}> |
|
||||||
{isLoadingScopes ? ( |
|
||||||
<Spinner data-testid="scopes-selector-loading" /> |
|
||||||
) : ( |
|
||||||
<ScopesTree |
|
||||||
nodes={nodes} |
|
||||||
nodePath={['']} |
|
||||||
loadingNodeName={loadingNodeName} |
|
||||||
scopes={treeScopes} |
|
||||||
onNodeUpdate={(path, isExpanded, query) => model.updateNode(path, isExpanded, query)} |
|
||||||
onNodeSelectToggle={(path) => model.toggleNodeSelect(path)} |
|
||||||
/> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
|
|
||||||
<div className={styles.buttonsContainer}> |
|
||||||
<Button |
|
||||||
variant="primary" |
|
||||||
data-testid="scopes-selector-apply" |
|
||||||
onClick={() => { |
|
||||||
model.closePicker(); |
|
||||||
model.updateScopes(); |
|
||||||
}} |
|
||||||
> |
|
||||||
<Trans i18nKey="scopes.selector.apply">Apply</Trans> |
|
||||||
</Button> |
|
||||||
<Button |
|
||||||
variant="secondary" |
|
||||||
data-testid="scopes-selector-cancel" |
|
||||||
onClick={() => { |
|
||||||
model.closePicker(); |
|
||||||
model.resetDirtyScopeNames(); |
|
||||||
}} |
|
||||||
> |
|
||||||
<Trans i18nKey="scopes.selector.cancel">Cancel</Trans> |
|
||||||
</Button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</Drawer> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => { |
|
||||||
return { |
|
||||||
container: css({ |
|
||||||
display: 'flex', |
|
||||||
flexDirection: 'row', |
|
||||||
paddingLeft: menuDockedAndOpen ? theme.spacing(2) : 'unset', |
|
||||||
}), |
|
||||||
dashboards: css({ |
|
||||||
color: theme.colors.text.secondary, |
|
||||||
marginRight: theme.spacing(2), |
|
||||||
|
|
||||||
'&:hover': css({ |
|
||||||
color: theme.colors.text.primary, |
|
||||||
}), |
|
||||||
}), |
|
||||||
drawerContainer: css({ |
|
||||||
display: 'flex', |
|
||||||
flexDirection: 'column', |
|
||||||
height: '100%', |
|
||||||
}), |
|
||||||
treeContainer: css({ |
|
||||||
display: 'flex', |
|
||||||
flexDirection: 'column', |
|
||||||
maxHeight: '100%', |
|
||||||
overflowY: 'hidden', |
|
||||||
// Fix for top level search outline overflow due to scrollbars
|
|
||||||
paddingLeft: theme.spacing(0.5), |
|
||||||
}), |
|
||||||
buttonsContainer: css({ |
|
||||||
display: 'flex', |
|
||||||
gap: theme.spacing(1), |
|
||||||
marginTop: theme.spacing(8), |
|
||||||
}), |
|
||||||
}; |
|
||||||
}; |
|
@ -1,102 +0,0 @@ |
|||||||
import { Scope, ScopeDashboardBinding, ScopeNode, ScopeSpec } from '@grafana/data'; |
|
||||||
import { getBackendSrv } from '@grafana/runtime'; |
|
||||||
import { ScopedResourceClient } from 'app/features/apiserver/client'; |
|
||||||
|
|
||||||
import { getAPINamespace } from '../../../api/utils'; |
|
||||||
|
|
||||||
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types'; |
|
||||||
import { getBasicScope, mergeScopes } from './utils'; |
|
||||||
|
|
||||||
const group = 'scope.grafana.app'; |
|
||||||
const version = 'v0alpha1'; |
|
||||||
const namespace = getAPINamespace(); |
|
||||||
|
|
||||||
const nodesEndpoint = `/apis/${group}/${version}/namespaces/${namespace}/find/scope_node_children`; |
|
||||||
const dashboardsEndpoint = `/apis/${group}/${version}/namespaces/${namespace}/find/scope_dashboard_bindings`; |
|
||||||
|
|
||||||
const scopesClient = new ScopedResourceClient<ScopeSpec, unknown, 'Scope'>({ |
|
||||||
group, |
|
||||||
version, |
|
||||||
resource: 'scopes', |
|
||||||
}); |
|
||||||
|
|
||||||
const scopesCache = new Map<string, Promise<Scope>>(); |
|
||||||
|
|
||||||
async function fetchScopeNodes(parent: string, query: string): Promise<ScopeNode[]> { |
|
||||||
try { |
|
||||||
return (await getBackendSrv().get<{ items: ScopeNode[] }>(nodesEndpoint, { parent, query }))?.items ?? []; |
|
||||||
} catch (err) { |
|
||||||
return []; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export async function fetchNodes(parent: string, query: string): Promise<NodesMap> { |
|
||||||
return (await fetchScopeNodes(parent, query)).reduce<NodesMap>((acc, { metadata: { name }, spec }) => { |
|
||||||
acc[name] = { |
|
||||||
name, |
|
||||||
...spec, |
|
||||||
isExpandable: spec.nodeType === 'container', |
|
||||||
isSelectable: spec.linkType === 'scope', |
|
||||||
isExpanded: false, |
|
||||||
query: '', |
|
||||||
reason: NodeReason.Result, |
|
||||||
nodes: {}, |
|
||||||
}; |
|
||||||
return acc; |
|
||||||
}, {}); |
|
||||||
} |
|
||||||
|
|
||||||
export async function fetchScope(name: string): Promise<Scope> { |
|
||||||
if (scopesCache.has(name)) { |
|
||||||
return scopesCache.get(name)!; |
|
||||||
} |
|
||||||
|
|
||||||
const response = new Promise<Scope>(async (resolve) => { |
|
||||||
const basicScope = getBasicScope(name); |
|
||||||
|
|
||||||
try { |
|
||||||
const serverScope = await scopesClient.get(name); |
|
||||||
|
|
||||||
const scope = mergeScopes(basicScope, serverScope); |
|
||||||
|
|
||||||
resolve(scope); |
|
||||||
} catch (err) { |
|
||||||
scopesCache.delete(name); |
|
||||||
|
|
||||||
resolve(basicScope); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
scopesCache.set(name, response); |
|
||||||
|
|
||||||
return response; |
|
||||||
} |
|
||||||
|
|
||||||
export async function fetchScopes(names: string[]): Promise<Scope[]> { |
|
||||||
return await Promise.all(names.map(fetchScope)); |
|
||||||
} |
|
||||||
|
|
||||||
export async function fetchSelectedScopes(treeScopes: TreeScope[]): Promise<SelectedScope[]> { |
|
||||||
const scopes = await fetchScopes(treeScopes.map(({ scopeName }) => scopeName)); |
|
||||||
|
|
||||||
return scopes.reduce<SelectedScope[]>((acc, scope, idx) => { |
|
||||||
acc.push({ |
|
||||||
scope, |
|
||||||
path: treeScopes[idx].path, |
|
||||||
}); |
|
||||||
|
|
||||||
return acc; |
|
||||||
}, []); |
|
||||||
} |
|
||||||
|
|
||||||
export async function fetchDashboards(scopeNames: string[]): Promise<ScopeDashboardBinding[]> { |
|
||||||
try { |
|
||||||
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, { |
|
||||||
scope: scopeNames, |
|
||||||
}); |
|
||||||
|
|
||||||
return response?.items ?? []; |
|
||||||
} catch (err) { |
|
||||||
return []; |
|
||||||
} |
|
||||||
} |
|
@ -1,48 +0,0 @@ |
|||||||
import { Scope, ScopeDashboardBinding, ScopeNodeSpec } from '@grafana/data'; |
|
||||||
|
|
||||||
export enum NodeReason { |
|
||||||
Persisted, |
|
||||||
Result, |
|
||||||
} |
|
||||||
|
|
||||||
export interface Node extends ScopeNodeSpec { |
|
||||||
name: string; |
|
||||||
reason: NodeReason; |
|
||||||
isExpandable: boolean; |
|
||||||
isSelectable: boolean; |
|
||||||
isExpanded: boolean; |
|
||||||
query: string; |
|
||||||
nodes: NodesMap; |
|
||||||
} |
|
||||||
|
|
||||||
export type NodesMap = Record<string, Node>; |
|
||||||
|
|
||||||
export interface SelectedScope { |
|
||||||
scope: Scope; |
|
||||||
path: string[]; |
|
||||||
} |
|
||||||
|
|
||||||
export interface TreeScope { |
|
||||||
scopeName: string; |
|
||||||
path: string[]; |
|
||||||
} |
|
||||||
|
|
||||||
export interface SuggestedDashboard { |
|
||||||
dashboard: string; |
|
||||||
dashboardTitle: string; |
|
||||||
items: ScopeDashboardBinding[]; |
|
||||||
} |
|
||||||
|
|
||||||
export interface SuggestedDashboardsFolder { |
|
||||||
title: string; |
|
||||||
isExpanded: boolean; |
|
||||||
folders: SuggestedDashboardsFoldersMap; |
|
||||||
dashboards: SuggestedDashboardsMap; |
|
||||||
} |
|
||||||
|
|
||||||
export type SuggestedDashboardsMap = Record<string, SuggestedDashboard>; |
|
||||||
export type SuggestedDashboardsFoldersMap = Record<string, SuggestedDashboardsFolder>; |
|
||||||
|
|
||||||
export type OnNodeUpdate = (path: string[], isExpanded: boolean, query: string) => void; |
|
||||||
export type OnNodeSelectToggle = (path: string[]) => void; |
|
||||||
export type OnFolderUpdate = (path: string[], isExpanded: boolean) => void; |
|
@ -1,177 +0,0 @@ |
|||||||
import { Scope, ScopeDashboardBinding } from '@grafana/data'; |
|
||||||
|
|
||||||
import { NodesMap, SelectedScope, SuggestedDashboardsFoldersMap, TreeScope } from './types'; |
|
||||||
|
|
||||||
export function getBasicScope(name: string): Scope { |
|
||||||
return { |
|
||||||
metadata: { name }, |
|
||||||
spec: { |
|
||||||
filters: [], |
|
||||||
title: name, |
|
||||||
type: '', |
|
||||||
category: '', |
|
||||||
description: '', |
|
||||||
}, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
export function mergeScopes(scope1: Scope, scope2: Scope): Scope { |
|
||||||
return { |
|
||||||
...scope1, |
|
||||||
metadata: { |
|
||||||
...scope1.metadata, |
|
||||||
...scope2.metadata, |
|
||||||
}, |
|
||||||
spec: { |
|
||||||
...scope1.spec, |
|
||||||
...scope2.spec, |
|
||||||
}, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
export function getTreeScopesFromSelectedScopes(scopes: SelectedScope[]): TreeScope[] { |
|
||||||
return scopes.map(({ scope, path }) => ({ |
|
||||||
scopeName: scope.metadata.name, |
|
||||||
path, |
|
||||||
})); |
|
||||||
} |
|
||||||
|
|
||||||
export function getScopesFromSelectedScopes(scopes: SelectedScope[]): Scope[] { |
|
||||||
return scopes.map(({ scope }) => scope); |
|
||||||
} |
|
||||||
|
|
||||||
export function getScopeNamesFromSelectedScopes(scopes: SelectedScope[]): string[] { |
|
||||||
return scopes.map(({ scope }) => scope.metadata.name); |
|
||||||
} |
|
||||||
|
|
||||||
// helper func to get the selected/tree scopes together with their paths
|
|
||||||
// needed to maintain selected scopes in tree for example when navigating
|
|
||||||
// between categories or when loading scopes from URL to find the scope's path
|
|
||||||
export function getScopesAndTreeScopesWithPaths( |
|
||||||
selectedScopes: SelectedScope[], |
|
||||||
treeScopes: TreeScope[], |
|
||||||
path: string[], |
|
||||||
childNodes: NodesMap |
|
||||||
): [SelectedScope[], TreeScope[]] { |
|
||||||
const childNodesArr = Object.values(childNodes); |
|
||||||
|
|
||||||
// Get all scopes without paths
|
|
||||||
// We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
|
|
||||||
const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName); |
|
||||||
|
|
||||||
// We search for the path of each scope name without a path
|
|
||||||
const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce<Record<string, string[]>>((acc, scopeName) => { |
|
||||||
const possibleParent = childNodesArr.find((childNode) => childNode.isSelectable && childNode.linkId === scopeName); |
|
||||||
|
|
||||||
if (possibleParent) { |
|
||||||
acc[scopeName] = [...path, possibleParent.name]; |
|
||||||
} |
|
||||||
|
|
||||||
return acc; |
|
||||||
}, {}); |
|
||||||
|
|
||||||
// Update the paths of the selected scopes based on what we found
|
|
||||||
const newSelectedScopes = selectedScopes.map((selectedScope) => { |
|
||||||
if (selectedScope.path.length > 0) { |
|
||||||
return selectedScope; |
|
||||||
} |
|
||||||
|
|
||||||
return { |
|
||||||
...selectedScope, |
|
||||||
path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [], |
|
||||||
}; |
|
||||||
}); |
|
||||||
|
|
||||||
// Update the paths of the tree scopes based on what we found
|
|
||||||
const newTreeScopes = treeScopes.map((treeScope) => { |
|
||||||
if (treeScope.path.length > 0) { |
|
||||||
return treeScope; |
|
||||||
} |
|
||||||
|
|
||||||
return { |
|
||||||
...treeScope, |
|
||||||
path: scopeNamesWithPaths[treeScope.scopeName] ?? [], |
|
||||||
}; |
|
||||||
}); |
|
||||||
|
|
||||||
return [newSelectedScopes, newTreeScopes]; |
|
||||||
} |
|
||||||
|
|
||||||
export function groupDashboards(dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap { |
|
||||||
return dashboards.reduce<SuggestedDashboardsFoldersMap>( |
|
||||||
(acc, dashboard) => { |
|
||||||
const rootNode = acc['']; |
|
||||||
const groups = dashboard.status.groups ?? []; |
|
||||||
|
|
||||||
groups.forEach((group) => { |
|
||||||
if (group && !rootNode.folders[group]) { |
|
||||||
rootNode.folders[group] = { |
|
||||||
title: group, |
|
||||||
isExpanded: false, |
|
||||||
folders: {}, |
|
||||||
dashboards: {}, |
|
||||||
}; |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
const targets = |
|
||||||
groups.length > 0 |
|
||||||
? groups.map((group) => (group === '' ? rootNode.dashboards : rootNode.folders[group].dashboards)) |
|
||||||
: [rootNode.dashboards]; |
|
||||||
|
|
||||||
targets.forEach((target) => { |
|
||||||
if (!target[dashboard.spec.dashboard]) { |
|
||||||
target[dashboard.spec.dashboard] = { |
|
||||||
dashboard: dashboard.spec.dashboard, |
|
||||||
dashboardTitle: dashboard.status.dashboardTitle, |
|
||||||
items: [], |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
target[dashboard.spec.dashboard].items.push(dashboard); |
|
||||||
}); |
|
||||||
|
|
||||||
return acc; |
|
||||||
}, |
|
||||||
{ |
|
||||||
'': { |
|
||||||
title: '', |
|
||||||
isExpanded: true, |
|
||||||
folders: {}, |
|
||||||
dashboards: {}, |
|
||||||
}, |
|
||||||
} |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
export function filterFolders(folders: SuggestedDashboardsFoldersMap, query: string): SuggestedDashboardsFoldersMap { |
|
||||||
query = (query ?? '').toLowerCase(); |
|
||||||
|
|
||||||
return Object.entries(folders).reduce<SuggestedDashboardsFoldersMap>((acc, [folderId, folder]) => { |
|
||||||
// If folder matches the query, we show everything inside
|
|
||||||
if (folder.title.toLowerCase().includes(query)) { |
|
||||||
acc[folderId] = { |
|
||||||
...folder, |
|
||||||
isExpanded: true, |
|
||||||
}; |
|
||||||
|
|
||||||
return acc; |
|
||||||
} |
|
||||||
|
|
||||||
const filteredFolders = filterFolders(folder.folders, query); |
|
||||||
const filteredDashboards = Object.entries(folder.dashboards).filter(([_, dashboard]) => |
|
||||||
dashboard.dashboardTitle.toLowerCase().includes(query) |
|
||||||
); |
|
||||||
|
|
||||||
if (Object.keys(filteredFolders).length > 0 || filteredDashboards.length > 0) { |
|
||||||
acc[folderId] = { |
|
||||||
...folder, |
|
||||||
isExpanded: true, |
|
||||||
folders: filteredFolders, |
|
||||||
dashboards: Object.fromEntries(filteredDashboards), |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
return acc; |
|
||||||
}, {}); |
|
||||||
} |
|
@ -0,0 +1,129 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import { useObservable } from 'react-use'; |
||||||
|
import { Observable } from 'rxjs'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { useScopes } from '@grafana/runtime'; |
||||||
|
import { Button, Drawer, IconButton, Spinner, useStyles2 } from '@grafana/ui'; |
||||||
|
import { useGrafana } from 'app/core/context/GrafanaContext'; |
||||||
|
import { t, Trans } from 'app/core/internationalization'; |
||||||
|
|
||||||
|
import { ScopesInput } from './ScopesInput'; |
||||||
|
import { ScopesSelectorService } from './ScopesSelectorService'; |
||||||
|
import { ScopesTree } from './ScopesTree'; |
||||||
|
|
||||||
|
export const ScopesSelector = () => { |
||||||
|
const { chrome } = useGrafana(); |
||||||
|
const chromeState = chrome.useState(); |
||||||
|
const menuDockedAndOpen = !chromeState.chromeless && chromeState.megaMenuDocked && chromeState.megaMenuOpen; |
||||||
|
const styles = useStyles2(getStyles, menuDockedAndOpen); |
||||||
|
const scopes = useScopes(); |
||||||
|
|
||||||
|
const scopesSelectorService = ScopesSelectorService.instance; |
||||||
|
|
||||||
|
useObservable(scopesSelectorService?.stateObservable ?? new Observable(), scopesSelectorService?.state); |
||||||
|
|
||||||
|
if (!scopes || !scopesSelectorService || !scopes.state.enabled) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const { readOnly, drawerOpened, loading } = scopes.state; |
||||||
|
const { nodes, selectedScopes, opened, loadingNodeName, treeScopes } = scopesSelectorService.state; |
||||||
|
const { toggleDrawer, open, removeAllScopes, closeAndApply, closeAndReset, updateNode, toggleNodeSelect } = |
||||||
|
scopesSelectorService; |
||||||
|
|
||||||
|
const dashboardsIconLabel = readOnly |
||||||
|
? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode') |
||||||
|
: drawerOpened |
||||||
|
? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list') |
||||||
|
: t('scopes.dashboards.toggle..expand', 'Expand suggested dashboards list'); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.container}> |
||||||
|
<IconButton |
||||||
|
name="web-section-alt" |
||||||
|
className={styles.dashboards} |
||||||
|
aria-label={dashboardsIconLabel} |
||||||
|
tooltip={dashboardsIconLabel} |
||||||
|
data-testid="scopes-dashboards-expand" |
||||||
|
disabled={readOnly} |
||||||
|
onClick={toggleDrawer} |
||||||
|
/> |
||||||
|
|
||||||
|
<ScopesInput |
||||||
|
nodes={nodes} |
||||||
|
scopes={selectedScopes} |
||||||
|
disabled={readOnly} |
||||||
|
loading={loading} |
||||||
|
onInputClick={open} |
||||||
|
onRemoveAllClick={removeAllScopes} |
||||||
|
/> |
||||||
|
|
||||||
|
{opened && ( |
||||||
|
<Drawer title={t('scopes.selector.title', 'Select scopes')} size="sm" onClose={closeAndReset}> |
||||||
|
<div className={styles.drawerContainer}> |
||||||
|
<div className={styles.treeContainer}> |
||||||
|
{loading ? ( |
||||||
|
<Spinner data-testid="scopes-selector-loading" /> |
||||||
|
) : ( |
||||||
|
<ScopesTree |
||||||
|
nodes={nodes} |
||||||
|
nodePath={['']} |
||||||
|
loadingNodeName={loadingNodeName} |
||||||
|
scopes={treeScopes} |
||||||
|
onNodeUpdate={updateNode} |
||||||
|
onNodeSelectToggle={toggleNodeSelect} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className={styles.buttonsContainer}> |
||||||
|
<Button variant="primary" data-testid="scopes-selector-apply" onClick={closeAndApply}> |
||||||
|
<Trans i18nKey="scopes.selector.apply">Apply</Trans> |
||||||
|
</Button> |
||||||
|
<Button variant="secondary" data-testid="scopes-selector-cancel" onClick={closeAndReset}> |
||||||
|
<Trans i18nKey="scopes.selector.cancel">Cancel</Trans> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Drawer> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => { |
||||||
|
return { |
||||||
|
container: css({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'row', |
||||||
|
paddingLeft: menuDockedAndOpen ? theme.spacing(2) : 'unset', |
||||||
|
}), |
||||||
|
dashboards: css({ |
||||||
|
color: theme.colors.text.secondary, |
||||||
|
marginRight: theme.spacing(2), |
||||||
|
|
||||||
|
'&:hover': css({ |
||||||
|
color: theme.colors.text.primary, |
||||||
|
}), |
||||||
|
}), |
||||||
|
drawerContainer: css({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
height: '100%', |
||||||
|
}), |
||||||
|
treeContainer: css({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
maxHeight: '100%', |
||||||
|
overflowY: 'hidden', |
||||||
|
// Fix for top level search outline overflow due to scrollbars
|
||||||
|
paddingLeft: theme.spacing(0.5), |
||||||
|
}), |
||||||
|
buttonsContainer: css({ |
||||||
|
display: 'flex', |
||||||
|
gap: theme.spacing(1), |
||||||
|
marginTop: theme.spacing(8), |
||||||
|
}), |
||||||
|
}; |
||||||
|
}; |
@ -0,0 +1,392 @@ |
|||||||
|
import { isEqual } from 'lodash'; |
||||||
|
import { finalize, from } from 'rxjs'; |
||||||
|
|
||||||
|
import { Scope, ScopeNode } from '@grafana/data'; |
||||||
|
import { config, getBackendSrv } from '@grafana/runtime'; |
||||||
|
|
||||||
|
import { ScopesService } from '../ScopesService'; |
||||||
|
import { ScopesServiceBase } from '../ScopesServiceBase'; |
||||||
|
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService'; |
||||||
|
|
||||||
|
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types'; |
||||||
|
|
||||||
|
interface ScopesSelectorServiceState { |
||||||
|
opened: boolean; |
||||||
|
loadingNodeName: string | undefined; |
||||||
|
nodes: NodesMap; |
||||||
|
selectedScopes: SelectedScope[]; |
||||||
|
treeScopes: TreeScope[]; |
||||||
|
} |
||||||
|
|
||||||
|
export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServiceState> { |
||||||
|
static #instance: ScopesSelectorService | undefined = undefined; |
||||||
|
|
||||||
|
private _scopesCache = new Map<string, Promise<Scope>>(); |
||||||
|
|
||||||
|
private constructor() { |
||||||
|
super({ |
||||||
|
opened: false, |
||||||
|
loadingNodeName: undefined, |
||||||
|
nodes: { |
||||||
|
'': { |
||||||
|
name: '', |
||||||
|
reason: NodeReason.Result, |
||||||
|
nodeType: 'container', |
||||||
|
title: '', |
||||||
|
expandable: true, |
||||||
|
selectable: false, |
||||||
|
expanded: true, |
||||||
|
query: '', |
||||||
|
nodes: {}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
selectedScopes: [], |
||||||
|
treeScopes: [], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public static get instance(): ScopesSelectorService | undefined { |
||||||
|
if (!ScopesSelectorService.#instance && config.featureToggles.scopeFilters) { |
||||||
|
ScopesSelectorService.#instance = new ScopesSelectorService(); |
||||||
|
} |
||||||
|
|
||||||
|
return ScopesSelectorService.#instance; |
||||||
|
} |
||||||
|
|
||||||
|
public updateNode = async (path: string[], expanded: boolean, query: string) => { |
||||||
|
this._fetchSub?.unsubscribe(); |
||||||
|
|
||||||
|
let nodes = { ...this.state.nodes }; |
||||||
|
let currentLevel: NodesMap = nodes; |
||||||
|
|
||||||
|
for (let idx = 0; idx < path.length - 1; idx++) { |
||||||
|
currentLevel = currentLevel[path[idx]].nodes; |
||||||
|
} |
||||||
|
|
||||||
|
const loadingNodeName = path[path.length - 1]; |
||||||
|
const currentNode = currentLevel[loadingNodeName]; |
||||||
|
|
||||||
|
const differentQuery = currentNode.query !== query; |
||||||
|
|
||||||
|
currentNode.expanded = expanded; |
||||||
|
currentNode.query = query; |
||||||
|
|
||||||
|
if (expanded || differentQuery) { |
||||||
|
this.updateState({ nodes, loadingNodeName }); |
||||||
|
|
||||||
|
this._fetchSub = from(this.fetchNodeApi(loadingNodeName, query)) |
||||||
|
.pipe( |
||||||
|
finalize(() => { |
||||||
|
this.updateState({ loadingNodeName: undefined }); |
||||||
|
}) |
||||||
|
) |
||||||
|
.subscribe((childNodes) => { |
||||||
|
const [selectedScopes, treeScopes] = this.getScopesAndTreeScopesWithPaths( |
||||||
|
this.state.selectedScopes, |
||||||
|
this.state.treeScopes, |
||||||
|
path, |
||||||
|
childNodes |
||||||
|
); |
||||||
|
|
||||||
|
const persistedNodes = treeScopes |
||||||
|
.map(({ path }) => path[path.length - 1]) |
||||||
|
.filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes)) |
||||||
|
.reduce<NodesMap>((acc, nodeName) => { |
||||||
|
acc[nodeName] = { |
||||||
|
...currentNode.nodes[nodeName], |
||||||
|
reason: NodeReason.Persisted, |
||||||
|
}; |
||||||
|
|
||||||
|
return acc; |
||||||
|
}, {}); |
||||||
|
|
||||||
|
currentNode.nodes = { ...persistedNodes, ...childNodes }; |
||||||
|
|
||||||
|
this.updateState({ nodes, selectedScopes, treeScopes }); |
||||||
|
|
||||||
|
this._fetchSub?.unsubscribe(); |
||||||
|
}); |
||||||
|
} else { |
||||||
|
this.updateState({ nodes, loadingNodeName: undefined }); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
public toggleNodeSelect = (path: string[]) => { |
||||||
|
let treeScopes = [...this.state.treeScopes]; |
||||||
|
|
||||||
|
let parentNode = this.state.nodes['']; |
||||||
|
|
||||||
|
for (let idx = 1; idx < path.length - 1; idx++) { |
||||||
|
parentNode = parentNode.nodes[path[idx]]; |
||||||
|
} |
||||||
|
|
||||||
|
const nodeName = path[path.length - 1]; |
||||||
|
const { linkId } = parentNode.nodes[nodeName]; |
||||||
|
|
||||||
|
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId); |
||||||
|
|
||||||
|
if (selectedIdx === -1) { |
||||||
|
this.fetchScopeApi(linkId!); |
||||||
|
|
||||||
|
const selectedFromSameNode = |
||||||
|
treeScopes.length === 0 || |
||||||
|
Object.values(parentNode.nodes).some(({ linkId }) => linkId === treeScopes[0].scopeName); |
||||||
|
|
||||||
|
const treeScope = { |
||||||
|
scopeName: linkId!, |
||||||
|
path, |
||||||
|
}; |
||||||
|
|
||||||
|
this.updateState({ |
||||||
|
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope], |
||||||
|
}); |
||||||
|
} else { |
||||||
|
treeScopes.splice(selectedIdx, 1); |
||||||
|
|
||||||
|
this.updateState({ treeScopes }); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
public changeScopes = (scopeNames: string[]) => |
||||||
|
this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] }))); |
||||||
|
|
||||||
|
public setNewScopes = async (treeScopes = this.state.treeScopes) => { |
||||||
|
if (isEqual(treeScopes, this.getTreeScopesFromSelectedScopes(this.state.selectedScopes))) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
let selectedScopes = treeScopes.map(({ scopeName, path }) => ({ |
||||||
|
scope: this.getBasicScope(scopeName), |
||||||
|
path, |
||||||
|
})); |
||||||
|
this.updateState({ selectedScopes, treeScopes }); |
||||||
|
ScopesService.instance?.setLoading(true); |
||||||
|
ScopesDashboardsService.instance?.fetchDashboards(selectedScopes.map(({ scope }) => scope.metadata.name)); |
||||||
|
|
||||||
|
selectedScopes = await this.fetchScopesApi(treeScopes); |
||||||
|
this.updateState({ selectedScopes }); |
||||||
|
ScopesService.instance?.setScopes(selectedScopes.map(({ scope }) => scope)); |
||||||
|
ScopesService.instance?.setLoading(false); |
||||||
|
}; |
||||||
|
|
||||||
|
public removeAllScopes = () => this.setNewScopes([]); |
||||||
|
|
||||||
|
public open = async () => { |
||||||
|
if (!ScopesService.instance?.state.readOnly) { |
||||||
|
if (Object.keys(this.state.nodes[''].nodes).length === 0) { |
||||||
|
await this.updateNode([''], true, ''); |
||||||
|
} |
||||||
|
|
||||||
|
let nodes = { ...this.state.nodes }; |
||||||
|
|
||||||
|
// First close all nodes
|
||||||
|
nodes = this.closeNodes(nodes); |
||||||
|
|
||||||
|
// Extract the path of a scope
|
||||||
|
let path = [...(this.state.selectedScopes[0]?.path ?? ['', ''])]; |
||||||
|
path.splice(path.length - 1, 1); |
||||||
|
|
||||||
|
// Expand the nodes to the selected scope
|
||||||
|
nodes = this.expandNodes(nodes, path); |
||||||
|
|
||||||
|
this.updateState({ nodes, opened: true }); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
public closeAndReset = () => { |
||||||
|
this.updateState({ opened: false, treeScopes: this.getTreeScopesFromSelectedScopes(this.state.selectedScopes) }); |
||||||
|
}; |
||||||
|
|
||||||
|
public closeAndApply = () => { |
||||||
|
this.updateState({ opened: false }); |
||||||
|
this.setNewScopes(); |
||||||
|
}; |
||||||
|
|
||||||
|
public toggleDrawer = () => ScopesService.instance?.setDrawerOpened(!ScopesService.instance?.state.drawerOpened); |
||||||
|
|
||||||
|
private closeNodes = (nodes: NodesMap): NodesMap => { |
||||||
|
return Object.entries(nodes).reduce<NodesMap>((acc, [id, node]) => { |
||||||
|
acc[id] = { |
||||||
|
...node, |
||||||
|
expanded: false, |
||||||
|
nodes: this.closeNodes(node.nodes), |
||||||
|
}; |
||||||
|
|
||||||
|
return acc; |
||||||
|
}, {}); |
||||||
|
}; |
||||||
|
|
||||||
|
private expandNodes = (nodes: NodesMap, path: string[]): NodesMap => { |
||||||
|
nodes = { ...nodes }; |
||||||
|
let currentNodes = nodes; |
||||||
|
|
||||||
|
for (let i = 0; i < path.length; i++) { |
||||||
|
const nodeId = path[i]; |
||||||
|
|
||||||
|
currentNodes[nodeId] = { |
||||||
|
...currentNodes[nodeId], |
||||||
|
expanded: true, |
||||||
|
}; |
||||||
|
currentNodes = currentNodes[nodeId].nodes; |
||||||
|
} |
||||||
|
|
||||||
|
return nodes; |
||||||
|
}; |
||||||
|
|
||||||
|
private getBasicScope = (name: string): Scope => { |
||||||
|
return { |
||||||
|
metadata: { name }, |
||||||
|
spec: { |
||||||
|
filters: [], |
||||||
|
title: name, |
||||||
|
type: '', |
||||||
|
category: '', |
||||||
|
description: '', |
||||||
|
}, |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
private getTreeScopesFromSelectedScopes = (scopes: SelectedScope[]): TreeScope[] => { |
||||||
|
return scopes.map(({ scope, path }) => ({ |
||||||
|
scopeName: scope.metadata.name, |
||||||
|
path, |
||||||
|
})); |
||||||
|
}; |
||||||
|
|
||||||
|
// helper func to get the selected/tree scopes together with their paths
|
||||||
|
// needed to maintain selected scopes in tree for example when navigating
|
||||||
|
// between categories or when loading scopes from URL to find the scope's path
|
||||||
|
private getScopesAndTreeScopesWithPaths = ( |
||||||
|
selectedScopes: SelectedScope[], |
||||||
|
treeScopes: TreeScope[], |
||||||
|
path: string[], |
||||||
|
childNodes: NodesMap |
||||||
|
): [SelectedScope[], TreeScope[]] => { |
||||||
|
const childNodesArr = Object.values(childNodes); |
||||||
|
|
||||||
|
// Get all scopes without paths
|
||||||
|
// We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
|
||||||
|
const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName); |
||||||
|
|
||||||
|
// We search for the path of each scope name without a path
|
||||||
|
const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce<Record<string, string[]>>((acc, scopeName) => { |
||||||
|
const possibleParent = childNodesArr.find((childNode) => childNode.selectable && childNode.linkId === scopeName); |
||||||
|
|
||||||
|
if (possibleParent) { |
||||||
|
acc[scopeName] = [...path, possibleParent.name]; |
||||||
|
} |
||||||
|
|
||||||
|
return acc; |
||||||
|
}, {}); |
||||||
|
|
||||||
|
// Update the paths of the selected scopes based on what we found
|
||||||
|
const newSelectedScopes = selectedScopes.map((selectedScope) => { |
||||||
|
if (selectedScope.path.length > 0) { |
||||||
|
return selectedScope; |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
...selectedScope, |
||||||
|
path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [], |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
// Update the paths of the tree scopes based on what we found
|
||||||
|
const newTreeScopes = treeScopes.map((treeScope) => { |
||||||
|
if (treeScope.path.length > 0) { |
||||||
|
return treeScope; |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
...treeScope, |
||||||
|
path: scopeNamesWithPaths[treeScope.scopeName] ?? [], |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
return [newSelectedScopes, newTreeScopes]; |
||||||
|
}; |
||||||
|
|
||||||
|
public fetchNodeApi = async (parent: string, query: string): Promise<NodesMap> => { |
||||||
|
try { |
||||||
|
const nodes = |
||||||
|
( |
||||||
|
await getBackendSrv().get<{ items: ScopeNode[] }>( |
||||||
|
`/apis/${this._apiGroup}/${this._apiVersion}/namespaces/${this._apiNamespace}/find/scope_node_children`, |
||||||
|
{ parent, query } |
||||||
|
) |
||||||
|
)?.items ?? []; |
||||||
|
|
||||||
|
return nodes.reduce<NodesMap>((acc, { metadata: { name }, spec }) => { |
||||||
|
acc[name] = { |
||||||
|
name, |
||||||
|
...spec, |
||||||
|
expandable: spec.nodeType === 'container', |
||||||
|
selectable: spec.linkType === 'scope', |
||||||
|
expanded: false, |
||||||
|
query: '', |
||||||
|
reason: NodeReason.Result, |
||||||
|
nodes: {}, |
||||||
|
}; |
||||||
|
return acc; |
||||||
|
}, {}); |
||||||
|
} catch (err) { |
||||||
|
return {}; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
public fetchScopeApi = async (name: string): Promise<Scope> => { |
||||||
|
if (this._scopesCache.has(name)) { |
||||||
|
return this._scopesCache.get(name)!; |
||||||
|
} |
||||||
|
|
||||||
|
const response = new Promise<Scope>(async (resolve) => { |
||||||
|
const basicScope = this.getBasicScope(name); |
||||||
|
|
||||||
|
try { |
||||||
|
const serverScope = await getBackendSrv().get<Scope>( |
||||||
|
`/apis/${this._apiGroup}/${this._apiVersion}/namespaces/${this._apiNamespace}/scopes/${name}` |
||||||
|
); |
||||||
|
|
||||||
|
const scope = { |
||||||
|
...basicScope, |
||||||
|
...serverScope, |
||||||
|
metadata: { |
||||||
|
...basicScope.metadata, |
||||||
|
...serverScope.metadata, |
||||||
|
}, |
||||||
|
spec: { |
||||||
|
...basicScope.spec, |
||||||
|
...serverScope.spec, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
resolve(scope); |
||||||
|
} catch (err) { |
||||||
|
this._scopesCache.delete(name); |
||||||
|
|
||||||
|
resolve(basicScope); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
this._scopesCache.set(name, response); |
||||||
|
|
||||||
|
return response; |
||||||
|
}; |
||||||
|
|
||||||
|
public fetchScopesApi = async (treeScopes: TreeScope[]): Promise<SelectedScope[]> => { |
||||||
|
const scopes = await Promise.all(treeScopes.map(({ scopeName }) => this.fetchScopeApi(scopeName))); |
||||||
|
|
||||||
|
return scopes.reduce<SelectedScope[]>((acc, scope, idx) => { |
||||||
|
acc.push({ |
||||||
|
scope, |
||||||
|
path: treeScopes[idx].path, |
||||||
|
}); |
||||||
|
|
||||||
|
return acc; |
||||||
|
}, []); |
||||||
|
}; |
||||||
|
|
||||||
|
public reset = () => { |
||||||
|
ScopesSelectorService.#instance = undefined; |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,31 @@ |
|||||||
|
import { Scope, ScopeNodeSpec } from '@grafana/data'; |
||||||
|
|
||||||
|
export enum NodeReason { |
||||||
|
Persisted, |
||||||
|
Result, |
||||||
|
} |
||||||
|
|
||||||
|
export interface Node extends ScopeNodeSpec { |
||||||
|
name: string; |
||||||
|
reason: NodeReason; |
||||||
|
expandable: boolean; |
||||||
|
selectable: boolean; |
||||||
|
expanded: boolean; |
||||||
|
query: string; |
||||||
|
nodes: NodesMap; |
||||||
|
} |
||||||
|
|
||||||
|
export type NodesMap = Record<string, Node>; |
||||||
|
|
||||||
|
export interface SelectedScope { |
||||||
|
scope: Scope; |
||||||
|
path: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface TreeScope { |
||||||
|
scopeName: string; |
||||||
|
path: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
export type OnNodeUpdate = (path: string[], expanded: boolean, query: string) => void; |
||||||
|
export type OnNodeSelectToggle = (path: string[]) => void; |
@ -1,27 +0,0 @@ |
|||||||
import { config } from '@grafana/runtime'; |
|
||||||
|
|
||||||
import { scopesSelectorScene } from '../instance'; |
|
||||||
|
|
||||||
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks'; |
|
||||||
import { renderDashboard } from './utils/render'; |
|
||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({ |
|
||||||
__esModule: true, |
|
||||||
...jest.requireActual('@grafana/runtime'), |
|
||||||
useChromeHeaderHeight: jest.fn(), |
|
||||||
getBackendSrv: () => ({ get: getMock }), |
|
||||||
getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }), |
|
||||||
usePluginLinks: jest.fn().mockReturnValue({ links: [] }), |
|
||||||
})); |
|
||||||
|
|
||||||
describe('Feature flag off', () => { |
|
||||||
beforeAll(() => { |
|
||||||
config.featureToggles.scopeFilters = false; |
|
||||||
config.featureToggles.groupByVariable = true; |
|
||||||
}); |
|
||||||
|
|
||||||
it('Does not initialize', () => { |
|
||||||
renderDashboard(); |
|
||||||
expect(scopesSelectorScene).toBeNull(); |
|
||||||
}); |
|
||||||
}); |
|
@ -1,364 +0,0 @@ |
|||||||
import { filterFolders, groupDashboards } from '../internal/utils'; |
|
||||||
|
|
||||||
import { |
|
||||||
alternativeDashboardWithRootFolder, |
|
||||||
alternativeDashboardWithTwoFolders, |
|
||||||
dashboardWithOneFolder, |
|
||||||
dashboardWithoutFolder, |
|
||||||
dashboardWithRootFolder, |
|
||||||
dashboardWithRootFolderAndOtherFolder, |
|
||||||
dashboardWithTwoFolders, |
|
||||||
} from './utils/mocks'; |
|
||||||
|
|
||||||
describe('Utils', () => { |
|
||||||
describe('groupDashboards', () => { |
|
||||||
it('Assigns dashboards without groups to root folder', () => { |
|
||||||
expect(groupDashboards([dashboardWithoutFolder])).toEqual({ |
|
||||||
'': { |
|
||||||
title: '', |
|
||||||
isExpanded: true, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
[dashboardWithoutFolder.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithoutFolder.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithoutFolder.status.dashboardTitle, |
|
||||||
items: [dashboardWithoutFolder], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it('Assigns dashboards with root group to root folder', () => { |
|
||||||
expect(groupDashboards([dashboardWithRootFolder])).toEqual({ |
|
||||||
'': { |
|
||||||
title: '', |
|
||||||
isExpanded: true, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
[dashboardWithRootFolder.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithRootFolder.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithRootFolder.status.dashboardTitle, |
|
||||||
items: [dashboardWithRootFolder], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it('Merges folders from multiple dashboards', () => { |
|
||||||
expect(groupDashboards([dashboardWithOneFolder, dashboardWithTwoFolders])).toEqual({ |
|
||||||
'': { |
|
||||||
title: '', |
|
||||||
isExpanded: true, |
|
||||||
folders: { |
|
||||||
'Folder 1': { |
|
||||||
title: 'Folder 1', |
|
||||||
isExpanded: false, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
[dashboardWithOneFolder.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithOneFolder.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithOneFolder.status.dashboardTitle, |
|
||||||
items: [dashboardWithOneFolder], |
|
||||||
}, |
|
||||||
[dashboardWithTwoFolders.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
|
||||||
items: [dashboardWithTwoFolders], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
'Folder 2': { |
|
||||||
title: 'Folder 2', |
|
||||||
isExpanded: false, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
[dashboardWithTwoFolders.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
|
||||||
items: [dashboardWithTwoFolders], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
dashboards: {}, |
|
||||||
}, |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it('Merges scopes from multiple dashboards', () => { |
|
||||||
expect(groupDashboards([dashboardWithTwoFolders, alternativeDashboardWithTwoFolders])).toEqual({ |
|
||||||
'': { |
|
||||||
title: '', |
|
||||||
isExpanded: true, |
|
||||||
folders: { |
|
||||||
'Folder 1': { |
|
||||||
title: 'Folder 1', |
|
||||||
isExpanded: false, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
[dashboardWithTwoFolders.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
|
||||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
'Folder 2': { |
|
||||||
title: 'Folder 2', |
|
||||||
isExpanded: false, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
[dashboardWithTwoFolders.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
|
||||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
dashboards: {}, |
|
||||||
}, |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it('Matches snapshot', () => { |
|
||||||
expect( |
|
||||||
groupDashboards([ |
|
||||||
dashboardWithoutFolder, |
|
||||||
dashboardWithOneFolder, |
|
||||||
dashboardWithTwoFolders, |
|
||||||
alternativeDashboardWithTwoFolders, |
|
||||||
dashboardWithRootFolder, |
|
||||||
alternativeDashboardWithRootFolder, |
|
||||||
dashboardWithRootFolderAndOtherFolder, |
|
||||||
]) |
|
||||||
).toEqual({ |
|
||||||
'': { |
|
||||||
dashboards: { |
|
||||||
[dashboardWithRootFolderAndOtherFolder.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithRootFolderAndOtherFolder.status.dashboardTitle, |
|
||||||
items: [dashboardWithRootFolderAndOtherFolder], |
|
||||||
}, |
|
||||||
[dashboardWithRootFolder.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithRootFolder.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithRootFolder.status.dashboardTitle, |
|
||||||
items: [dashboardWithRootFolder, alternativeDashboardWithRootFolder], |
|
||||||
}, |
|
||||||
[dashboardWithoutFolder.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithoutFolder.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithoutFolder.status.dashboardTitle, |
|
||||||
items: [dashboardWithoutFolder], |
|
||||||
}, |
|
||||||
}, |
|
||||||
folders: { |
|
||||||
'Folder 1': { |
|
||||||
dashboards: { |
|
||||||
[dashboardWithOneFolder.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithOneFolder.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithOneFolder.status.dashboardTitle, |
|
||||||
items: [dashboardWithOneFolder], |
|
||||||
}, |
|
||||||
[dashboardWithTwoFolders.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
|
||||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], |
|
||||||
}, |
|
||||||
}, |
|
||||||
folders: {}, |
|
||||||
isExpanded: false, |
|
||||||
title: 'Folder 1', |
|
||||||
}, |
|
||||||
'Folder 2': { |
|
||||||
dashboards: { |
|
||||||
[dashboardWithTwoFolders.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithTwoFolders.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithTwoFolders.status.dashboardTitle, |
|
||||||
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], |
|
||||||
}, |
|
||||||
}, |
|
||||||
folders: {}, |
|
||||||
isExpanded: false, |
|
||||||
title: 'Folder 2', |
|
||||||
}, |
|
||||||
'Folder 3': { |
|
||||||
dashboards: { |
|
||||||
[dashboardWithRootFolderAndOtherFolder.spec.dashboard]: { |
|
||||||
dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard, |
|
||||||
dashboardTitle: dashboardWithRootFolderAndOtherFolder.status.dashboardTitle, |
|
||||||
items: [dashboardWithRootFolderAndOtherFolder], |
|
||||||
}, |
|
||||||
}, |
|
||||||
folders: {}, |
|
||||||
isExpanded: false, |
|
||||||
title: 'Folder 3', |
|
||||||
}, |
|
||||||
}, |
|
||||||
isExpanded: true, |
|
||||||
title: '', |
|
||||||
}, |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('filterFolders', () => { |
|
||||||
it('Shows folders matching criteria', () => { |
|
||||||
expect( |
|
||||||
filterFolders( |
|
||||||
{ |
|
||||||
'': { |
|
||||||
title: '', |
|
||||||
isExpanded: true, |
|
||||||
folders: { |
|
||||||
'Folder 1': { |
|
||||||
title: 'Folder 1', |
|
||||||
isExpanded: false, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
'Dashboard ID': { |
|
||||||
dashboard: 'Dashboard ID', |
|
||||||
dashboardTitle: 'Dashboard Title', |
|
||||||
items: [], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
'Folder 2': { |
|
||||||
title: 'Folder 2', |
|
||||||
isExpanded: true, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
'Dashboard ID': { |
|
||||||
dashboard: 'Dashboard ID', |
|
||||||
dashboardTitle: 'Dashboard Title', |
|
||||||
items: [], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
dashboards: { |
|
||||||
'Dashboard ID': { |
|
||||||
dashboard: 'Dashboard ID', |
|
||||||
dashboardTitle: 'Dashboard Title', |
|
||||||
items: [], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
'Folder' |
|
||||||
) |
|
||||||
).toEqual({ |
|
||||||
'': { |
|
||||||
title: '', |
|
||||||
isExpanded: true, |
|
||||||
folders: { |
|
||||||
'Folder 1': { |
|
||||||
title: 'Folder 1', |
|
||||||
isExpanded: true, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
'Dashboard ID': { |
|
||||||
dashboard: 'Dashboard ID', |
|
||||||
dashboardTitle: 'Dashboard Title', |
|
||||||
items: [], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
'Folder 2': { |
|
||||||
title: 'Folder 2', |
|
||||||
isExpanded: true, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
'Dashboard ID': { |
|
||||||
dashboard: 'Dashboard ID', |
|
||||||
dashboardTitle: 'Dashboard Title', |
|
||||||
items: [], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
dashboards: {}, |
|
||||||
}, |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it('Shows dashboards matching criteria', () => { |
|
||||||
expect( |
|
||||||
filterFolders( |
|
||||||
{ |
|
||||||
'': { |
|
||||||
title: '', |
|
||||||
isExpanded: true, |
|
||||||
folders: { |
|
||||||
'Folder 1': { |
|
||||||
title: 'Folder 1', |
|
||||||
isExpanded: false, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
'Dashboard ID': { |
|
||||||
dashboard: 'Dashboard ID', |
|
||||||
dashboardTitle: 'Dashboard Title', |
|
||||||
items: [], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
'Folder 2': { |
|
||||||
title: 'Folder 2', |
|
||||||
isExpanded: true, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
'Random ID': { |
|
||||||
dashboard: 'Random ID', |
|
||||||
dashboardTitle: 'Random Title', |
|
||||||
items: [], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
dashboards: { |
|
||||||
'Dashboard ID': { |
|
||||||
dashboard: 'Dashboard ID', |
|
||||||
dashboardTitle: 'Dashboard Title', |
|
||||||
items: [], |
|
||||||
}, |
|
||||||
'Random ID': { |
|
||||||
dashboard: 'Random ID', |
|
||||||
dashboardTitle: 'Random Title', |
|
||||||
items: [], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
'dash' |
|
||||||
) |
|
||||||
).toEqual({ |
|
||||||
'': { |
|
||||||
title: '', |
|
||||||
isExpanded: true, |
|
||||||
folders: { |
|
||||||
'Folder 1': { |
|
||||||
title: 'Folder 1', |
|
||||||
isExpanded: true, |
|
||||||
folders: {}, |
|
||||||
dashboards: { |
|
||||||
'Dashboard ID': { |
|
||||||
dashboard: 'Dashboard ID', |
|
||||||
dashboardTitle: 'Dashboard Title', |
|
||||||
items: [], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
dashboards: { |
|
||||||
'Dashboard ID': { |
|
||||||
dashboard: 'Dashboard ID', |
|
||||||
dashboardTitle: 'Dashboard Title', |
|
||||||
items: [], |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
@ -1,5 +0,0 @@ |
|||||||
import { scopesDashboardsScene } from './instance'; |
|
||||||
|
|
||||||
export const useScopesDashboardsState = () => { |
|
||||||
return scopesDashboardsScene?.useState(); |
|
||||||
}; |
|
@ -1,39 +0,0 @@ |
|||||||
import { Scope } from '@grafana/data'; |
|
||||||
import { sceneGraph, SceneObject } from '@grafana/scenes'; |
|
||||||
|
|
||||||
import { ScopesFacade } from './ScopesFacadeScene'; |
|
||||||
import { scopesDashboardsScene, scopesSelectorScene } from './instance'; |
|
||||||
import { getScopesFromSelectedScopes } from './internal/utils'; |
|
||||||
|
|
||||||
export function getSelectedScopes(): Scope[] { |
|
||||||
return getScopesFromSelectedScopes(scopesSelectorScene?.state.scopes ?? []); |
|
||||||
} |
|
||||||
|
|
||||||
export function getSelectedScopesNames(): string[] { |
|
||||||
return getSelectedScopes().map((scope) => scope.metadata.name); |
|
||||||
} |
|
||||||
|
|
||||||
export function enableScopes() { |
|
||||||
scopesSelectorScene?.enable(); |
|
||||||
scopesDashboardsScene?.enable(); |
|
||||||
} |
|
||||||
|
|
||||||
export function disableScopes() { |
|
||||||
scopesSelectorScene?.disable(); |
|
||||||
scopesDashboardsScene?.disable(); |
|
||||||
} |
|
||||||
|
|
||||||
export function exitScopesReadOnly() { |
|
||||||
scopesSelectorScene?.exitReadOnly(); |
|
||||||
scopesDashboardsScene?.exitReadOnly(); |
|
||||||
} |
|
||||||
|
|
||||||
export function enterScopesReadOnly() { |
|
||||||
scopesSelectorScene?.enterReadOnly(); |
|
||||||
scopesDashboardsScene?.enterReadOnly(); |
|
||||||
} |
|
||||||
|
|
||||||
export function getClosestScopesFacade(scene: SceneObject): ScopesFacade | null { |
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
return sceneGraph.findObject(scene, (obj) => obj instanceof ScopesFacade) as ScopesFacade | null; |
|
||||||
} |
|
Loading…
Reference in new issue