Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import { DownloadButton } from '../DownloadButton';
import { EditButton } from '../EditButton';
import { EditJsonButton } from '../EditJsonButton';
import { LinksDisplay } from '../LinksDisplay';
import { LockDashboardButton } from '../LockDashboardButton';
import { SaveDashboardButton } from '../SaveDashboardButton';
import { UpdatePluginsButton } from '../UpdatePluginsButton';
import { EditVariablesButton } from '../Variables';

export interface DashboardToolbarProps {
Expand All @@ -39,6 +41,13 @@ export interface DashboardToolbarProps {
isAnnotationEnabled: boolean;
isDatasourceEnabled: boolean;
isLinksEnabled?: boolean;
/**
* When true, offers the button that locks/unlocks the dashboard, i.e. pins every plugin it uses to an exact version.
* It only makes the action available: whether the dashboard is actually locked is derived from its plugin
* definitions. Not available by default. Plugin versioning itself is always on: the button that updates
* already-pinned plugins is shown regardless of this flag.
*/
isLockModeAvailable?: boolean;
timezone: string;
onEditButtonClick: () => void;
onCancelButtonClick: () => void;
Expand All @@ -55,6 +64,7 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement =>
isAnnotationEnabled,
isDatasourceEnabled,
isLinksEnabled = true,
isLockModeAvailable = false,
timezone: toolbarTimezone,
onEditButtonClick,
onCancelButtonClick,
Expand Down Expand Up @@ -105,20 +115,20 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement =>
{isLinksEnabled && <EditDashboardLinksButton />}
<AddPanelButton />
<AddGroupButton />
<UpdatePluginsButton />
{isLockModeAvailable && <LockDashboardButton />}
</Stack>
<SaveDashboardButton onSave={onSave} isDisabled={isReadonly} />
<Button variant="outlined" onClick={onCancelButtonClick}>
Cancel
</Button>
</Stack>
) : (
<>
{isBiggerThanSm && (
<Stack direction="row" gap={1} ml="auto">
<EditButton onClick={onEditButtonClick} />
</Stack>
)}
</>
isBiggerThanSm && (
<Stack direction="row" gap={1} ml="auto">
<EditButton onClick={onEditButtonClick} />
</Stack>
)
)}
</Box>
<Box
Expand Down
9 changes: 7 additions & 2 deletions dashboards/src/components/GridLayout/GridItemContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// limitations under the License.

import { Box, useForkRef } from '@mui/material';
import { DataQueriesProvider, usePlugin, useSuggestedStepMs } from '@perses-dev/plugin-system';
import { DataQueriesProvider, usePlugin, useSuggestedStepMs, getPluginOverrides } from '@perses-dev/plugin-system';
import React, { ReactElement, useMemo, useState } from 'react';
import { useInView } from 'react-intersection-observer';

