Skip to content
Open
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
30 changes: 23 additions & 7 deletions dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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 @@ -40,6 +42,18 @@ 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.
*/
Comment on lines +45 to +50

@jgbernalp jgbernalp Sep 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got lost on this comment, it says a lot of things but it seems contradictory in the button that updates already-pinned plugins is shown regardless of this flag. as the button is gated behind the isUpdateButtonAvailable.

isLockModeAvailable?: boolean;
/**
* When true, offers the button that updates all plugins to their latest version.
* Based on plugins available. It will open a drawer that shows the plugins that can be updated and allows the user to update them.
*/
isUpdateButtonAvailable?: boolean;
timezone: string;
onEditButtonClick: () => void;
onCancelButtonClick: () => void;
Expand All @@ -56,6 +70,8 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement =>
isAnnotationEnabled,
isDatasourceEnabled,
isLinksEnabled = true,
isLockModeAvailable = false,
isUpdateButtonAvailable = false,
timezone: toolbarTimezone,
onEditButtonClick,
onCancelButtonClick,
Expand Down Expand Up @@ -106,20 +122,20 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement =>
{isLinksEnabled && <EditDashboardLinksButton />}
<AddPanelButton />
<AddGroupButton />
{isUpdateButtonAvailable && <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
5 changes: 4 additions & 1 deletion dashboards/src/components/GridLayout/GridItemContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ 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, {
version: panelDefinition.spec.plugin.metadata?.version,
registry: panelDefinition.spec.plugin.metadata?.registry,
});

const pluginQueryOptions =
typeof plugin?.queryOptions === 'function'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// 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 type { ReactElement } from 'react';
import { useCallback, useMemo, useState } from 'react';

import { useDashboard } from '../../context/useDashboard';
import {
applyPluginVersions,
buildLatestPluginVersions,
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 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="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>
) : (
<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>
)}
<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';
9 changes: 7 additions & 2 deletions dashboards/src/components/Panel/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,12 @@ 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,
version: definition.spec.plugin.metadata?.version,
registry: definition.spec.plugin.metadata?.registry,
});

// More defensive checking for plugin and actions
if (
Expand Down Expand Up @@ -173,7 +178,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
6 changes: 5 additions & 1 deletion dashboards/src/components/Panel/PanelContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ 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,
version: definition?.spec.plugin.metadata?.version,
registry: definition?.spec.plugin.metadata?.registry,
});

// Show fullsize skeleton if the panel plugin is loading.
if (isPanelLoading) {
Expand Down
6 changes: 5 additions & 1 deletion dashboards/src/components/Panel/PanelPluginLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ 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,
version: definition?.spec.plugin.metadata?.version,
registry: definition?.spec.plugin.metadata?.registry,
});
const PanelComponent = plugin?.PanelComponent;
const supportedQueryTypes = plugin?.supportedQueryTypes || [];
// Clear out the queryResults parameter for plugins which don't support any query types
Expand Down
35 changes: 23 additions & 12 deletions dashboards/src/components/PanelDrawer/PanelEditorForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
} from '@perses-dev/components';
import type { PanelEditorValues } from '@perses-dev/plugin-system';
import { PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system';
import type { PanelDefinition } from '@perses-dev/spec';
import type { Definition, PanelDefinition, UnknownSpec } from '@perses-dev/spec';
import type { ReactElement } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
Expand Down Expand Up @@ -62,17 +62,26 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement {
mode: 'onBlur',
defaultValues: initialValues,
});
const pluginMetadata = plugin.metadata;

// 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: pluginMetadata }, 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 @@ -217,16 +226,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: pluginMetadata }}
onChange={(selection) => {
field.onChange(selection.kind);
pluginEditor.onSelectionChange(selection);
}}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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 { Badge, Button, Tooltip } from '@mui/material';
import { useListPluginMetadata } from '@perses-dev/plugin-system';
import UpdateIcon from 'mdi-material-ui/Update';
import type { ReactElement } from 'react';
import { useMemo, useState } from 'react';

import { useDashboard } from '../../context/useDashboard';
import type { OutdatedPlugin } from '../../utils/pluginVersioning';
import { buildLatestPluginVersions, findOutdatedPlugins, updatePluginVersions } from '../../utils/pluginVersioning';
import { UpdatePluginsDrawer } from '../UpdatePluginsDrawer';

/**
* Toolbar button shown when at least one plugin pinned by the dashboard has a newer version installed. Opens a drawer to
* review and select which plugins to update.
*
* This is not reserved to fully locked dashboards: versioning can be enforced partially (a single panel pinned from the
* panel editor, for instance) and those pins are just as worth updating.
*/
export function UpdatePluginsButton(): ReactElement | null {
const { dashboard, setDashboard } = useDashboard();
const { data: pluginMetadata } = useListPluginMetadata();
const [isDrawerOpen, setDrawerOpen] = useState(false);

const outdatedPlugins = useMemo(
() => findOutdatedPlugins(dashboard, buildLatestPluginVersions(pluginMetadata ?? [])),
[dashboard, pluginMetadata],
);

const handleUpdate = (plugins: OutdatedPlugin[]): void => {
setDashboard(updatePluginVersions(dashboard, plugins));
setDrawerOpen(false);
};

// Nothing to update: don't render the button at all.
if (outdatedPlugins.length === 0) {
return null;
}

return (
<>
<Tooltip title="Update plugins to their latest version" placement="bottom">
<Badge badgeContent={outdatedPlugins.length} color="primary">
<Button
onClick={() => setDrawerOpen(true)}
startIcon={<UpdateIcon />}
variant="outlined"
color="secondary"
sx={{ whiteSpace: 'nowrap', minWidth: 'auto' }}
>
Update
</Button>
</Badge>
</Tooltip>
<UpdatePluginsDrawer
isOpen={isDrawerOpen}
outdatedPlugins={outdatedPlugins}
onUpdate={handleUpdate}
onClose={() => setDrawerOpen(false)}
/>
</>
);
}
Loading
Loading