Expand Down Expand Up @@ -103,7 +103,12 @@ export function GridItemContent(props: GridItemContentProps): ReactElement {
// map TimeSeriesQueryDefinition to Definition<UnknownSpec>
const suggestedStepMs = useSuggestedStepMs(width);

const { data: plugin } = usePlugin('Panel', panelDefinition.spec.plugin.kind);
const { data: plugin } = usePlugin(
'Panel',
panelDefinition.spec.plugin.kind,
undefined,
getPluginOverrides(panelDefinition.spec.plugin),
);

const pluginQueryOptions =
typeof plugin?.queryOptions === 'function'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { Button, Tooltip } from '@mui/material';
import { Dialog } from '@perses-dev/components';
import { useListPluginMetadata } from '@perses-dev/plugin-system';
import LockOpenOutline from 'mdi-material-ui/LockOpenOutline';
import LockOutline from 'mdi-material-ui/LockOutline';
import { ReactElement, useCallback, useMemo, useState } from 'react';

import { useDashboard } from '../../context/useDashboard';
import {
applyPluginVersions,
buildLatestPluginVersions,
hasPinnedPluginVersions,
isDashboardLocked,
removePluginVersions,
} from '../../utils/pluginVersioning';

/**
* Toolbar button that "locks" or "unlocks" the dashboard.
*
* Locking pins every plugin definition (panels, queries, variables, datasources, annotations) to the latest version
* currently available in the Perses instance, by setting `plugin.metadata.version`. Unlocking removes that pinned
* version so the plugins float on the latest available version again.
*
* A dashboard can also be versioned partially (a single panel pinned from the panel editor, for instance). In that case
* both actions are offered: locking completes the pinning, unlocking clears it.
*
* Both actions are confirmed through a dialog explaining their consequences before the dashboard is updated.
*/
export function LockDashboardButton(): ReactElement {
const { dashboard, setDashboard } = useDashboard();
const { data: pluginMetadata, isLoading } = useListPluginMetadata();
const [pendingAction, setPendingAction] = useState<'lock' | 'unlock' | undefined>(undefined);

const isLocked = useMemo(() => isDashboardLocked(dashboard), [dashboard]);
const hasPins = useMemo(() => hasPinnedPluginVersions(dashboard), [dashboard]);

const closeConfirmation = useCallback((): void => setPendingAction(undefined), []);

const handleConfirm = useCallback((): void => {
if (pendingAction === 'unlock') {
setDashboard(removePluginVersions(dashboard));
} else if (pendingAction === 'lock') {
setDashboard(applyPluginVersions(dashboard, buildLatestPluginVersions(pluginMetadata ?? [])));
}
setPendingAction(undefined);
}, [dashboard, pendingAction, pluginMetadata, setDashboard]);

const isUnlockAction = pendingAction === 'unlock';
const confirmLabel = isUnlockAction ? 'Unlock' : 'Lock';

return (
<>
{!isLocked && (
<Tooltip title="Pin every plugin to its latest version" placement="bottom">
<span>
<Button
onClick={() => setPendingAction('lock')}
disabled={isLoading}
startIcon={<LockOutline />}
variant="outlined"
color="secondary"
sx={{ whiteSpace: 'nowrap', minWidth: 'auto' }}
>
Lock
</Button>
</span>
</Tooltip>
)}
{hasPins && (
<Tooltip title="Remove the pinned plugin versions" placement="bottom">
<span>
<Button
onClick={() => setPendingAction('unlock')}
startIcon={<LockOpenOutline />}
variant="outlined"
color="secondary"
sx={{ whiteSpace: 'nowrap', minWidth: 'auto' }}
>
Unlock
</Button>
</span>
</Tooltip>
)}
<Dialog open={pendingAction !== undefined} onClose={closeConfirmation} aria-labelledby="lock-dashboard-dialog">
<Dialog.Header id="lock-dashboard-dialog" onClose={closeConfirmation}>
{isUnlockAction ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Dialog.Header>
<Dialog.Content>
{isUnlockAction
? 'Unlocking removes the plugin versions pinned on this dashboard. Its panels, queries, variables, datasources and annotations will use the latest plugin versions available in this Perses instance, so their behavior may change when those plugins are updated.'
: 'Locking pins every plugin used by this dashboard (panels, queries, variables, datasources and annotations) to the latest version currently available in this Perses instance. The dashboard keeps using those exact versions, even after the plugins are updated. Plugins that are not installed in this instance cannot be pinned.'}
{' The change only applies once you save the dashboard.'}
</Dialog.Content>
<Dialog.Actions>
<Dialog.PrimaryButton onClick={handleConfirm}>{confirmLabel}</Dialog.PrimaryButton>
<Dialog.SecondaryButton onClick={closeConfirmation}>Cancel</Dialog.SecondaryButton>
</Dialog.Actions>
</Dialog>
</>
);
}
14 changes: 14 additions & 0 deletions dashboards/src/components/LockDashboardButton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

export * from './LockDashboardButton';
10 changes: 7 additions & 3 deletions dashboards/src/components/Panel/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
combineSx,
useId,
} from '@perses-dev/components';
import { ActionOptions, useDataQueriesContext, usePluginRegistry } from '@perses-dev/plugin-system';
import { ActionOptions, useDataQueriesContext, usePluginRegistry, getPluginOverrides } from '@perses-dev/plugin-system';
import { PanelDefinition } from '@perses-dev/spec';
import { ReactNode, memo, useEffect, useMemo, useState } from 'react';
import useResizeObserver from 'use-resize-observer';
Expand Down Expand Up @@ -132,7 +132,11 @@ export const Panel = memo(function Panel(props: PanelProps) {
}

try {
const plugin = await getPlugin({ kind: 'Panel', name: panelPluginKind });
const plugin = await getPlugin({
kind: 'Panel',
name: panelPluginKind,
...getPluginOverrides(definition.spec.plugin),
});

// More defensive checking for plugin and actions
if (
Expand Down Expand Up @@ -169,7 +173,7 @@ export const Panel = memo(function Panel(props: PanelProps) {
};

loadPluginActions();
}, [definition.spec.plugin.kind, panelPropsForActions, getPlugin]);
}, [definition.spec.plugin, panelPropsForActions, getPlugin]);

const handleMouseEnter: CardProps['onMouseEnter'] = (e) => {
onMouseEnter?.(e);
Expand Down
9 changes: 7 additions & 2 deletions dashboards/src/components/Panel/PanelContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import { Skeleton } from '@mui/material';
import { LoadingOverlay } from '@perses-dev/components';
import { usePlugin, PanelProps, QueryData, PanelPlugin } from '@perses-dev/plugin-system';
import { usePlugin, PanelProps, QueryData, PanelPlugin, getPluginOverrides } from '@perses-dev/plugin-system';
import { UnknownSpec, PanelDefinition, QueryDataType } from '@perses-dev/spec';
import { ReactElement } from 'react';

Expand All @@ -31,7 +31,12 @@ export interface PanelContentProps extends Omit<PanelProps<UnknownSpec>, 'queryR
*/
export function PanelContent(props: PanelContentProps): ReactElement {
const { panelPluginKind, definition, queryResults, spec, contentDimensions } = props;
const { data: plugin, isLoading: isPanelLoading } = usePlugin('Panel', panelPluginKind, { useErrorBoundary: true });
const { data: plugin, isLoading: isPanelLoading } = usePlugin(
'Panel',
panelPluginKind,
{ useErrorBoundary: true },
getPluginOverrides(definition?.spec.plugin),
);

// Show fullsize skeleton if the panel plugin is loading.
if (isPanelLoading) {
Expand Down
9 changes: 7 additions & 2 deletions dashboards/src/components/Panel/PanelPluginLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// limitations under the License.

import { Skeleton } from '@mui/material';
import { usePlugin, PanelProps } from '@perses-dev/plugin-system';
import { usePlugin, PanelProps, getPluginOverrides } from '@perses-dev/plugin-system';
import { UnknownSpec, QueryDataType } from '@perses-dev/spec';
import { ReactElement } from 'react';

Expand All @@ -26,7 +26,12 @@ interface PanelPluginProps extends PanelProps<UnknownSpec, QueryDataType> {
*/
export function PanelPluginLoader(props: PanelPluginProps): ReactElement {
const { kind, spec, contentDimensions, definition, queryResults } = props;
const { data: plugin, isLoading: isPanelLoading } = usePlugin('Panel', kind, { useErrorBoundary: true });
const { data: plugin, isLoading: isPanelLoading } = usePlugin(
'Panel',
kind,
{ useErrorBoundary: true },
getPluginOverrides(definition?.spec.plugin),
);
const PanelComponent = plugin?.PanelComponent;
const supportedQueryTypes = plugin?.supportedQueryTypes || [];
// Clear out the queryResults parameter for plugins which don't support any query types
Expand Down
45 changes: 32 additions & 13 deletions dashboards/src/components/PanelDrawer/PanelEditorForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,14 @@ import {
getSubmitText,
getTitleAction,
} from '@perses-dev/components';
import { PanelEditorValues, PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system';
import { PanelDefinition } from '@perses-dev/spec';
import {
getPluginOverrides,
PanelEditorValues,
PluginKindSelect,
usePluginEditor,
useValidationSchemas,
} from '@perses-dev/plugin-system';
import { PanelDefinition, Definition, UnknownSpec } from '@perses-dev/spec';
import { ReactElement, useCallback, useEffect, useState } from 'react';
import { Controller, FormProvider, SubmitHandler, useForm, useWatch } from 'react-hook-form';

Expand Down Expand Up @@ -60,16 +66,27 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement {
defaultValues: initialValues,
});

// The version/registry the panel is currently pinned to, if any. `latest` is not a pin, so it is filtered out.
const pinnedPluginMetadata = getPluginOverrides(plugin);

// Use common plugin editor logic even though we've split the inputs up in this form
const pluginEditor = usePluginEditor({
pluginTypes: ['Panel'],
value: { selection: { kind: plugin.kind, type: 'Panel' }, spec: plugin.spec },
onChange: (plugin) => {
form.setValue('panelDefinition.spec.plugin', { kind: plugin.selection.kind, spec: plugin.spec });
setPlugin({
kind: plugin.selection.kind,
spec: plugin.spec,
});
// Carry the current pin so that editing the options doesn't silently drop it, and so the options editor is loaded
// from the pinned implementation.
value: { selection: { kind: plugin.kind, type: 'Panel', metadata: pinnedPluginMetadata }, spec: plugin.spec },
onChange: (next) => {
// Persist the selected version/registry (if any) as plugin metadata so the panel uses that exact implementation.
// When nothing is selected (a single version/registry is available), metadata is omitted so the latest version
// of the default registry is used.
const metadata = next.selection.metadata;
const nextPlugin: Definition<UnknownSpec> = {
kind: next.selection.kind,
...(metadata?.version || metadata?.registry ? { metadata } : {}),
spec: next.spec,
};
form.setValue('panelDefinition.spec.plugin', nextPlugin);
setPlugin(nextPlugin);
},
onHideQueryEditorChange: (isHidden) => {
setQueries(undefined, isHidden);
Expand Down Expand Up @@ -214,16 +231,18 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement {
<PluginKindSelect
{...field}
pluginTypes={['Panel']}
enableVersionSelection
enableRegistrySelection
required
fullWidth
label="Type"
disabled={pluginEditor.isLoading}
error={!!pluginEditor.error || !!fieldState.error}
helperText={pluginEditor.error?.message ?? fieldState.error?.message}
value={{ type: 'Panel', kind: watchedPluginKind }}
onChange={(event) => {
field.onChange(event.kind);
pluginEditor.onSelectionChange(event);
value={{ type: 'Panel', kind: watchedPluginKind, metadata: pinnedPluginMetadata }}
onChange={(selection) => {
field.onChange(selection.kind);
pluginEditor.onSelectionChange(selection);
}}
/>
)}
Expand Down
Loading
Loading