mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 23:20:37 +01:00
Compare commits
5 Commits
main
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7136a51fdc | ||
|
|
3b1c1c401f | ||
|
|
58aec92161 | ||
|
|
4e1c77cab1 | ||
|
|
14694f55e1 |
@@ -47,7 +47,7 @@ function CheckboxValueRow({
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<TooltipSimple title={String(value)} side="top" align="start">
|
||||
<TooltipSimple title={value} side="top" align="center" arrow>
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -32,11 +32,6 @@ export enum LOCALSTORAGE {
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
|
||||
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
|
||||
QUICK_FILTERS_WIDTH_TRACES = 'QUICK_FILTERS_WIDTH_TRACES',
|
||||
QUICK_FILTERS_WIDTH_METER = 'QUICK_FILTERS_WIDTH_METER',
|
||||
QUICK_FILTERS_WIDTH_API_MONITORING = 'QUICK_FILTERS_WIDTH_API_MONITORING',
|
||||
QUICK_FILTERS_WIDTH_EXCEPTIONS = 'QUICK_FILTERS_WIDTH_EXCEPTIONS',
|
||||
QUICK_FILTERS_WIDTH_INFRA = 'QUICK_FILTERS_WIDTH_INFRA',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
|
||||
@@ -163,23 +163,12 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.api-quick-filter-left-section {
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,49 +4,21 @@ import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
|
||||
import DomainList from './Domains/DomainList';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
useEffect(() => {
|
||||
logEvent('API Monitoring: Landing page visited', {});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_API_MONITORING,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="api-quick-filter-left-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
@@ -55,7 +27,7 @@ function Explorer(): JSX.Element {
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</section>
|
||||
<DomainList />
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -6,7 +6,6 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
} from 'container/InfraMonitoringK8s/hooks';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -43,21 +40,8 @@ import { getHostsQuickFiltersConfig } from './utils';
|
||||
import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
|
||||
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
<Tooltip title="Collapse Filters">
|
||||
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.listContainer}${
|
||||
|
||||
@@ -44,20 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
@@ -136,9 +142,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.quickFiltersToggleContainer {
|
||||
|
||||
@@ -44,15 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
@@ -117,9 +128,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
@@ -210,16 +219,8 @@
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import {
|
||||
@@ -23,8 +22,6 @@ import {
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { FeatureKeys } from '../../constants/features';
|
||||
@@ -59,23 +56,9 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
|
||||
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
@@ -3,23 +3,12 @@
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
// Width is owned by ResizableBox (inline style).
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
@@ -89,9 +78,7 @@
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
width: calc(100% - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
@@ -19,8 +18,6 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Filter } from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -33,10 +30,6 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
handleRunQuery,
|
||||
@@ -62,16 +55,6 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const [showQuickFilters, setShowQuickFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_METER,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
@@ -144,19 +127,10 @@ function Explorer(): JSX.Element {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
<div
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
@@ -168,7 +142,7 @@ function Explorer(): JSX.Element {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
|
||||
@@ -20,23 +20,12 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.all-errors-quick-filter-section {
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,12 @@ import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
|
||||
import { routes } from './config';
|
||||
import { useAllErrorsQueryState } from './QueryStateContext';
|
||||
|
||||
import './AllErrors.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function AllErrors(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
@@ -61,38 +55,17 @@ function AllErrors(): JSX.Element {
|
||||
setShowFilters((prev) => !prev);
|
||||
};
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_EXCEPTIONS,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="all-errors-quick-filter-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<section className={cx('all-errors-quick-filter-section')}>
|
||||
<QuickFilters
|
||||
className="qf-exceptions"
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</section>
|
||||
)}
|
||||
<section
|
||||
className={cx(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -29,6 +30,7 @@ import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
|
||||
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
|
||||
@@ -58,7 +60,8 @@ function DashboardActions({
|
||||
onLockToggle,
|
||||
onOpenRename,
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
@@ -111,23 +114,37 @@ function DashboardActions({
|
||||
});
|
||||
}, [deleteDashboardMutation]);
|
||||
|
||||
// Edit-action label: plain text when editable, else a disabled row whose label
|
||||
// still shows a hover tooltip explaining why (locked / no permission).
|
||||
const editLabel = useCallback(
|
||||
(text: string): ReactNode =>
|
||||
isEditable ? (
|
||||
text
|
||||
) : (
|
||||
<DisabledMenuItemLabel reason={editDisabledReason}>
|
||||
{text}
|
||||
</DisabledMenuItemLabel>
|
||||
),
|
||||
[isEditable, editDisabledReason],
|
||||
);
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const dashboardGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
dashboardGroup.push({
|
||||
const dashboardGroup: MenuItem[] = [
|
||||
{
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
label: editLabel('Rename'),
|
||||
icon: <PenLine size={14} />,
|
||||
disabled: !isEditable,
|
||||
onClick: onOpenRename,
|
||||
});
|
||||
}
|
||||
dashboardGroup.push({
|
||||
key: 'clone',
|
||||
label: 'Clone dashboard',
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isCloning,
|
||||
onClick: (): void => void handleClone(),
|
||||
});
|
||||
},
|
||||
{
|
||||
key: 'clone',
|
||||
label: 'Clone dashboard',
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isCloning,
|
||||
onClick: (): void => void handleClone(),
|
||||
},
|
||||
];
|
||||
if (isAuthor || user.role === USER_ROLES.ADMIN) {
|
||||
dashboardGroup.push({
|
||||
key: 'lock',
|
||||
@@ -144,45 +161,40 @@ function DashboardActions({
|
||||
onClick: handle.enter,
|
||||
});
|
||||
|
||||
const layoutGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
layoutGroup.push({
|
||||
key: 'new-section',
|
||||
label: 'New section',
|
||||
icon: <SquareStack size={14} />,
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
});
|
||||
}
|
||||
|
||||
const items: MenuItem[] = [
|
||||
return [
|
||||
{
|
||||
type: 'group',
|
||||
key: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
children: dashboardGroup,
|
||||
},
|
||||
];
|
||||
if (layoutGroup.length > 0) {
|
||||
items.push({
|
||||
{
|
||||
type: 'group',
|
||||
key: 'group-layout',
|
||||
label: 'Layout',
|
||||
children: layoutGroup,
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
children: [
|
||||
{
|
||||
key: 'new-section',
|
||||
label: editLabel('New section'),
|
||||
icon: <SquareStack size={14} />,
|
||||
disabled: !isEditable,
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'divider', key: 'divider-danger' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete dashboard',
|
||||
label: editLabel('Delete dashboard'),
|
||||
icon: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
disabled: !isEditable,
|
||||
onClick: (): void => setIsDeleteOpen(true),
|
||||
},
|
||||
);
|
||||
return items;
|
||||
];
|
||||
}, [
|
||||
canEdit,
|
||||
editLabel,
|
||||
isEditable,
|
||||
isCloning,
|
||||
isAuthor,
|
||||
user.role,
|
||||
@@ -194,6 +206,16 @@ function DashboardActions({
|
||||
handle.enter,
|
||||
]);
|
||||
|
||||
// A disabled edit control stays visible with a tooltip explaining why.
|
||||
const withDisabledTooltip = (node: JSX.Element): JSX.Element =>
|
||||
isEditable ? (
|
||||
node
|
||||
) : (
|
||||
<TooltipSimple title={editDisabledReason} disableHoverableContent>
|
||||
{node}
|
||||
</TooltipSimple>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
@@ -207,27 +229,26 @@ function DashboardActions({
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
{withDisabledTooltip(
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={!isEditable}
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>,
|
||||
)}
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -238,17 +259,18 @@ function DashboardActions({
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
{!isDashboardLocked && (
|
||||
{withDisabledTooltip(
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</Button>,
|
||||
)}
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useVisibleTagCount } from './useVisibleTagCount';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface DashboardInfoProps {
|
||||
@@ -100,7 +99,7 @@ function DashboardInfo({
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
maxLength={120}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
|
||||
import styles from './JsonEditorDrawer.module.scss';
|
||||
import JsonEditorToolbar from './JsonEditorToolbar';
|
||||
import { useJsonEditor } from './useJsonEditor';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface JsonEditorDrawerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -28,6 +29,12 @@ function JsonEditorDrawer({
|
||||
}: JsonEditorDrawerProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const readOnlyReason = useDashboardStore((s) => s.editDisabledReason);
|
||||
// Locked/no-permission dashboards open the JSON for inspection only — Apply,
|
||||
// Format and Reset are disabled and edits can't be saved.
|
||||
const readOnly = !isEditable;
|
||||
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
@@ -39,7 +46,7 @@ function JsonEditorDrawer({
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
} = useJsonEditor({ dashboard, isOpen, readOnly, onApplied: onClose });
|
||||
|
||||
const onCopy = useCallback((): void => {
|
||||
copyToClipboard(draft);
|
||||
@@ -63,13 +70,15 @@ function JsonEditorDrawer({
|
||||
event.stopPropagation();
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void apply();
|
||||
if (!readOnly) {
|
||||
void apply();
|
||||
}
|
||||
}
|
||||
},
|
||||
[apply],
|
||||
[apply, readOnly],
|
||||
);
|
||||
|
||||
const applyDisabled = !isDirty || !validity.valid || isSaving;
|
||||
const applyDisabled = readOnly || !isDirty || !validity.valid || isSaving;
|
||||
const validationText = validity.valid
|
||||
? `Valid JSON · ${validity.lineCount} lines`
|
||||
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
|
||||
@@ -150,16 +159,30 @@ function JsonEditorDrawer({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="json-editor-apply"
|
||||
disabled={applyDisabled}
|
||||
onClick={(): void => void apply()}
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
{readOnly ? (
|
||||
<TooltipSimple title={readOnlyReason} disableHoverableContent>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="json-editor-apply"
|
||||
disabled
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="json-editor-apply"
|
||||
disabled={applyDisabled}
|
||||
onClick={(): void => void apply()}
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -168,6 +191,7 @@ function JsonEditorDrawer({
|
||||
<div className={styles.body} onKeyDown={onKeyDown}>
|
||||
<JsonEditorToolbar
|
||||
isDirty={isDirty}
|
||||
readOnly={readOnly}
|
||||
onFormat={format}
|
||||
onCopy={onCopy}
|
||||
onDownload={onDownload}
|
||||
@@ -180,6 +204,7 @@ function JsonEditorDrawer({
|
||||
value={draft}
|
||||
onChange={(value): void => setDraft(value ?? '')}
|
||||
options={{
|
||||
readOnly,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
|
||||
@@ -5,6 +5,8 @@ import styles from './JsonEditorToolbar.module.scss';
|
||||
|
||||
interface JsonEditorToolbarProps {
|
||||
isDirty: boolean;
|
||||
/** Locked/no-permission — Format and Reset (draft mutators) are disabled. */
|
||||
readOnly?: boolean;
|
||||
onFormat: () => void;
|
||||
onCopy: () => void;
|
||||
onDownload: () => void;
|
||||
@@ -13,6 +15,7 @@ interface JsonEditorToolbarProps {
|
||||
|
||||
function JsonEditorToolbar({
|
||||
isDirty,
|
||||
readOnly = false,
|
||||
onFormat,
|
||||
onCopy,
|
||||
onDownload,
|
||||
@@ -26,6 +29,7 @@ function JsonEditorToolbar({
|
||||
size="sm"
|
||||
prefix={<AlignLeft size={14} />}
|
||||
testId="json-editor-format"
|
||||
disabled={readOnly}
|
||||
onClick={onFormat}
|
||||
>
|
||||
Format
|
||||
@@ -57,7 +61,7 @@ function JsonEditorToolbar({
|
||||
size="sm"
|
||||
prefix={<RotateCcw size={14} />}
|
||||
testId="json-editor-reset"
|
||||
disabled={!isDirty}
|
||||
disabled={readOnly || !isDirty}
|
||||
onClick={onReset}
|
||||
>
|
||||
Reset
|
||||
|
||||
@@ -7,6 +7,13 @@ import { useJsonEditor } from '../useJsonEditor';
|
||||
|
||||
jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
|
||||
|
||||
// Editable by default so the drawer renders in its editable (non-read-only) mode.
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { isEditable: boolean; editDisabledReason: string }) => unknown,
|
||||
): unknown => selector({ isEditable: true, editDisabledReason: '' }),
|
||||
}));
|
||||
|
||||
jest.mock('@monaco-editor/react', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface JsonValidity {
|
||||
interface Params {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
isOpen: boolean;
|
||||
/** Locked/no-permission — `apply` is a no-op so edits can never be saved. */
|
||||
readOnly?: boolean;
|
||||
onApplied: () => void;
|
||||
}
|
||||
|
||||
@@ -77,6 +79,7 @@ function errorLineFromMessage(
|
||||
export function useJsonEditor({
|
||||
dashboard,
|
||||
isOpen,
|
||||
readOnly = false,
|
||||
onApplied,
|
||||
}: Params): Result {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
@@ -147,7 +150,7 @@ export function useJsonEditor({
|
||||
}, [appliedText]);
|
||||
|
||||
const apply = useCallback(async (): Promise<void> => {
|
||||
if (!validity.valid || !isDirty) {
|
||||
if (readOnly || !validity.valid || !isDirty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -173,6 +176,7 @@ export function useJsonEditor({
|
||||
validity.valid,
|
||||
isDirty,
|
||||
draft,
|
||||
readOnly,
|
||||
refetch,
|
||||
onApplied,
|
||||
showErrorModal,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
lockDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
@@ -32,13 +35,13 @@ import styles from './DashboardPageToolbar.module.scss';
|
||||
interface DashboardPageToolbarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { dashboard, handle, refetch } = props;
|
||||
const { dashboard, handle } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Session-local lock state: the toggle appears once locked and persists for the page.
|
||||
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
|
||||
@@ -101,12 +104,21 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
}
|
||||
refetch();
|
||||
// Patch just the `locked` flag in the cache — a full refetch would reload
|
||||
// every panel's chart data for a metadata-only change.
|
||||
const key = getGetDashboardV2QueryKey({ id });
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
|
||||
if (cached) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(key, {
|
||||
...cached,
|
||||
data: { ...cached.data, locked: next },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setIsDashboardLocked(!next);
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [id, isDashboardLocked, refetch, showErrorModal]);
|
||||
}, [id, isDashboardLocked, queryClient, showErrorModal]);
|
||||
|
||||
const onNameSave = useCallback(
|
||||
async (next: string): Promise<void> => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Input as AntdInput } from 'antd';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './DashboardInfoForm.module.scss';
|
||||
|
||||
@@ -72,7 +71,6 @@ function DashboardInfoForm({
|
||||
testId="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={title}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -46,12 +46,8 @@ export interface UseVariableForm {
|
||||
handleSave: () => void;
|
||||
}
|
||||
|
||||
// `defaultValue` is a string | string[] on the wire; the editor uses a single
|
||||
// string, so take the first when it's an array.
|
||||
const readDefaultValue = (model: VariableFormModel): string => {
|
||||
const dv = model.defaultValue;
|
||||
return Array.isArray(dv) ? (dv[0] ?? '') : (dv ?? '');
|
||||
};
|
||||
const readDefaultValue = (model: VariableFormModel): string =>
|
||||
((model.defaultValue as { value?: string })?.value ?? '') as string;
|
||||
|
||||
/** Form state, derivations and handlers for the variable editor. */
|
||||
export function useVariableForm({
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
|
||||
import styles from './VariableInfoForm.module.scss';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../../constants';
|
||||
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
|
||||
|
||||
interface VariableInfoFormProps {
|
||||
@@ -31,7 +30,6 @@ function VariableInfoForm({
|
||||
testId="variable-name"
|
||||
className={styles.variableNameInput}
|
||||
value={title}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
placeholder="Unique name of the variable"
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,6 @@ import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
import styles from './ConfigPane.module.scss';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { PanelKind } from '../../Panels/types/panelKind';
|
||||
|
||||
interface ConfigPaneProps {
|
||||
@@ -87,7 +86,6 @@ function ConfigPane({
|
||||
data-testid="panel-editor-v2-title"
|
||||
value={spec.display.name}
|
||||
placeholder="Panel title"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => setDisplayField('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SolidAlertTriangle, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
|
||||
@@ -12,6 +13,9 @@ interface HeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
showSwitchToView?: boolean;
|
||||
/** Locked/no-permission dashboard — Save is disabled with a reason. */
|
||||
readOnly?: boolean;
|
||||
readOnlyReason?: string;
|
||||
onSave: () => void;
|
||||
onSwitchToView?: () => void;
|
||||
onClose: () => void;
|
||||
@@ -21,6 +25,8 @@ function Header({
|
||||
isDirty,
|
||||
isSaving,
|
||||
showSwitchToView = false,
|
||||
readOnly = false,
|
||||
readOnlyReason,
|
||||
onSave,
|
||||
onSwitchToView,
|
||||
onClose,
|
||||
@@ -63,16 +69,29 @@ function Header({
|
||||
Switch to View Mode
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
{readOnly ? (
|
||||
<TooltipSimple title={readOnlyReason} disableHoverableContent>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogWrapper
|
||||
|
||||
@@ -159,6 +159,8 @@ function makePanel(
|
||||
const baseProps = {
|
||||
dashboardId: 'dash-1',
|
||||
panelId: 'panel-1',
|
||||
isEditable: true,
|
||||
editDisabledReason: '',
|
||||
onClose: jest.fn(),
|
||||
onSaved: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -43,6 +43,10 @@ interface PanelEditorContainerProps {
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
/** The dashboard can be edited (unlocked + permission); gates Save. */
|
||||
isEditable: boolean;
|
||||
/** Why Save is disabled (locked / no permission); '' when editable. */
|
||||
editDisabledReason: string;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
@@ -60,6 +64,8 @@ function PanelEditorContainer({
|
||||
panel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
isEditable,
|
||||
editDisabledReason,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
@@ -198,6 +204,9 @@ function PanelEditorContainer({
|
||||
});
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
@@ -206,7 +215,7 @@ function PanelEditorContainer({
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [save, buildSaveSpec, draft.spec, onSaved]);
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
@@ -214,6 +223,8 @@ function PanelEditorContainer({
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
showSwitchToView={!isNew}
|
||||
readOnly={!isEditable}
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={onSwitchToView}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -1,60 +1,37 @@
|
||||
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
|
||||
import { Clock, RotateCw } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
selectViewPanelExtendWindow,
|
||||
useViewPanelStore,
|
||||
} from '../../../store/useViewPanelStore';
|
||||
import PanelLoader from '../PanelLoader/PanelLoader';
|
||||
import PanelMessage, { PanelMessageAction } from '../PanelMessage/PanelMessage';
|
||||
import { useExtendTimeWindow } from './useExtendTimeWindow';
|
||||
import PanelMessage from '../PanelMessage/PanelMessage';
|
||||
|
||||
interface NoDataProps {
|
||||
/** Title override. Defaults to the time-range empty-state copy. */
|
||||
title?: string;
|
||||
/** Description override. Defaults to the "widen the range" hint. */
|
||||
description?: string;
|
||||
/** In flight over empty data → show the loader, not the empty state. */
|
||||
isFetching?: boolean;
|
||||
/** When provided, renders a Retry button that re-runs the query. */
|
||||
onRetry?: () => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared empty-state for panel renderers. The query succeeded but returned nothing,
|
||||
* so we offer to widen the time window — global by default, or the View modal's
|
||||
* local window when it publishes one to the store — alongside a Retry that re-runs
|
||||
* the query.
|
||||
* Shared empty-state for panel renderers: wraps `PanelMessage` so every panel
|
||||
* kind surfaces the same "no data" affordance when a query returns nothing.
|
||||
*/
|
||||
function NoData({
|
||||
title = 'No data in this time range',
|
||||
description = 'Nothing in the selected window. Try widening the range.',
|
||||
isFetching = false,
|
||||
onRetry,
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
const viewExtend = useViewPanelStore(selectViewPanelExtendWindow);
|
||||
const globalExtend = useExtendTimeWindow();
|
||||
const { canExtend, actionLabel, extend } = viewExtend ?? globalExtend;
|
||||
|
||||
if (isFetching) {
|
||||
return <PanelLoader />;
|
||||
}
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
canExtend && actionLabel
|
||||
? { label: actionLabel, onClick: extend, icon: <CalendarRange size={14} /> }
|
||||
: undefined;
|
||||
|
||||
const retryAction: PanelMessageAction | undefined = onRetry
|
||||
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<PanelMessage
|
||||
icon={<Clock size={18} />}
|
||||
title={title}
|
||||
description={description}
|
||||
action={extendAction ?? retryAction}
|
||||
secondaryAction={extendAction ? retryAction : undefined}
|
||||
action={
|
||||
onRetry
|
||||
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
|
||||
: undefined
|
||||
}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { useViewPanelStore } from '../../../../store/useViewPanelStore';
|
||||
import { ExtendTimeWindow } from '../extendWindow';
|
||||
import NoData from '../NoData';
|
||||
|
||||
const mockUseExtendTimeWindow = jest.fn();
|
||||
jest.mock('../useExtendTimeWindow', () => ({
|
||||
useExtendTimeWindow: (): ExtendTimeWindow => mockUseExtendTimeWindow(),
|
||||
}));
|
||||
|
||||
const inert: ExtendTimeWindow = {
|
||||
canExtend: false,
|
||||
actionLabel: null,
|
||||
extend: jest.fn(),
|
||||
};
|
||||
|
||||
function extender(over?: Partial<ExtendTimeWindow>): ExtendTimeWindow {
|
||||
return {
|
||||
canExtend: true,
|
||||
actionLabel: 'Extend time range',
|
||||
extend: jest.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('NoData', () => {
|
||||
beforeEach(() => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(inert);
|
||||
useViewPanelStore.setState({ viewPanelExtendWindow: null });
|
||||
});
|
||||
|
||||
it('renders the empty-state title and hint', () => {
|
||||
render(<NoData />);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
|
||||
expect(screen.getByText('No data in this time range')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Nothing in the selected window. Try widening the range.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers to extend the window as the primary action', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData />);
|
||||
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Extend time range');
|
||||
// No retry handler → no secondary Retry button.
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders both Extend (primary) and Retry (secondary) when a retry handler is given', () => {
|
||||
const onRetry = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData onRetry={onRetry} />);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
|
||||
'Extend time range',
|
||||
);
|
||||
const retry = screen.getByTestId('panel-no-data-secondary-action');
|
||||
expect(retry).toHaveTextContent('Retry');
|
||||
|
||||
fireEvent.click(retry);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to Retry as the sole action when the window cannot be widened', () => {
|
||||
const onRetry = jest.fn();
|
||||
render(<NoData onRetry={onRetry} />);
|
||||
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Retry');
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(action);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prefers the View modal extender (store) over the global one', () => {
|
||||
const globalExtend = jest.fn();
|
||||
const storeExtend = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender({ extend: globalExtend }));
|
||||
useViewPanelStore.setState({
|
||||
viewPanelExtendWindow: extender({ extend: storeExtend }),
|
||||
});
|
||||
render(<NoData />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-no-data-action'));
|
||||
expect(storeExtend).toHaveBeenCalledTimes(1);
|
||||
expect(globalExtend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders no action when nothing can be widened and no retry handler', () => {
|
||||
render(<NoData />);
|
||||
|
||||
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the panel loader (not the empty state) while refetching', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData isFetching />);
|
||||
|
||||
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-no-data')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('honours the data-testid override for the number panel', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData data-testid="number-panel-no-data" />);
|
||||
|
||||
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import { useExtendTimeWindow } from '../useExtendTimeWindow';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * MINUTE_MS;
|
||||
|
||||
// A window anchored in the past so the zoom-out ladder is deterministic (real
|
||||
// Date.now() is far ahead, so it always center-anchors into a custom range).
|
||||
const PAST_END_MS = 1_600_003_600_000;
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
function globalTimeState(
|
||||
partial: Pick<GlobalReducer, 'selectedTime' | 'minTime' | 'maxTime'>,
|
||||
): GlobalReducer {
|
||||
return {
|
||||
loading: false,
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(globalTime: GlobalReducer): {
|
||||
result: { current: ReturnType<typeof useExtendTimeWindow> };
|
||||
store: ReturnType<typeof mockStore>;
|
||||
} {
|
||||
const store = mockStore({ globalTime });
|
||||
const { result } = renderHook(() => useExtendTimeWindow(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</Provider>
|
||||
),
|
||||
});
|
||||
return { result, store };
|
||||
}
|
||||
|
||||
describe('useExtendTimeWindow', () => {
|
||||
beforeEach(() => {
|
||||
mockSafeNavigate.mockClear();
|
||||
});
|
||||
|
||||
it('reports it can extend and dispatches a wider range', () => {
|
||||
const { result, store } = setup(
|
||||
globalTimeState({
|
||||
selectedTime: '30m',
|
||||
minTime: (PAST_END_MS - 30 * MINUTE_MS) * NS_PER_MS,
|
||||
maxTime: PAST_END_MS * NS_PER_MS,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.canExtend).toBe(true);
|
||||
expect(result.current.actionLabel).toBe('Extend time range');
|
||||
|
||||
act(() => result.current.extend());
|
||||
|
||||
const [action] = store.getActions();
|
||||
expect(action.type).toBe(UPDATE_TIME_INTERVAL);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cannot extend past the widest window', () => {
|
||||
const { result, store } = setup(
|
||||
globalTimeState({
|
||||
selectedTime: 'custom',
|
||||
minTime: (PAST_END_MS - 40 * DAY_MS) * NS_PER_MS,
|
||||
maxTime: PAST_END_MS * NS_PER_MS,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.canExtend).toBe(false);
|
||||
expect(result.current.actionLabel).toBeNull();
|
||||
|
||||
act(() => result.current.extend());
|
||||
expect(store.getActions()).toHaveLength(0);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { ZoomOutResult } from 'lib/zoomOutUtils';
|
||||
|
||||
export interface ExtendTimeWindow {
|
||||
canExtend: boolean;
|
||||
/** Button copy; null when the window can't be widened further. */
|
||||
actionLabel: string | null;
|
||||
extend: () => void;
|
||||
}
|
||||
|
||||
const EXTEND_ACTION_LABEL = 'Extend time range';
|
||||
|
||||
/**
|
||||
* Adapts a zoom-out ladder result (`getNextZoomOutRange`) plus its apply callback
|
||||
* into the panel empty-state extender. A `null` result means the window is already
|
||||
* at the widest ladder step, so there is nothing to extend to.
|
||||
*/
|
||||
export function buildExtendWindow(
|
||||
result: ZoomOutResult | null,
|
||||
extend: () => void,
|
||||
): ExtendTimeWindow {
|
||||
return {
|
||||
canExtend: result !== null,
|
||||
actionLabel: result ? EXTEND_ACTION_LABEL : null,
|
||||
extend,
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports -- global time lives in redux; V2 time dispatch is not yet migrated
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useZoomOut } from 'hooks/useZoomOut';
|
||||
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { buildExtendWindow, ExtendTimeWindow } from './extendWindow';
|
||||
import { NANO_SECOND_MULTIPLIER } from '@/store/globalTime';
|
||||
|
||||
/**
|
||||
* Default empty-state extender: widen the dashboard's global time via the shared
|
||||
* zoom-out ladder. Dispatch + URL sync live in `useZoomOut`; the View modal
|
||||
* overrides this with its own local extender via the store.
|
||||
*/
|
||||
export function useExtendTimeWindow(): ExtendTimeWindow {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const extend = useZoomOut();
|
||||
const result = getNextZoomOutRange(
|
||||
Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
);
|
||||
return buildExtendWindow(result, extend);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Centred full-body spinner (first-fetch + refetch-over-empty).
|
||||
.loader {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Spin } from 'antd';
|
||||
import { Loader } from '@signozhq/icons';
|
||||
|
||||
import styles from './PanelLoader.module.scss';
|
||||
|
||||
interface PanelLoaderProps {
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/** Centred full-panel spinner shown while a query is in flight with nothing to display yet. */
|
||||
function PanelLoader({
|
||||
'data-testid': testId = 'panel-loading',
|
||||
}: PanelLoaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.loader} data-testid={testId}>
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelLoader;
|
||||
@@ -45,10 +45,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
.action {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ interface PanelMessageProps {
|
||||
description?: string;
|
||||
/** Optional call-to-action (e.g. Retry). Omitted → no button. */
|
||||
action?: PanelMessageAction;
|
||||
/** Second call-to-action rendered beside `action` (e.g. Extend + Retry). */
|
||||
secondaryAction?: PanelMessageAction;
|
||||
/** `danger` tints the icon for failure states; `neutral` for empty states. */
|
||||
tone?: 'neutral' | 'danger';
|
||||
'data-testid'?: string;
|
||||
@@ -36,18 +34,9 @@ function PanelMessage({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
secondaryAction,
|
||||
tone = 'neutral',
|
||||
'data-testid': testId,
|
||||
}: PanelMessageProps): JSX.Element {
|
||||
const actions: Array<{ suffix: string; action: PanelMessageAction }> = [];
|
||||
if (action) {
|
||||
actions.push({ suffix: 'action', action });
|
||||
}
|
||||
if (secondaryAction) {
|
||||
actions.push({ suffix: 'secondary-action', action: secondaryAction });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.message} data-testid={testId}>
|
||||
<div className={cx(styles.icon, { [styles.iconDanger]: tone === 'danger' })}>
|
||||
@@ -59,22 +48,18 @@ function PanelMessage({
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{actions.length > 0 && (
|
||||
<div className={styles.actions}>
|
||||
{actions.map(({ suffix, action: item }) => (
|
||||
<Button
|
||||
key={suffix}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={item.icon}
|
||||
onClick={item.onClick}
|
||||
data-testid={testId ? `${testId}-${suffix}` : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{action && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={action.icon}
|
||||
onClick={action.onClick}
|
||||
className={styles.action}
|
||||
data-testid={testId ? `${testId}-action` : undefined}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,28 +43,4 @@ describe('PanelMessage', () => {
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders both actions side by side and wires each onClick', () => {
|
||||
const onPrimary = jest.fn();
|
||||
const onSecondary = jest.fn();
|
||||
render(
|
||||
<PanelMessage
|
||||
icon={null}
|
||||
title="No data in this time range"
|
||||
action={{ label: 'Extend time range', onClick: onPrimary }}
|
||||
secondaryAction={{ label: 'Retry', onClick: onSecondary }}
|
||||
data-testid="panel-no-data"
|
||||
/>,
|
||||
);
|
||||
|
||||
const primary = screen.getByTestId('panel-no-data-action');
|
||||
const secondary = screen.getByTestId('panel-no-data-secondary-action');
|
||||
expect(primary).toHaveTextContent('Extend time range');
|
||||
expect(secondary).toHaveTextContent('Retry');
|
||||
|
||||
fireEvent.click(primary);
|
||||
fireEvent.click(secondary);
|
||||
expect(onPrimary).toHaveBeenCalledTimes(1);
|
||||
expect(onSecondary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,6 @@ function BarPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -187,9 +186,7 @@ function BarPanelRenderer({
|
||||
data-testid="bar-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
)}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -25,7 +25,6 @@ function HistogramPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
panelMode,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
@@ -105,9 +104,7 @@ function HistogramPanelRenderer({
|
||||
data-testid="histogram-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
)}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -33,7 +33,6 @@ function ListPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
pagination,
|
||||
@@ -143,7 +142,7 @@ function ListPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -24,7 +24,6 @@ import ValueDisplay from './components/ValueDisplay/ValueDisplay';
|
||||
function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
@@ -126,11 +125,7 @@ function NumberPanelRenderer({
|
||||
: {})}
|
||||
>
|
||||
{value === null ? (
|
||||
<NoData
|
||||
data-testid="number-panel-no-data"
|
||||
isFetching={isFetching}
|
||||
onRetry={refetch}
|
||||
/>
|
||||
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
|
||||
) : (
|
||||
<ValueDisplay
|
||||
value={formattedValue}
|
||||
|
||||
@@ -27,7 +27,6 @@ function PiePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
@@ -95,7 +94,7 @@ function PiePanelRenderer({
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
|
||||
@@ -35,7 +35,6 @@ function TablePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
onClick,
|
||||
@@ -160,7 +159,7 @@ function TablePanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
|
||||
@@ -35,7 +35,6 @@ function TimeSeriesPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -188,9 +187,7 @@ function TimeSeriesPanelRenderer({
|
||||
data-testid="time-series-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
)}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -4,9 +4,19 @@ import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/qu
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../../../store/slices/editContextSlice';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelActionItems } from '../usePanelActionItems';
|
||||
|
||||
/** Keys of the disabled items, in order. */
|
||||
function disabledKeys(
|
||||
result: ReturnType<typeof usePanelActionItems>,
|
||||
): unknown[] {
|
||||
return result.items
|
||||
.filter((item) => 'disabled' in item && item.disabled)
|
||||
.map((item) => ('key' in item ? item.key : undefined));
|
||||
}
|
||||
|
||||
const mockOpenEditor = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
|
||||
@@ -115,7 +125,7 @@ describe('usePanelActionItems', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRole = 'ADMIN';
|
||||
useDashboardStore.setState({ isEditable: true });
|
||||
useDashboardStore.setState({ isEditable: true, editDisabledReason: '' });
|
||||
});
|
||||
|
||||
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
|
||||
@@ -136,11 +146,14 @@ describe('usePanelActionItems', () => {
|
||||
// it's present for every renderable kind.
|
||||
});
|
||||
|
||||
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
|
||||
it('AUTHOR sees edit and clone disabled (edit_widget excludes AUTHOR) but can move and delete', () => {
|
||||
mockRole = 'AUTHOR';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
// The full set is now always present — the role gate disables rather than hides.
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
@@ -149,31 +162,49 @@ describe('usePanelActionItems', () => {
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('VIEWER keeps only the role-ungated actions (view, download, create-alert)', () => {
|
||||
mockRole = 'VIEWER';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
expect(disabledKeys(result.current)).toStrictEqual([
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('read-only dashboard keeps the non-mutating actions (View, Download, Create Alerts)', () => {
|
||||
useDashboardStore.setState({ isEditable: false });
|
||||
it('VIEWER sees every edit action disabled (no edit permissions)', () => {
|
||||
mockRole = 'VIEWER';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(disabledKeys(result.current)).toStrictEqual([
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'move',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a non-editable (locked / no-permission) dashboard disables every edit action', () => {
|
||||
useDashboardStore.setState({
|
||||
isEditable: false,
|
||||
editDisabledReason: DASHBOARD_LOCKED_REASON,
|
||||
});
|
||||
// A read-only dashboard mounts panels without layout context (no panelActions).
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
);
|
||||
// View, the Download submenu (PNG/SVG) and Create Alerts are all
|
||||
// non-mutating, so they survive on a read-only dashboard (V1 parity).
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
expect(disabledKeys(result.current)).toStrictEqual([
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'move',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Bell, Copy, Fullscreen, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { type ReactNode, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
Copy,
|
||||
FolderInput,
|
||||
Fullscreen,
|
||||
PenLine,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
@@ -23,6 +30,8 @@ import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
import { buildMoveItems } from '../utils/buildMoveItems';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import { DASHBOARD_NO_EDIT_PERMISSION_REASON } from '../../../hooks/useDashboardEditGuard';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
@@ -68,6 +77,7 @@ export function usePanelActionItems({
|
||||
user.role,
|
||||
);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
@@ -102,6 +112,23 @@ export function usePanelActionItems({
|
||||
const { request: requestDelete } = deleteConfirm;
|
||||
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
// The reason an edit action is unavailable: dashboard not editable (locked /
|
||||
// no permission) takes precedence, else the missing widget-level role
|
||||
// permission. Empty string ⇒ the action is enabled.
|
||||
const reasonFor = (hasRolePerm: boolean): string => {
|
||||
if (!isEditable) {
|
||||
return editDisabledReason;
|
||||
}
|
||||
return hasRolePerm ? '' : DASHBOARD_NO_EDIT_PERMISSION_REASON;
|
||||
};
|
||||
// Disabled rows keep a hover tooltip via DisabledMenuItemLabel (see component).
|
||||
const label = (reason: string, text: string): ReactNode =>
|
||||
reason ? (
|
||||
<DisabledMenuItemLabel reason={reason}>{text}</DisabledMenuItemLabel>
|
||||
) : (
|
||||
text
|
||||
);
|
||||
|
||||
const panelGroup: MenuItem[] = [];
|
||||
if (panelCapabilities.view) {
|
||||
panelGroup.push({
|
||||
@@ -111,26 +138,33 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openView(panelId),
|
||||
});
|
||||
}
|
||||
if (isEditable && canEditWidget && panelCapabilities.edit) {
|
||||
if (panelCapabilities.edit) {
|
||||
const reason = reasonFor(canEditWidget);
|
||||
panelGroup.push({
|
||||
key: 'edit-panel',
|
||||
label: 'Edit panel',
|
||||
label: label(reason, 'Edit panel'),
|
||||
icon: <PenLine size={14} />,
|
||||
disabled: !!reason,
|
||||
onClick: (): void => openPanelEditor(panelId),
|
||||
});
|
||||
}
|
||||
// Clone needs the section context to place the copy, so — unlike Edit —
|
||||
// it requires panelActions.
|
||||
if (isEditable && canEditWidget && panelActions && panelCapabilities.clone) {
|
||||
if (panelCapabilities.clone) {
|
||||
// Clone needs the section context to place the copy; without it (read-only
|
||||
// mount) it stays disabled.
|
||||
const reason = reasonFor(canEditWidget);
|
||||
panelGroup.push({
|
||||
key: 'clone-panel',
|
||||
label: 'Clone',
|
||||
label: label(reason, 'Clone'),
|
||||
icon: <Copy size={14} />,
|
||||
onClick: (): void =>
|
||||
void clonePanel({
|
||||
panelId,
|
||||
layoutIndex: panelActions.currentLayoutIndex,
|
||||
}),
|
||||
disabled: !!reason || !panelActions,
|
||||
onClick: (): void => {
|
||||
if (panelActions) {
|
||||
void clonePanel({
|
||||
panelId,
|
||||
layoutIndex: panelActions.currentLayoutIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,7 +174,7 @@ export function usePanelActionItems({
|
||||
}
|
||||
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity).
|
||||
// unlike edit/clone — it isn't gated on editability (V1 parity).
|
||||
if (panelCapabilities.createAlert) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
@@ -150,28 +184,35 @@ export function usePanelActionItems({
|
||||
});
|
||||
}
|
||||
|
||||
const moveReason = reasonFor(canMove);
|
||||
const moveGroup: MenuItem[] =
|
||||
canMove && panelActions
|
||||
!moveReason && panelActions
|
||||
? buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex: panelActions.currentLayoutIndex,
|
||||
panelId,
|
||||
movePanel,
|
||||
})
|
||||
: [];
|
||||
|
||||
const deleteGroup: MenuItem[] =
|
||||
canDelete && panelActions
|
||||
? [
|
||||
: [
|
||||
{
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete panel',
|
||||
onClick: (): void => requestDelete(),
|
||||
key: 'move',
|
||||
label: label(moveReason, 'Move to section'),
|
||||
icon: <FolderInput size={14} />,
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
];
|
||||
|
||||
const deleteReason = reasonFor(canDelete);
|
||||
const deleteGroup: MenuItem[] = [
|
||||
{
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: label(deleteReason, 'Delete panel'),
|
||||
disabled: !!deleteReason || !panelActions,
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
];
|
||||
|
||||
return [panelGroup, dataGroup, moveGroup, deleteGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
@@ -180,6 +221,7 @@ export function usePanelActionItems({
|
||||
);
|
||||
}, [
|
||||
isEditable,
|
||||
editDisabledReason,
|
||||
canEditWidget,
|
||||
canMove,
|
||||
canDelete,
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
.panelContainer {
|
||||
// Generic centred body — used by the loading indicator.
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Container for the rendered chart — fills the panel below the header and lets
|
||||
// the chart shrink (min-* 0) so it resizes with the grid cell.
|
||||
.chartContainer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
|
||||
import { Spin } from 'antd';
|
||||
import { Loader, RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import PanelLoader from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelLoader/PanelLoader';
|
||||
import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
@@ -106,13 +106,17 @@ function PanelBody({
|
||||
}
|
||||
|
||||
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
|
||||
// mounted (e.g. list page change). A refetch over empty data loads via NoData.
|
||||
// mounted (e.g. list page change) with the header carrying the in-flight indicator.
|
||||
if (isFetching && !hasData) {
|
||||
return <PanelLoader />;
|
||||
return (
|
||||
<div className={styles.body} data-testid="panel-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panelContainer}>
|
||||
<div className={styles.chartContainer}>
|
||||
<panelDefinition.Renderer
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
@@ -6,7 +6,6 @@ import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { useViewPanelStore } from 'pages/DashboardPageV2/DashboardContainer/store/useViewPanelStore';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
|
||||
import { useDrilldown } from '../hooks/useDrilldown';
|
||||
@@ -39,7 +38,6 @@ function ViewPanelModalContent({
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
extendWindow,
|
||||
} = useViewPanelTimeWindow();
|
||||
|
||||
const {
|
||||
@@ -71,15 +69,6 @@ function ViewPanelModalContent({
|
||||
);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// Publish the modal's local extender for the nested no-data state; cleared on close.
|
||||
const setViewPanelExtendWindow = useViewPanelStore(
|
||||
(s) => s.setViewPanelExtendWindow,
|
||||
);
|
||||
useEffect(() => {
|
||||
setViewPanelExtendWindow(extendWindow);
|
||||
return (): void => setViewPanelExtendWindow(null);
|
||||
}, [extendWindow, setViewPanelExtendWindow]);
|
||||
|
||||
// The View action only appears for registered kinds, so this is defensive.
|
||||
if (!panelDefinition) {
|
||||
return null;
|
||||
|
||||
@@ -6,15 +6,11 @@ import type {
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import {
|
||||
buildExtendWindow,
|
||||
ExtendTimeWindow,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/components/NoData/extendWindow';
|
||||
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
|
||||
import type { PanelQueryTimeOverride } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from '@/store/globalTime';
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
|
||||
export interface ViewPanelTimeWindow {
|
||||
/** Absolute window (epoch ms) to pass to usePanelQuery as a time override. */
|
||||
@@ -30,8 +26,6 @@ export interface ViewPanelTimeWindow {
|
||||
refreshWindow: () => void;
|
||||
/** Drag-to-zoom on a time chart → set a custom window locally (not the dashboard's). */
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/** Widens this local window in place — powers the empty-state "extend" action. */
|
||||
extendWindow: ExtendTimeWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,8 +45,8 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
>(selectedTime as Time);
|
||||
const [timeOverride, setTimeOverride] = useState<PanelQueryTimeOverride>(
|
||||
() => ({
|
||||
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
startMs: Math.floor(minTime / NS_PER_MS),
|
||||
endMs: Math.floor(maxTime / NS_PER_MS),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -70,8 +64,8 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
// GetMinMax returns nanoseconds — convert to the ms window we work in.
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(interval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -84,8 +78,8 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
}
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(selectedInterval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
});
|
||||
}, [selectedInterval]);
|
||||
|
||||
@@ -101,22 +95,6 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
setTimeOverride({ startMs, endMs });
|
||||
}, []);
|
||||
|
||||
// Empty-state extender that widens this local window via onTimeChange (not global time).
|
||||
const extendWindow = useMemo<ExtendTimeWindow>(() => {
|
||||
const result = getNextZoomOutRange(timeOverride.startMs, timeOverride.endMs);
|
||||
return buildExtendWindow(result, (): void => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
const [startMs, endMs] = result.range;
|
||||
if (result.preset) {
|
||||
onTimeChange(result.preset);
|
||||
} else {
|
||||
onTimeChange('custom', [startMs, endMs]);
|
||||
}
|
||||
});
|
||||
}, [timeOverride, onTimeChange]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
timeOverride,
|
||||
@@ -124,15 +102,7 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
extendWindow,
|
||||
}),
|
||||
[
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
extendWindow,
|
||||
],
|
||||
[timeOverride, selectedInterval, onTimeChange, refreshWindow, onDragSelect],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ jest.mock('../ViewPanelModal/useViewPanelTimeWindow', () => ({
|
||||
onTimeChange: jest.fn(),
|
||||
refreshWindow: jest.fn(),
|
||||
onDragSelect: jest.fn(),
|
||||
extendWindow: { canExtend: false, actionLabel: null, extend: jest.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -90,35 +90,4 @@ describe('useViewPanelTimeWindow', () => {
|
||||
|
||||
expect(result.current.timeOverride).toStrictEqual(initial);
|
||||
});
|
||||
|
||||
it('widens the local window in place via the zoom-out ladder', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
act(() => result.current.onDragSelect(1000, 5000));
|
||||
|
||||
expect(result.current.extendWindow.canExtend).toBe(true);
|
||||
expect(result.current.extendWindow.actionLabel).toBe('Extend time range');
|
||||
|
||||
act(() => result.current.extendWindow.extend());
|
||||
|
||||
// A past 4s window (< 15m) zooms out 3× around its centre (3000): [-3000, 9000].
|
||||
expect(result.current.selectedInterval).toBe('custom');
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: -3000,
|
||||
endMs: 9000,
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot extend a window already at the widest ladder step', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
// 40 days > the ladder's 1-month max.
|
||||
act(() => result.current.onDragSelect(0, 40 * 24 * 60 * 60 * 1000));
|
||||
|
||||
expect(result.current.extendWindow.canExtend).toBe(false);
|
||||
expect(result.current.extendWindow.actionLabel).toBeNull();
|
||||
|
||||
// extend() is a no-op — the window is unchanged.
|
||||
const before = result.current.timeOverride;
|
||||
act(() => result.current.extendWindow.extend());
|
||||
expect(result.current.timeOverride).toStrictEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
@@ -29,6 +30,7 @@ interface SectionProps {
|
||||
|
||||
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
@@ -104,22 +106,19 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
onToggle={toggle}
|
||||
repeatVariable={section.repeatVariable}
|
||||
dragHandle={dragHandle}
|
||||
actions={
|
||||
isEditable
|
||||
? {
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabledReason={isEditable ? '' : editDisabledReason}
|
||||
actions={{
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}}
|
||||
/>
|
||||
{open &&
|
||||
(section.items.length > 0 ? (
|
||||
grid
|
||||
) : (
|
||||
<div className={styles.emptySection}>
|
||||
{isEditable && (
|
||||
{isEditable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="dashed"
|
||||
@@ -130,6 +129,19 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
) : (
|
||||
<TooltipSimple title={editDisabledReason} disableHoverableContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
disabled
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useMemo } from 'react';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import styles from './SectionActionsMenu.module.scss';
|
||||
|
||||
interface SectionActionsMenuProps {
|
||||
sectionId: string;
|
||||
/** Non-empty when edits are unavailable — items render disabled with this reason. */
|
||||
disabledReason?: string;
|
||||
onAddPanel?: () => void;
|
||||
onRename?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
@@ -15,17 +18,28 @@ interface SectionActionsMenuProps {
|
||||
|
||||
function SectionActionsMenu({
|
||||
sectionId,
|
||||
disabledReason = '',
|
||||
onAddPanel,
|
||||
onRename,
|
||||
onDeleteSection,
|
||||
}: SectionActionsMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const disabled = !!disabledReason;
|
||||
const label = (text: string): ReactNode =>
|
||||
disabled ? (
|
||||
<DisabledMenuItemLabel reason={disabledReason}>
|
||||
{text}
|
||||
</DisabledMenuItemLabel>
|
||||
) : (
|
||||
text
|
||||
);
|
||||
const result: MenuItem[] = [];
|
||||
if (onAddPanel) {
|
||||
result.push({
|
||||
key: 'add-panel',
|
||||
icon: <Plus size={14} />,
|
||||
label: 'Add panel',
|
||||
label: label('Add panel'),
|
||||
disabled,
|
||||
onClick: onAddPanel,
|
||||
});
|
||||
}
|
||||
@@ -33,7 +47,8 @@ function SectionActionsMenu({
|
||||
result.push({
|
||||
key: 'rename',
|
||||
icon: <PenLine size={14} />,
|
||||
label: 'Rename section',
|
||||
label: label('Rename section'),
|
||||
disabled,
|
||||
onClick: onRename,
|
||||
});
|
||||
}
|
||||
@@ -44,13 +59,14 @@ function SectionActionsMenu({
|
||||
key: 'delete-section',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete section',
|
||||
label: label('Delete section'),
|
||||
disabled,
|
||||
onClick: onDeleteSection,
|
||||
},
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [onAddPanel, onRename, onDeleteSection]);
|
||||
}, [disabledReason, onAddPanel, onRename, onDeleteSection]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
|
||||
@@ -29,8 +29,10 @@ interface SectionHeaderProps {
|
||||
repeatVariable?: string;
|
||||
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
|
||||
dragHandle?: SectionDragHandle;
|
||||
/** Present only in editable mode; absent (read-only) when locked/no-permission. */
|
||||
/** The section action handlers (always provided; disabled state gates them). */
|
||||
actions?: SectionHeaderActions;
|
||||
/** Non-empty when edits are unavailable — actions render disabled with this reason. */
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
@@ -41,6 +43,7 @@ function SectionHeader({
|
||||
repeatVariable,
|
||||
dragHandle,
|
||||
actions,
|
||||
disabledReason = '',
|
||||
}: SectionHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
|
||||
@@ -79,6 +82,7 @@ function SectionHeader({
|
||||
{actions ? (
|
||||
<SectionActionsMenu
|
||||
sectionId={sectionId}
|
||||
disabledReason={disabledReason}
|
||||
onAddPanel={actions.onAddPanel}
|
||||
onRename={actions.onRename}
|
||||
onDeleteSection={actions.onDeleteSection}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useEffect, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
|
||||
interface SectionTitleModalProps {
|
||||
open: boolean;
|
||||
/** Modal heading, e.g. "Rename section" / "New section". */
|
||||
@@ -58,7 +56,7 @@ function SectionTitleModal({
|
||||
testId="section-title-input"
|
||||
autoFocus
|
||||
value={value}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
maxLength={120}
|
||||
placeholder={placeholder}
|
||||
onChange={(e): void => setValue(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
|
||||
@@ -18,22 +18,22 @@ interface VariableSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables (Dynamic uses them to scope options by sibling selections). */
|
||||
variables: VariableFormModel[];
|
||||
/** Names this variable depends on (for Query gating). */
|
||||
parents: string[];
|
||||
/** All current selections (Query passes them as the request payload). */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched fill applied when options resolve (Query/Dynamic auto-selection). */
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/** One labelled variable control; dispatches on the variable type. */
|
||||
function VariableSelector({
|
||||
variable,
|
||||
variables,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: VariableSelectorProps): JSX.Element {
|
||||
const customOptions = useMemo(
|
||||
() =>
|
||||
@@ -61,10 +61,10 @@ function VariableSelector({
|
||||
return (
|
||||
<QuerySelector
|
||||
variable={variable}
|
||||
parents={parents}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
onAutoSelect={onAutoSelect}
|
||||
/>
|
||||
);
|
||||
case 'DYNAMIC':
|
||||
@@ -75,7 +75,6 @@ function VariableSelector({
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
onAutoSelect={onAutoSelect}
|
||||
/>
|
||||
);
|
||||
case 'CUSTOM':
|
||||
|
||||
@@ -112,13 +112,3 @@
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.overflowTooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.overflowName {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
|
||||
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
import { useVariableSelection } from './useVariableSelection';
|
||||
import VariableSelector from './VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
// Short display of a variable's current selection, for the collapsed +N tooltip.
|
||||
function formatSelection(selection: VariableSelection | undefined): string {
|
||||
if (!selection) {
|
||||
return '—';
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return 'ALL';
|
||||
}
|
||||
const { value } = selection;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value.join(', ') : '—';
|
||||
}
|
||||
return value === '' || value === null || value === undefined
|
||||
? '—'
|
||||
: String(value);
|
||||
}
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
@@ -42,7 +23,7 @@ interface VariablesBarProps {
|
||||
* either way so auto-selection and option fetching keep driving the panels.
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const { variables, selection, setSelection, autoSelect } =
|
||||
const { variables, dependencyData, selection, setSelection } =
|
||||
useVariableSelection(dashboard);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
@@ -57,22 +38,6 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
}
|
||||
|
||||
const hasOverflow = overflowCount > 0;
|
||||
const hiddenVariables =
|
||||
!expanded && hasOverflow ? variables.slice(visibleCount) : [];
|
||||
|
||||
const moreButton = (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
|
||||
aria-expanded={expanded}
|
||||
testId="dashboard-variables-more"
|
||||
onClick={(): void => setExpanded((prev) => !prev)}
|
||||
>
|
||||
{expanded ? 'Less' : `+${overflowCount}`}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
@@ -92,6 +57,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
<VariableSelector
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
parents={dependencyData.parentGraph[variable.name] ?? []}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
@@ -100,32 +66,23 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
}
|
||||
}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
onAutoSelect={(next): void => autoSelect(variable.name, next)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasOverflow && (
|
||||
<span className={styles.moreButton}>
|
||||
{expanded ? (
|
||||
moreButton
|
||||
) : (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
title={
|
||||
<div className={styles.overflowTooltip}>
|
||||
{hiddenVariables.map((variable) => (
|
||||
<div key={variable.name}>
|
||||
<span className={styles.overflowName}>{variable.name}</span>:{' '}
|
||||
{formatSelection(selection[variable.name])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{moreButton}
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
|
||||
aria-expanded={expanded}
|
||||
testId="dashboard-variables-more"
|
||||
onClick={(): void => setExpanded((prev) => !prev)}
|
||||
>
|
||||
{expanded ? 'Less' : `+${overflowCount}`}
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
@@ -11,14 +10,12 @@ import {
|
||||
sortValuesByOrder,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import { useVariableFetchState } from '../useVariableFetchState';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface DynamicSelectorProps {
|
||||
@@ -28,16 +25,12 @@ interface DynamicSelectorProps {
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched auto-selection fill applied when options resolve. */
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-variable options sourced from live telemetry field values for the
|
||||
* chosen signal + attribute, scoped by the other dynamic variables' selections
|
||||
* (so e.g. `pod` narrows to the chosen `namespace`). WHEN to fetch is owned by
|
||||
* the runtime fetch engine: dynamics fetch together once the query variables have
|
||||
* values, and refetch (via a `cycleId` bump) whenever any variable value changes.
|
||||
* (so e.g. `pod` narrows to the chosen `namespace`).
|
||||
*/
|
||||
function DynamicSelector({
|
||||
variable,
|
||||
@@ -45,7 +38,6 @@ function DynamicSelector({
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: DynamicSelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
@@ -56,51 +48,14 @@ function DynamicSelector({
|
||||
[variables, selections, variable.name],
|
||||
);
|
||||
|
||||
const {
|
||||
variableFetchCycleId,
|
||||
isVariableFetching,
|
||||
isVariableSettled,
|
||||
isVariableWaiting,
|
||||
hasVariableFetchedOnce,
|
||||
} = useVariableFetchState(variable.name);
|
||||
const onVariableFetchComplete = useDashboardStore(
|
||||
(s) => s.onVariableFetchComplete,
|
||||
);
|
||||
const onVariableFetchFailure = useDashboardStore(
|
||||
(s) => s.onVariableFetchFailure,
|
||||
);
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
[
|
||||
'dashboard-variable-dynamic',
|
||||
variable.name,
|
||||
variable.dynamicSignal,
|
||||
variable.dynamicAttribute,
|
||||
existingQuery,
|
||||
minTime,
|
||||
maxTime,
|
||||
variableFetchCycleId,
|
||||
],
|
||||
() =>
|
||||
getFieldValues(
|
||||
signalForApi(variable.dynamicSignal),
|
||||
variable.dynamicAttribute,
|
||||
undefined,
|
||||
minTime,
|
||||
maxTime,
|
||||
existingQuery || undefined,
|
||||
),
|
||||
{
|
||||
enabled:
|
||||
!!variable.dynamicAttribute &&
|
||||
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
|
||||
refetchOnWindowFocus: false,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
: onVariableFetchComplete(variable.name),
|
||||
},
|
||||
);
|
||||
const { data, isFetching } = useGetFieldValues({
|
||||
signal: signalForApi(variable.dynamicSignal),
|
||||
name: variable.dynamicAttribute,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
existingQuery: existingQuery || undefined,
|
||||
enabled: !!variable.dynamicAttribute,
|
||||
});
|
||||
|
||||
const options = useMemo(() => {
|
||||
const payload = data?.data;
|
||||
@@ -109,14 +64,14 @@ function DynamicSelector({
|
||||
return sortValuesByOrder(values, variable.sort).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching || isVariableWaiting}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
|
||||
@@ -8,82 +8,58 @@ import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { selectionToPayload } from '../selectionUtils';
|
||||
import { isResolved, selectionToPayload } from '../selectionUtils';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import { useVariableFetchState } from '../useVariableFetchState';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface QuerySelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** Names this variable's query references; it waits until they're resolved. */
|
||||
parents: string[];
|
||||
/** All current selections, fed to the query as `{ name: value }`. */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched auto-selection fill applied when options resolve. */
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-driven options. WHEN to fetch is owned by the runtime fetch engine
|
||||
* (`variableFetchSlice`): the query is `enabled` while this variable is fetching
|
||||
* (or settled-after-a-first-fetch, so a cycle bump re-runs it), and the engine's
|
||||
* per-variable `cycleId` keys the request — so a parent's value change refetches
|
||||
* only the dependent variables, in dependency order. The current selections feed
|
||||
* the request payload but are deliberately NOT in the key (V1 parity).
|
||||
* Query-driven options. Dependency orchestration is declarative: the query is
|
||||
* `enabled` only once every parent is resolved, and the parent values are in the
|
||||
* query key — so it refetches automatically when a parent changes (and a cyclic
|
||||
* dependency is simply never enabled).
|
||||
*/
|
||||
function QuerySelector({
|
||||
variable,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: QuerySelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const payload = useMemo(() => selectionToPayload(selections), [selections]);
|
||||
|
||||
const {
|
||||
variableFetchCycleId,
|
||||
isVariableFetching,
|
||||
isVariableSettled,
|
||||
isVariableWaiting,
|
||||
hasVariableFetchedOnce,
|
||||
} = useVariableFetchState(variable.name);
|
||||
const onVariableFetchComplete = useDashboardStore(
|
||||
(s) => s.onVariableFetchComplete,
|
||||
);
|
||||
const onVariableFetchFailure = useDashboardStore(
|
||||
(s) => s.onVariableFetchFailure,
|
||||
);
|
||||
const enabled = parents.every((parent) => isResolved(selections[parent]));
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
[
|
||||
'dashboard-variable',
|
||||
variable.name,
|
||||
variable.queryValue,
|
||||
payload,
|
||||
minTime,
|
||||
maxTime,
|
||||
variableFetchCycleId,
|
||||
],
|
||||
() =>
|
||||
dashboardVariablesQuery({
|
||||
query: variable.queryValue,
|
||||
variables: payload,
|
||||
}),
|
||||
{
|
||||
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
|
||||
refetchOnWindowFocus: false,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
: onVariableFetchComplete(variable.name),
|
||||
},
|
||||
{ enabled, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
@@ -96,14 +72,14 @@ function QuerySelector({
|
||||
).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching || isVariableWaiting}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useAutoSelect(
|
||||
variable: VariableFormModel,
|
||||
options: string[],
|
||||
selection: VariableSelection,
|
||||
onAutoSelect: (selection: VariableSelection) => void,
|
||||
onChange: (selection: VariableSelection) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (options.length === 0 || selection.allSelected) {
|
||||
@@ -28,11 +28,11 @@ export function useAutoSelect(
|
||||
if (isValid) {
|
||||
return;
|
||||
}
|
||||
const dv = variable.defaultValue;
|
||||
const fallback = Array.isArray(dv) ? dv[0] : dv;
|
||||
const fallback = (variable.defaultValue as { value?: string } | undefined)
|
||||
?.value;
|
||||
const initial =
|
||||
fallback && options.includes(fallback) ? fallback : options[0];
|
||||
onAutoSelect({
|
||||
onChange({
|
||||
value: variable.multiSelect ? [initial] : initial,
|
||||
allSelected: false,
|
||||
});
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import {
|
||||
selectVariableCycleId,
|
||||
selectVariableFetchedOnce,
|
||||
selectVariableFetchState,
|
||||
VariableFetchState,
|
||||
} from '../store/slices/variableFetchSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
export interface VariableFetchStateResult {
|
||||
variableFetchState: VariableFetchState;
|
||||
/** Include in the selector's react-query key to auto-cancel stale requests. */
|
||||
variableFetchCycleId: number;
|
||||
/** Actively fetching (first load or revalidating). */
|
||||
isVariableFetching: boolean;
|
||||
/** Stable — the fetch completed (or errored). */
|
||||
isVariableSettled: boolean;
|
||||
/** Blocked on parent dependencies (query order) or query variables (dynamics). */
|
||||
isVariableWaiting: boolean;
|
||||
/** Completed at least one fetch — keeps the query subscribed so a cycle bump refetches. */
|
||||
hasVariableFetchedOnce: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-variable view of the runtime fetch engine (`variableFetchSlice`), consumed
|
||||
* by the Query/Dynamic selectors to gate their fetch and key it by cycle id.
|
||||
* V2-native equivalent of V1's `useVariableFetchState`.
|
||||
*/
|
||||
export function useVariableFetchState(name: string): VariableFetchStateResult {
|
||||
const variableFetchState = useDashboardStore(selectVariableFetchState(name));
|
||||
const variableFetchCycleId = useDashboardStore(selectVariableCycleId(name));
|
||||
const hasVariableFetchedOnce = useDashboardStore(
|
||||
selectVariableFetchedOnce(name),
|
||||
);
|
||||
|
||||
return {
|
||||
variableFetchState,
|
||||
variableFetchCycleId,
|
||||
isVariableFetching:
|
||||
variableFetchState === VariableFetchState.Loading ||
|
||||
variableFetchState === VariableFetchState.Revalidating,
|
||||
isVariableSettled: variableFetchState === VariableFetchState.Idle,
|
||||
isVariableWaiting: variableFetchState === VariableFetchState.Waiting,
|
||||
hasVariableFetchedOnce,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { parseAsJson, useQueryState } from 'nuqs';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time selector still on redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
@@ -16,8 +12,8 @@ import type {
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
import {
|
||||
deriveFetchContext,
|
||||
doAllQueryVariablesHaveValues,
|
||||
computeVariableDependencies,
|
||||
type VariableDependencyData,
|
||||
} from './variableDependencies';
|
||||
|
||||
/** URL sentinel for an "ALL values selected" state (matches V1). */
|
||||
@@ -33,14 +29,12 @@ export const variablesUrlParser = parseAsJson<
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
// `defaultValue` is a string | string[] on the wire.
|
||||
const def = model.defaultValue;
|
||||
if (Array.isArray(def) && def.length > 0) {
|
||||
const def = (
|
||||
model.defaultValue as { value?: SelectedVariableValue } | undefined
|
||||
)?.value;
|
||||
if (def !== undefined && def !== null && def !== '') {
|
||||
return { value: def, allSelected: false };
|
||||
}
|
||||
if (typeof def === 'string' && def !== '') {
|
||||
return { value: model.multiSelect ? [def] : def, allSelected: false };
|
||||
}
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
@@ -52,14 +46,9 @@ function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
|
||||
|
||||
interface UseVariableSelection {
|
||||
variables: VariableFormModel[];
|
||||
dependencyData: VariableDependencyData;
|
||||
selection: VariableSelectionMap;
|
||||
setSelection: (name: string, selection: VariableSelection) => void;
|
||||
/**
|
||||
* Auto-selection fill (default/first-option) applied when options arrive. Unlike
|
||||
* {@link UseVariableSelection.setSelection}, fills from the initial load burst are
|
||||
* coalesced into one store write + one downstream refresh.
|
||||
*/
|
||||
autoSelect: (name: string, selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,37 +65,27 @@ export function useVariableSelection(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
|
||||
const dependencyData = useMemo(
|
||||
() => computeVariableDependencies(variables),
|
||||
[variables],
|
||||
);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
|
||||
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
|
||||
const initVariableFetch = useDashboardStore((s) => s.initVariableFetch);
|
||||
const enqueueFetchAll = useDashboardStore((s) => s.enqueueFetchAll);
|
||||
const enqueueDescendants = useDashboardStore((s) => s.enqueueDescendants);
|
||||
const enqueueDescendantsBatch = useDashboardStore(
|
||||
(s) => s.enqueueDescendantsBatch,
|
||||
);
|
||||
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
// Latest selection, read by the fetch-cycle effect without subscribing to it
|
||||
// (so a value change doesn't re-trigger a full fetch cycle).
|
||||
const selectionRef = useRef(selection);
|
||||
selectionRef.current = selection;
|
||||
|
||||
const [urlValues, setUrlValues] = useQueryState(
|
||||
'variables',
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
|
||||
// Seed selections: URL wins, then persisted store, then default.
|
||||
// Seed selections for this dashboard: URL wins, then persisted store, then default.
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
// `selection` here is the persisted (localStorage) map on mount — the
|
||||
// effect deliberately doesn't depend on it, so seeding runs once per set.
|
||||
const stored = selection;
|
||||
const seeded: VariableSelectionMap = {};
|
||||
variables.forEach((variable) => {
|
||||
@@ -123,83 +102,16 @@ export function useVariableSelection(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, variables]);
|
||||
|
||||
// Start a full fetch cycle on load / dependency-order / time change. Runs after
|
||||
// the seeding effect above, so it reads the seeded selection from the store; a
|
||||
// value change instead goes through `enqueueDescendants`, not this effect.
|
||||
const orderKey = `${fetchContext.queryVariableOrder.join(
|
||||
',',
|
||||
)}|${fetchContext.dynamicVariableOrder.join(',')}`;
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
const names = variables
|
||||
.map((v) => v.name)
|
||||
.filter((name): name is string => !!name);
|
||||
initVariableFetch(names, fetchContext);
|
||||
enqueueFetchAll(
|
||||
doAllQueryVariablesHaveValues(variables, selectionRef.current),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, orderKey, minTime, maxTime]);
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
setVariableValue(dashboardId, name, next);
|
||||
enqueueDescendants(name);
|
||||
void setUrlValues((prev) => ({
|
||||
...(prev ?? {}),
|
||||
[name]: next.allSelected ? ALL_SELECTED : next.value,
|
||||
}));
|
||||
},
|
||||
[dashboardId, setVariableValue, enqueueDescendants, setUrlValues],
|
||||
[dashboardId, setVariableValue, setUrlValues],
|
||||
);
|
||||
|
||||
// Coalesce the initial load burst of auto-selections: each selector fills its
|
||||
// value as its options resolve (at different times). Collecting them into one
|
||||
// store write + one `enqueueDescendantsBatch` means dependents re-fetch once
|
||||
// with the settled parent values, instead of once per fill.
|
||||
const pendingAutoFillRef = useRef<VariableSelectionMap>({});
|
||||
const autoFillFrameRef = useRef<number | null>(null);
|
||||
|
||||
const flushAutoFills = useCallback((): void => {
|
||||
autoFillFrameRef.current = null;
|
||||
const fills = pendingAutoFillRef.current;
|
||||
pendingAutoFillRef.current = {};
|
||||
const names = Object.keys(fills);
|
||||
if (names.length === 0 || !dashboardId) {
|
||||
return;
|
||||
}
|
||||
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
|
||||
void setUrlValues((prev) => {
|
||||
const next = { ...(prev ?? {}) };
|
||||
names.forEach((name) => {
|
||||
const sel = fills[name];
|
||||
next[name] = sel.allSelected ? ALL_SELECTED : sel.value;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
enqueueDescendantsBatch(names);
|
||||
}, [dashboardId, setVariableValues, setUrlValues, enqueueDescendantsBatch]);
|
||||
|
||||
const autoSelect = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
pendingAutoFillRef.current[name] = next;
|
||||
if (autoFillFrameRef.current == null) {
|
||||
autoFillFrameRef.current = requestAnimationFrame(flushAutoFills);
|
||||
}
|
||||
},
|
||||
[flushAutoFills],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (autoFillFrameRef.current != null) {
|
||||
cancelAnimationFrame(autoFillFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { variables, selection, setSelection, autoSelect };
|
||||
return { variables, dependencyData, selection, setSelection };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import type {
|
||||
VariableFormModel,
|
||||
VariableType,
|
||||
} from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from './selectionTypes';
|
||||
import { isResolved } from './selectionUtils';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
|
||||
/**
|
||||
* Inter-variable dependency graph for runtime selection. A QUERY variable
|
||||
@@ -202,57 +197,3 @@ export function computeVariableDependencies(
|
||||
): VariableDependencyData {
|
||||
return buildDependencyData(buildDependencies(variables));
|
||||
}
|
||||
|
||||
/**
|
||||
* Static context the runtime fetch engine (`variableFetchSlice`) needs to order
|
||||
* fetches: the dependency graph plus the per-name type index and the QUERY /
|
||||
* DYNAMIC fetch orders. Derived from the variable definitions; stable until the
|
||||
* spec's variables change. Mirrors V1's `getVariableDependencyContext`.
|
||||
*/
|
||||
export interface VariableFetchContext {
|
||||
dependencyData: VariableDependencyData;
|
||||
/** variable name → its type. */
|
||||
variableTypes: Record<string, VariableType>;
|
||||
/** QUERY variables in topological (parent-before-child) order. */
|
||||
queryVariableOrder: string[];
|
||||
/** DYNAMIC variable names (they implicitly depend on all QUERY values). */
|
||||
dynamicVariableOrder: string[];
|
||||
}
|
||||
|
||||
export function deriveFetchContext(
|
||||
variables: VariableFormModel[],
|
||||
): VariableFetchContext {
|
||||
const dependencyData = computeVariableDependencies(variables);
|
||||
const variableTypes: Record<string, VariableType> = {};
|
||||
variables.forEach((v) => {
|
||||
if (v.name) {
|
||||
variableTypes[v.name] = v.type;
|
||||
}
|
||||
});
|
||||
const queryVariableOrder = dependencyData.order.filter(
|
||||
(name) => variableTypes[name] === 'QUERY',
|
||||
);
|
||||
const dynamicVariableOrder = variables
|
||||
.filter((v) => v.type === 'DYNAMIC' && !!v.name)
|
||||
.map((v) => v.name);
|
||||
return {
|
||||
dependencyData,
|
||||
variableTypes,
|
||||
queryVariableOrder,
|
||||
dynamicVariableOrder,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every QUERY variable already has a usable selection — decides at load
|
||||
* time whether dynamic variables may fetch immediately or must wait for the
|
||||
* query variables to settle first (V1 parity).
|
||||
*/
|
||||
export function doAllQueryVariablesHaveValues(
|
||||
variables: VariableFormModel[],
|
||||
selection: VariableSelectionMap,
|
||||
): boolean {
|
||||
return variables
|
||||
.filter((v) => v.type === 'QUERY')
|
||||
.every((v) => isResolved(selection[v.name]));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.label {
|
||||
// Re-enable pointer events so the tooltip fires while the disabled row
|
||||
// (pointer-events: none) suppresses selection.
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './DisabledMenuItemLabel.module.scss';
|
||||
|
||||
interface DisabledMenuItemLabelProps {
|
||||
reason: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu-item label that shows a hover tooltip even though the row is disabled.
|
||||
* A disabled dropdown item has `pointer-events: none`, so the label re-enables
|
||||
* them (`styles.label`) to become a valid hover target for the tooltip.
|
||||
*/
|
||||
function DisabledMenuItemLabel({
|
||||
reason,
|
||||
children,
|
||||
}: DisabledMenuItemLabelProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple title={reason} disableHoverableContent>
|
||||
<span className={styles.label}>{children}</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisabledMenuItemLabel;
|
||||
@@ -1,2 +0,0 @@
|
||||
/** Max length for dashboard / section / panel / variable name inputs. */
|
||||
export const DASHBOARD_NAME_MAX_LENGTH = 128;
|
||||
@@ -21,8 +21,8 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: jest.fn(
|
||||
(selector: (s: { dashboardId: string }) => unknown) =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
(selector: (s: { dashboardId: string; isEditable: boolean }) => unknown) =>
|
||||
selector({ dashboardId: 'dash-1', isEditable: true }),
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import {
|
||||
DASHBOARD_LOCKED_REASON,
|
||||
DASHBOARD_NO_EDIT_PERMISSION_REASON,
|
||||
} from '../store/slices/editContextSlice';
|
||||
|
||||
// Re-exported from the (dependency-light) store slice so importing just the reason
|
||||
// strings doesn't pull this hook's provider chain into leaf modules / unit tests.
|
||||
export {
|
||||
DASHBOARD_LOCKED_REASON,
|
||||
DASHBOARD_NO_EDIT_PERMISSION_REASON,
|
||||
} from '../store/slices/editContextSlice';
|
||||
|
||||
export interface DashboardEditGuard {
|
||||
/** `canEditDashboard && !isLocked` — the effective gate for performing edits. */
|
||||
isEditable: boolean;
|
||||
isLocked: boolean;
|
||||
/** The user's role grants edit permission, regardless of the lock. */
|
||||
canEditDashboard: boolean;
|
||||
/** Why edits are disabled (locked vs no-permission), for tooltips; '' when editable. */
|
||||
editDisabledReason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for whether a V2 dashboard can be edited, plus the reason
|
||||
* it can't (locked vs no permission) so controls can render disabled with a tooltip.
|
||||
* Used where the store isn't seeded (the panel-editor route reached by direct URL).
|
||||
*/
|
||||
export function useDashboardEditGuard(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
|
||||
): DashboardEditGuard {
|
||||
const { user } = useAppContext();
|
||||
const [editDashboardPermission] = useComponentPermission(
|
||||
['edit_dashboard'],
|
||||
user.role,
|
||||
);
|
||||
const canEditDashboard = !!editDashboardPermission;
|
||||
const isLocked = !!dashboard?.locked;
|
||||
let editDisabledReason = '';
|
||||
if (isLocked) {
|
||||
editDisabledReason = DASHBOARD_LOCKED_REASON;
|
||||
} else if (!canEditDashboard) {
|
||||
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
|
||||
}
|
||||
return {
|
||||
isEditable: canEditDashboard && !isLocked,
|
||||
isLocked,
|
||||
canEditDashboard,
|
||||
editDisabledReason,
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { isResolved } from '../VariablesBar/selectionUtils';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* True while a panel should stay in its loading state because a variable it
|
||||
* references is still loading/waiting and has no usable value yet — i.e. the
|
||||
* first load. Once the variable has a value, a later change no longer blocks the
|
||||
* panel (it refetches over stale data instead). V1 parity with
|
||||
* `useIsPanelWaitingOnVariable`.
|
||||
*/
|
||||
export function useIsPanelWaitingOnVariable(names: string[]): boolean {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const states = useDashboardStore((s) => s.variableFetchStates);
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
|
||||
return names.some((name) => {
|
||||
const state = states[name];
|
||||
const inFlight =
|
||||
state === 'loading' || state === 'revalidating' || state === 'waiting';
|
||||
return isResolved(selection[name]) ? false : inFlight;
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
@@ -11,6 +12,7 @@ import type {
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { applyJsonPatch } from '../optimistic/applyJsonPatch';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../store/slices/editContextSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/** Cached dashboard snapshot, kept for rollback on error. */
|
||||
@@ -35,6 +37,7 @@ export function useOptimisticPatch(
|
||||
dashboardIdOverride?: string,
|
||||
): UseOptimisticPatch {
|
||||
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const storeIsEditable = useDashboardStore((s) => s.isEditable);
|
||||
const dashboardId = dashboardIdOverride ?? storeDashboardId;
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
@@ -69,8 +72,24 @@ export function useOptimisticPatch(
|
||||
},
|
||||
});
|
||||
|
||||
// Defense-in-depth: block edits when the store is warm for this dashboard and
|
||||
// it isn't editable (locked/no-permission). Callers already hide/disable their
|
||||
// controls; this guards any un-gated or future caller. When the store isn't
|
||||
// seeded for this id (e.g. the panel editor reached by direct URL), fall through
|
||||
// — that surface derives editability itself and gates its own save.
|
||||
const { mutateAsync } = mutation;
|
||||
const patchAsync = useCallback(
|
||||
(ops: DashboardtypesJSONPatchOperationDTO[]): Promise<unknown> => {
|
||||
if (storeDashboardId === dashboardId && !storeIsEditable) {
|
||||
return Promise.reject(new Error(DASHBOARD_LOCKED_REASON));
|
||||
}
|
||||
return mutateAsync(ops);
|
||||
},
|
||||
[storeDashboardId, dashboardId, storeIsEditable, mutateAsync],
|
||||
);
|
||||
|
||||
return {
|
||||
patchAsync: mutation.mutateAsync,
|
||||
patchAsync,
|
||||
isPatching: mutation.isLoading,
|
||||
error: mutation.error ?? null,
|
||||
};
|
||||
|
||||
@@ -15,14 +15,12 @@ import {
|
||||
} from '../queryV5/buildQueryRangeRequest';
|
||||
import type { PanelPagination, PanelQueryData } from '../queryV5/types';
|
||||
import { getRawResults } from '../queryV5/v5ResponseData';
|
||||
import { getReferencedVariables } from '../queryV5/getReferencedVariables';
|
||||
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
|
||||
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
|
||||
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
|
||||
import { useIsPanelWaitingOnVariable } from './useIsPanelWaitingOnVariable';
|
||||
|
||||
// V1 parity: PER_PAGE_OPTIONS + default page size from V1's list views.
|
||||
const LIST_PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200];
|
||||
@@ -113,34 +111,9 @@ export function usePanelQuery({
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
// Resolved variable values for this dashboard, published by useResolvedVariables.
|
||||
// The full set is substituted into every request, but only the values this panel
|
||||
// *references* key the cache — so a variable change refetches only the panels that
|
||||
// use it (V1 parity). Names come from the fetch context (all variables, even
|
||||
// unresolved ones); null before the variable bar initializes it.
|
||||
// Substituted into the request and keyed into the cache so a selection change refetches.
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
|
||||
const fetchContext = useDashboardStore((s) => s.variableFetchContext);
|
||||
|
||||
const referencedVariableNames = useMemo(() => {
|
||||
const allNames = fetchContext ? Object.keys(fetchContext.variableTypes) : [];
|
||||
return getReferencedVariables(queries, allNames);
|
||||
}, [queries, fetchContext]);
|
||||
|
||||
const scopedVariables = useMemo(() => {
|
||||
const scoped: typeof variables = {};
|
||||
referencedVariableNames.forEach((name) => {
|
||||
if (variables[name] !== undefined) {
|
||||
scoped[name] = variables[name];
|
||||
}
|
||||
});
|
||||
return scoped;
|
||||
}, [variables, referencedVariableNames]);
|
||||
|
||||
// First-load gate: hold the panel in its loading state until every referenced
|
||||
// variable has resolved a value.
|
||||
const isWaitingOnVariable = useIsPanelWaitingOnVariable(
|
||||
referencedVariableNames,
|
||||
);
|
||||
|
||||
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
|
||||
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
|
||||
@@ -215,9 +188,8 @@ export function usePanelQuery({
|
||||
// Each page is its own cache entry (0/default for non-paged kinds).
|
||||
offset,
|
||||
pageSize,
|
||||
// Only the variables this panel references re-key the cache, so an unrelated
|
||||
// variable change doesn't refetch it.
|
||||
scopedVariables,
|
||||
// Variable selection changes the request, so it must re-key the cache (refetch).
|
||||
variables,
|
||||
],
|
||||
[
|
||||
panelId,
|
||||
@@ -233,14 +205,14 @@ export function usePanelQuery({
|
||||
queries,
|
||||
offset,
|
||||
pageSize,
|
||||
scopedVariables,
|
||||
variables,
|
||||
],
|
||||
);
|
||||
|
||||
const response = useGetQueryRangeV5({
|
||||
requestPayload,
|
||||
queryKey,
|
||||
enabled: enabled && runnable && !isWaitingOnVariable,
|
||||
enabled: enabled && runnable,
|
||||
// Hold the current page while the next loads (offset re-keys) so the pager doesn't flash.
|
||||
keepPreviousData: isPaginated,
|
||||
});
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import type {
|
||||
IDashboardVariable,
|
||||
TVariableQueryType,
|
||||
} from 'types/api/dashboard/getAll';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import {
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
type VariableFormModel,
|
||||
type VariableType,
|
||||
} from '../DashboardSettings/Variables/variableFormModel';
|
||||
|
||||
const TYPE_TO_V1: Record<VariableType, TVariableQueryType> = {
|
||||
QUERY: 'QUERY',
|
||||
CUSTOM: 'CUSTOM',
|
||||
TEXT: 'TEXTBOX',
|
||||
DYNAMIC: 'DYNAMIC',
|
||||
};
|
||||
|
||||
/** Minimal V1-shaped variable — only the fields the shared query builder reads. */
|
||||
function toV1Variable(model: VariableFormModel): IDashboardVariable {
|
||||
return {
|
||||
id: model.name,
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
type: TYPE_TO_V1[model.type],
|
||||
queryValue: model.queryValue,
|
||||
customValue: model.customValue,
|
||||
textboxValue: model.textValue,
|
||||
sort: 'DISABLED',
|
||||
multiSelect: model.multiSelect,
|
||||
showALLOption: model.showAllOption,
|
||||
dynamicVariablesAttribute: model.dynamicAttribute,
|
||||
dynamicVariablesSource:
|
||||
model.dynamicSignal === DYNAMIC_SIGNAL_ALL
|
||||
? 'all sources'
|
||||
: model.dynamicSignal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the V2 dashboard's variables into the shared `dashboardVariablesStore`
|
||||
* that the query builder's autocomplete (`QuerySearch`) reads, so `$variable`
|
||||
* suggestions show up in the panel editor and the dashboards-page query builder.
|
||||
* Suggestion-only — the runtime engine lives in the V2 store. Clears on unmount so
|
||||
* the shared store doesn't leak into other pages.
|
||||
*/
|
||||
export function useSyncVariablesForSuggestions(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
|
||||
): void {
|
||||
const dashboardId = dashboard?.id ?? '';
|
||||
const specVariables = dashboard?.spec?.variables;
|
||||
const variables = useMemo(
|
||||
() => (specVariables ?? []).map(dtoToFormModel),
|
||||
[specVariables],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId) {
|
||||
return undefined;
|
||||
}
|
||||
const record: Record<string, IDashboardVariable> = {};
|
||||
variables.forEach((model) => {
|
||||
if (model.name) {
|
||||
record[model.name] = toV1Variable(model);
|
||||
}
|
||||
});
|
||||
setDashboardVariablesStore({ dashboardId, variables: record });
|
||||
return (): void =>
|
||||
setDashboardVariablesStore({ dashboardId: '', variables: {} });
|
||||
}, [dashboardId, variables]);
|
||||
}
|
||||
@@ -2,13 +2,11 @@ import { useEffect } from 'react';
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import DashboardPageToolbar from './DashboardPageToolbar';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useDashboardEditGuard } from './hooks/useDashboardEditGuard';
|
||||
import { useResolvedVariables } from './hooks/useResolvedVariables';
|
||||
import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSuggestions';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
|
||||
@@ -33,18 +31,15 @@ function DashboardContainer({
|
||||
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboardPermission] = useComponentPermission(
|
||||
['edit_dashboard'],
|
||||
user.role,
|
||||
);
|
||||
const { isLocked, canEditDashboard } = useDashboardEditGuard(dashboard);
|
||||
|
||||
// Seed during render (not an effect) so the first Panel render already sees the id —
|
||||
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
|
||||
const setEditContext = useDashboardStore((s) => s.setEditContext);
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isEditable: !dashboard.locked && editDashboardPermission,
|
||||
isLocked,
|
||||
canEditDashboard,
|
||||
refetch,
|
||||
});
|
||||
|
||||
@@ -52,19 +47,11 @@ function DashboardContainer({
|
||||
// the store, so each panel's query substitutes the bar's selected values.
|
||||
useResolvedVariables(dashboard);
|
||||
|
||||
// Publish variables to the shared store so the query builder autocomplete
|
||||
// suggests them ($variable) in the panel editor and dashboards-page builder.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
<DashboardPageToolbar
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />
|
||||
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
|
||||
</div>
|
||||
</FullScreen>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import { toQueryEnvelopes } from './buildQueryRangeRequest';
|
||||
|
||||
// Envelope spec fields that can carry a variable reference: a builder query's
|
||||
// filter expression, or a PromQL/ClickHouse query string.
|
||||
interface ReferenceableSpec {
|
||||
query?: string;
|
||||
filter?: { expression?: string };
|
||||
}
|
||||
|
||||
/** Every text string in a panel's queries that could reference a variable. */
|
||||
function extractQueryTexts(queries: DashboardtypesQueryDTO[]): string[] {
|
||||
const texts: string[] = [];
|
||||
toQueryEnvelopes(queries).forEach((envelope) => {
|
||||
const spec = envelope.spec as ReferenceableSpec | undefined;
|
||||
if (typeof spec?.query === 'string') {
|
||||
texts.push(spec.query);
|
||||
}
|
||||
if (typeof spec?.filter?.expression === 'string') {
|
||||
texts.push(spec.filter.expression);
|
||||
}
|
||||
});
|
||||
return texts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of `variableNames` a panel's queries reference (`$name`, `{{.name}}`,
|
||||
* `[[name]]`), so a variable change only refetches the panels that actually use it.
|
||||
* Reuses the shared text-based reference detector over the panel's filter/query text.
|
||||
*/
|
||||
export function getReferencedVariables(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
variableNames: string[],
|
||||
): string[] {
|
||||
if (queries.length === 0 || variableNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const texts = extractQueryTexts(queries);
|
||||
if (texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return variableNames.filter((name) =>
|
||||
texts.some((text) => textContainsVariableReference(text, name)),
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../../DashboardSettings/Variables/variableFormModel';
|
||||
import { deriveFetchContext } from '../../../VariablesBar/variableDependencies';
|
||||
import { useDashboardStore } from '../../useDashboardStore';
|
||||
import { VariableFetchState } from '../variableFetchSlice.utils';
|
||||
|
||||
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), ...overrides };
|
||||
}
|
||||
|
||||
// q1 (root query) → q2 (query referencing $q1) ; d1 (dynamic).
|
||||
const q1 = model({ name: 'q1', type: 'QUERY', queryValue: 'SELECT 1' });
|
||||
const q2 = model({ name: 'q2', type: 'QUERY', queryValue: 'SELECT $q1' });
|
||||
const d1 = model({ name: 'd1', type: 'DYNAMIC', dynamicAttribute: 'pod' });
|
||||
const context = deriveFetchContext([q1, q2, d1]);
|
||||
|
||||
function store(): ReturnType<typeof useDashboardStore.getState> {
|
||||
return useDashboardStore.getState();
|
||||
}
|
||||
function states(): Record<string, string> {
|
||||
return store().variableFetchStates;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useDashboardStore.setState({
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableFetchContext: null,
|
||||
});
|
||||
store().initVariableFetch(['q1', 'q2', 'd1'], context);
|
||||
});
|
||||
|
||||
describe('variableFetchSlice', () => {
|
||||
it('initializes every variable to idle', () => {
|
||||
expect(states()).toStrictEqual({
|
||||
q1: VariableFetchState.Idle,
|
||||
q2: VariableFetchState.Idle,
|
||||
d1: VariableFetchState.Idle,
|
||||
});
|
||||
});
|
||||
|
||||
it('enqueueFetchAll loads roots, waits dependents and (ungated) dynamics', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
expect(states()).toStrictEqual({
|
||||
q1: VariableFetchState.Loading,
|
||||
q2: VariableFetchState.Waiting,
|
||||
d1: VariableFetchState.Waiting,
|
||||
});
|
||||
expect(store().variableCycleIds).toStrictEqual({ q1: 1, q2: 1, d1: 1 });
|
||||
});
|
||||
|
||||
it('enqueueFetchAll loads dynamics immediately when query values exist', () => {
|
||||
store().enqueueFetchAll(true);
|
||||
expect(states().d1).toBe(VariableFetchState.Loading);
|
||||
});
|
||||
|
||||
it('completing a parent unblocks its query child, then unlocks dynamics', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
store().onVariableFetchComplete('q1');
|
||||
expect(states()).toMatchObject({
|
||||
q1: VariableFetchState.Idle,
|
||||
q2: VariableFetchState.Loading,
|
||||
d1: VariableFetchState.Waiting,
|
||||
});
|
||||
|
||||
store().onVariableFetchComplete('q2');
|
||||
expect(states()).toMatchObject({
|
||||
q1: VariableFetchState.Idle,
|
||||
q2: VariableFetchState.Idle,
|
||||
d1: VariableFetchState.Loading,
|
||||
});
|
||||
});
|
||||
|
||||
it('enqueueDescendants revalidates only descendants + dynamics', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
store().onVariableFetchComplete('q1');
|
||||
store().onVariableFetchComplete('q2');
|
||||
store().onVariableFetchComplete('d1');
|
||||
|
||||
store().enqueueDescendants('q1');
|
||||
// q2 depends on q1 (settled) → revalidates; d1 waits (q2 no longer settled).
|
||||
expect(states().q2).toBe(VariableFetchState.Revalidating);
|
||||
expect(states().d1).toBe(VariableFetchState.Waiting);
|
||||
});
|
||||
|
||||
it('enqueueDescendantsBatch bumps each descendant + dynamic exactly once', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
store().onVariableFetchComplete('q1');
|
||||
store().onVariableFetchComplete('q2');
|
||||
store().onVariableFetchComplete('d1');
|
||||
const before = { ...store().variableCycleIds };
|
||||
|
||||
// q1 and q2 auto-select together: q2 is a descendant of q1 but is also in
|
||||
// the batch — it should still bump only once, as should the dynamic.
|
||||
store().enqueueDescendantsBatch(['q1', 'q2']);
|
||||
const after = store().variableCycleIds;
|
||||
expect(after.q2).toBe(before.q2 + 1);
|
||||
expect(after.d1).toBe(before.d1 + 1);
|
||||
});
|
||||
|
||||
it('a failed parent idles its query descendants', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
store().onVariableFetchFailure('q1');
|
||||
expect(states().q1).toBe(VariableFetchState.Error);
|
||||
expect(states().q2).toBe(VariableFetchState.Idle);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,13 @@ import type { StateCreator } from 'zustand';
|
||||
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
/** Tooltip reason shown on edit controls disabled because the dashboard is locked. */
|
||||
export const DASHBOARD_LOCKED_REASON = 'This dashboard is locked';
|
||||
|
||||
/** Tooltip reason shown on edit controls disabled for want of edit permission. */
|
||||
export const DASHBOARD_NO_EDIT_PERMISSION_REASON =
|
||||
'You don’t have permission to edit this dashboard';
|
||||
|
||||
/**
|
||||
* Edit context shared across the V2 dashboard tree — the dashboard id, whether
|
||||
* the user can edit, and the react-query refetch. Set once by DashboardContainer
|
||||
@@ -10,11 +17,20 @@ import type { DashboardStore } from '../useDashboardStore';
|
||||
*/
|
||||
export interface EditContextSlice {
|
||||
dashboardId: string;
|
||||
/** `canEditDashboard && !isLocked` — the effective edit gate. */
|
||||
isEditable: boolean;
|
||||
/** The dashboard is locked. Distinct from `isEditable`: edit-permitted users
|
||||
* still see a locked dashboard's controls (disabled), viewers never do. */
|
||||
isLocked: boolean;
|
||||
/** The user's role grants edit permission, regardless of the lock state. */
|
||||
canEditDashboard: boolean;
|
||||
/** Why edits are disabled (locked vs no-permission), for control tooltips; '' when editable. */
|
||||
editDisabledReason: string;
|
||||
refetch: () => void;
|
||||
setEditContext: (ctx: {
|
||||
dashboardId: string;
|
||||
isEditable: boolean;
|
||||
isLocked: boolean;
|
||||
canEditDashboard: boolean;
|
||||
refetch: () => void;
|
||||
}) => void;
|
||||
}
|
||||
@@ -27,20 +43,35 @@ export const createEditContextSlice: StateCreator<
|
||||
> = (set, get) => ({
|
||||
dashboardId: '',
|
||||
isEditable: false,
|
||||
isLocked: false,
|
||||
canEditDashboard: false,
|
||||
editDisabledReason: '',
|
||||
refetch: (): void => undefined,
|
||||
// Idempotent (no-op when unchanged) so it's safe to call during render.
|
||||
setEditContext: (ctx): void => {
|
||||
const { dashboardId, isEditable, refetch } = get();
|
||||
const isEditable = ctx.canEditDashboard && !ctx.isLocked;
|
||||
let editDisabledReason = '';
|
||||
if (ctx.isLocked) {
|
||||
editDisabledReason = DASHBOARD_LOCKED_REASON;
|
||||
} else if (!ctx.canEditDashboard) {
|
||||
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
|
||||
}
|
||||
const prev = get();
|
||||
if (
|
||||
dashboardId === ctx.dashboardId &&
|
||||
isEditable === ctx.isEditable &&
|
||||
refetch === ctx.refetch
|
||||
prev.dashboardId === ctx.dashboardId &&
|
||||
prev.isEditable === isEditable &&
|
||||
prev.isLocked === ctx.isLocked &&
|
||||
prev.canEditDashboard === ctx.canEditDashboard &&
|
||||
prev.refetch === ctx.refetch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
set({
|
||||
dashboardId: ctx.dashboardId,
|
||||
isEditable: ctx.isEditable,
|
||||
isEditable,
|
||||
isLocked: ctx.isLocked,
|
||||
canEditDashboard: ctx.canEditDashboard,
|
||||
editDisabledReason,
|
||||
refetch: ctx.refetch,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type { VariableFetchContext } from '../../VariablesBar/variableDependencies';
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
import {
|
||||
areAllQueryVariablesSettled,
|
||||
type FetchMaps,
|
||||
isSettled,
|
||||
resolveFetchState,
|
||||
unlockWaitingDynamicVariables,
|
||||
VariableFetchState,
|
||||
} from './variableFetchSlice.utils';
|
||||
|
||||
export { VariableFetchState } from './variableFetchSlice.utils';
|
||||
|
||||
/**
|
||||
* Runtime fetch orchestration for dashboard variables — native port of V1's
|
||||
* `variableFetchStore`. Decides WHEN each variable's options fetch: query
|
||||
* variables in dependency order, dynamics together once query values exist,
|
||||
* text/custom never. `cycleIds` is a per-variable request nonce keyed into each
|
||||
* selector's react-query key (bump = fresh fetch, auto-cancel stale). Transient.
|
||||
* `enqueueFetchAll` = load/time change; `enqueueDescendants` = one value changed.
|
||||
*/
|
||||
export interface VariableFetchSlice {
|
||||
variableFetchStates: Record<string, VariableFetchState>;
|
||||
variableLastUpdated: Record<string, number>;
|
||||
variableCycleIds: Record<string, number>;
|
||||
/** Static dependency context, set by `initVariableFetch` (null before init). */
|
||||
variableFetchContext: VariableFetchContext | null;
|
||||
|
||||
/** Seed state entries for the current variable set and store the context. */
|
||||
initVariableFetch: (names: string[], context: VariableFetchContext) => void;
|
||||
/** Start a full fetch cycle for every fetchable variable (load / time change). */
|
||||
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected: boolean) => void;
|
||||
/** Mark a variable's fetch as done; unblock its waiting children / dynamics. */
|
||||
onVariableFetchComplete: (name: string) => void;
|
||||
/** Mark a variable's fetch as failed; idle its query descendants. */
|
||||
onVariableFetchFailure: (name: string) => void;
|
||||
/** Cascade a value change to a variable's query descendants + the dynamics. */
|
||||
enqueueDescendants: (name: string) => void;
|
||||
/**
|
||||
* Batched value-change cascade: refresh the union of the given variables'
|
||||
* query descendants plus the dynamics, each exactly once. Used to collapse the
|
||||
* initial burst of auto-selections into a single downstream fetch.
|
||||
*/
|
||||
enqueueDescendantsBatch: (names: string[]) => void;
|
||||
}
|
||||
|
||||
/** Snapshot the three fetch maps into mutable clones for a single action. */
|
||||
function cloneMaps(state: DashboardStore): FetchMaps {
|
||||
return {
|
||||
states: { ...state.variableFetchStates },
|
||||
lastUpdated: { ...state.variableLastUpdated },
|
||||
cycleIds: { ...state.variableCycleIds },
|
||||
};
|
||||
}
|
||||
|
||||
export const createVariableFetchSlice: StateCreator<
|
||||
DashboardStore,
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
VariableFetchSlice
|
||||
> = (set, get) => ({
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableFetchContext: null,
|
||||
|
||||
initVariableFetch: (names, context): void => {
|
||||
const maps = cloneMaps(get());
|
||||
// Initialize new variables to idle, preserving existing states.
|
||||
names.forEach((name) => {
|
||||
if (!maps.states[name]) {
|
||||
maps.states[name] = VariableFetchState.Idle;
|
||||
}
|
||||
});
|
||||
// Drop entries for variables that no longer exist.
|
||||
const nameSet = new Set(names);
|
||||
Object.keys(maps.states).forEach((name) => {
|
||||
if (!nameSet.has(name)) {
|
||||
delete maps.states[name];
|
||||
delete maps.lastUpdated[name];
|
||||
delete maps.cycleIds[name];
|
||||
}
|
||||
});
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
variableFetchContext: context,
|
||||
});
|
||||
},
|
||||
|
||||
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected): void => {
|
||||
const { variableFetchContext } = get();
|
||||
if (!variableFetchContext) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
dependencyData,
|
||||
variableTypes,
|
||||
queryVariableOrder,
|
||||
dynamicVariableOrder,
|
||||
} = variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
|
||||
// Query variables: roots start immediately, dependents wait for parents.
|
||||
queryVariableOrder.forEach((name) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
const parents = dependencyData.parentGraph[name] || [];
|
||||
const hasQueryParents = parents.some((p) => variableTypes[p] === 'QUERY');
|
||||
maps.states[name] = hasQueryParents
|
||||
? VariableFetchState.Waiting
|
||||
: resolveFetchState(maps, name);
|
||||
});
|
||||
|
||||
// Dynamic variables: start now if query variables already have values,
|
||||
// otherwise wait until the query variables settle.
|
||||
dynamicVariableOrder.forEach((name) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
maps.states[name] = doAllQueryVariablesHaveValuesSelected
|
||||
? resolveFetchState(maps, name)
|
||||
: VariableFetchState.Waiting;
|
||||
});
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
|
||||
onVariableFetchComplete: (name): void => {
|
||||
const { variableFetchContext } = get();
|
||||
const maps = cloneMaps(get());
|
||||
maps.states[name] = VariableFetchState.Idle;
|
||||
maps.lastUpdated[name] = Date.now();
|
||||
|
||||
if (variableFetchContext) {
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
// Unblock waiting query-type children.
|
||||
(dependencyData.graph[name] || []).forEach((child) => {
|
||||
if (
|
||||
variableTypes[child] === 'QUERY' &&
|
||||
maps.states[child] === VariableFetchState.Waiting
|
||||
) {
|
||||
maps.states[child] = resolveFetchState(maps, child);
|
||||
}
|
||||
});
|
||||
// Once all query variables settle, unlock any waiting dynamics.
|
||||
if (
|
||||
variableTypes[name] === 'QUERY' &&
|
||||
areAllQueryVariablesSettled(maps.states, variableTypes)
|
||||
) {
|
||||
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
|
||||
onVariableFetchFailure: (name): void => {
|
||||
const { variableFetchContext } = get();
|
||||
const maps = cloneMaps(get());
|
||||
maps.states[name] = VariableFetchState.Error;
|
||||
|
||||
if (variableFetchContext) {
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
// Query descendants can't proceed without this parent — idle them.
|
||||
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
|
||||
if (variableTypes[desc] === 'QUERY') {
|
||||
maps.states[desc] = VariableFetchState.Idle;
|
||||
}
|
||||
});
|
||||
if (
|
||||
variableTypes[name] === 'QUERY' &&
|
||||
areAllQueryVariablesSettled(maps.states, variableTypes)
|
||||
) {
|
||||
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
|
||||
enqueueDescendants: (name): void => {
|
||||
get().enqueueDescendantsBatch([name]);
|
||||
},
|
||||
|
||||
enqueueDescendantsBatch: (names): void => {
|
||||
const { variableFetchContext } = get();
|
||||
if (!variableFetchContext || names.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
|
||||
// Union of the changed variables' query descendants, refreshed once each:
|
||||
// refetch when all their parents are settled, else wait.
|
||||
const queryDescendants = new Set<string>();
|
||||
names.forEach((name) => {
|
||||
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
|
||||
if (variableTypes[desc] === 'QUERY') {
|
||||
queryDescendants.add(desc);
|
||||
}
|
||||
});
|
||||
});
|
||||
queryDescendants.forEach((desc) => {
|
||||
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
|
||||
const parents = dependencyData.parentGraph[desc] || [];
|
||||
const allParentsSettled = parents.every((p) => isSettled(maps.states[p]));
|
||||
maps.states[desc] = allParentsSettled
|
||||
? resolveFetchState(maps, desc)
|
||||
: VariableFetchState.Waiting;
|
||||
});
|
||||
|
||||
// Dynamics implicitly depend on all query values: refetch now if the query
|
||||
// variables are settled, otherwise wait for them.
|
||||
dynamicVariableOrder.forEach((dynName) => {
|
||||
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
|
||||
maps.states[dynName] = areAllQueryVariablesSettled(
|
||||
maps.states,
|
||||
variableTypes,
|
||||
)
|
||||
? resolveFetchState(maps, dynName)
|
||||
: VariableFetchState.Waiting;
|
||||
});
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Selector: the fetch state for a single variable (defaults to idle). */
|
||||
export const selectVariableFetchState =
|
||||
(name: string) =>
|
||||
(state: DashboardStore): VariableFetchState =>
|
||||
state.variableFetchStates[name] ?? VariableFetchState.Idle;
|
||||
|
||||
/** Selector: the current fetch cycle id for a single variable (defaults to 0). */
|
||||
export const selectVariableCycleId =
|
||||
(name: string) =>
|
||||
(state: DashboardStore): number =>
|
||||
state.variableCycleIds[name] ?? 0;
|
||||
|
||||
/** Selector: whether a variable has completed at least one fetch. */
|
||||
export const selectVariableFetchedOnce =
|
||||
(name: string) =>
|
||||
(state: DashboardStore): boolean =>
|
||||
(state.variableLastUpdated[name] ?? 0) > 0;
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { VariableType } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
|
||||
/** Per-variable fetch lifecycle (ported from V1's `variableFetchStore`). */
|
||||
export enum VariableFetchState {
|
||||
Idle = 'idle',
|
||||
Loading = 'loading',
|
||||
Revalidating = 'revalidating',
|
||||
Waiting = 'waiting',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
/** Mutable clones a fetch action works over before committing back in one `set`. */
|
||||
export interface FetchMaps {
|
||||
states: Record<string, VariableFetchState>;
|
||||
lastUpdated: Record<string, number>;
|
||||
cycleIds: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Settled = can make no further progress (idle or error). */
|
||||
export function isSettled(state: VariableFetchState | undefined): boolean {
|
||||
return state === VariableFetchState.Idle || state === VariableFetchState.Error;
|
||||
}
|
||||
|
||||
/** Fetch-start state: `revalidating` if fetched before, else `loading`. */
|
||||
export function resolveFetchState(
|
||||
maps: FetchMaps,
|
||||
name: string,
|
||||
): VariableFetchState {
|
||||
return (maps.lastUpdated[name] || 0) > 0
|
||||
? VariableFetchState.Revalidating
|
||||
: VariableFetchState.Loading;
|
||||
}
|
||||
|
||||
/** True once every QUERY variable is settled. */
|
||||
export function areAllQueryVariablesSettled(
|
||||
states: Record<string, VariableFetchState>,
|
||||
variableTypes: Record<string, VariableType>,
|
||||
): boolean {
|
||||
return Object.entries(variableTypes)
|
||||
.filter(([, type]) => type === 'QUERY')
|
||||
.every(([name]) => isSettled(states[name]));
|
||||
}
|
||||
|
||||
/** Move any `waiting` dynamic variables into loading/revalidating. */
|
||||
export function unlockWaitingDynamicVariables(
|
||||
maps: FetchMaps,
|
||||
dynamicVariableOrder: string[],
|
||||
): void {
|
||||
dynamicVariableOrder.forEach((dynName) => {
|
||||
if (maps.states[dynName] === VariableFetchState.Waiting) {
|
||||
maps.states[dynName] = resolveFetchState(maps, dynName);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -13,15 +13,10 @@ import {
|
||||
createVariableSelectionSlice,
|
||||
type VariableSelectionSlice,
|
||||
} from './slices/variableSelectionSlice';
|
||||
import {
|
||||
createVariableFetchSlice,
|
||||
type VariableFetchSlice,
|
||||
} from './slices/variableFetchSlice';
|
||||
|
||||
export type DashboardStore = EditContextSlice &
|
||||
CollapseSlice &
|
||||
VariableSelectionSlice &
|
||||
VariableFetchSlice;
|
||||
VariableSelectionSlice;
|
||||
|
||||
/**
|
||||
* V2 dashboard session store. Holds cross-cutting client state only — never the
|
||||
@@ -36,7 +31,6 @@ export const useDashboardStore = create<DashboardStore>()(
|
||||
...createEditContextSlice(...a),
|
||||
...createCollapseSlice(...a),
|
||||
...createVariableSelectionSlice(...a),
|
||||
...createVariableFetchSlice(...a),
|
||||
}),
|
||||
{
|
||||
name: '@signoz/dashboard-v2',
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { ExtendTimeWindow } from '../Panels/components/NoData/extendWindow';
|
||||
|
||||
/**
|
||||
* Kept out of `useDashboardStore` (never persisted): the View modal publishes its
|
||||
* local-window extender here so the deeply-nested `NoData` state reads it without
|
||||
* prop threading. Set while the modal is open, cleared on close.
|
||||
*/
|
||||
export interface ViewPanelStore {
|
||||
viewPanelExtendWindow: ExtendTimeWindow | null;
|
||||
setViewPanelExtendWindow: (extendWindow: ExtendTimeWindow | null) => void;
|
||||
}
|
||||
|
||||
export const useViewPanelStore = create<ViewPanelStore>((set) => ({
|
||||
viewPanelExtendWindow: null,
|
||||
setViewPanelExtendWindow: (viewPanelExtendWindow): void => {
|
||||
set({ viewPanelExtendWindow });
|
||||
},
|
||||
}));
|
||||
|
||||
export const selectViewPanelExtendWindow = (
|
||||
state: ViewPanelStore,
|
||||
): ExtendTimeWindow | null => state.viewPanelExtendWindow;
|
||||
@@ -12,6 +12,7 @@ import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
|
||||
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
parseNewPanelKind,
|
||||
parseNewPanelLayoutIndex,
|
||||
} from '../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import styles from './PanelEditorPage.module.scss';
|
||||
|
||||
@@ -43,9 +43,9 @@ function PanelEditorPage(): JSX.Element {
|
||||
|
||||
const { dashboard, isLoading, isError, error } =
|
||||
useDashboardFetch(dashboardId);
|
||||
|
||||
// Feed variables to the query builder autocomplete inside the editor.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
// Derived here (not from the store) because the editor route doesn't mount
|
||||
// DashboardContainer, so the store's edit context may be cold on a direct URL.
|
||||
const { isEditable, editDisabledReason } = useDashboardEditGuard(dashboard);
|
||||
|
||||
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
|
||||
// kind rather than looking one up. Persisted (with a real id) only on save.
|
||||
@@ -114,6 +114,8 @@ function PanelEditorPage(): JSX.Element {
|
||||
panel={panel}
|
||||
isNew={!!newKind}
|
||||
layoutIndex={layoutIndex}
|
||||
isEditable={isEditable}
|
||||
editDisabledReason={editDisabledReason}
|
||||
onClose={backToDashboard}
|
||||
onSaved={backToDashboard}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../DashboardPageV2/DashboardContainer/constants';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
interface Props {
|
||||
@@ -103,7 +102,6 @@ function RenameDashboardModal({
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
placeholder="Dashboard name"
|
||||
testId="rename-dashboard-input"
|
||||
onChange={(e): void => setName(e.target.value)}
|
||||
|
||||
@@ -17,7 +17,6 @@ import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { keyValueStringsToTags } from '../../utils/helpers';
|
||||
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../DashboardPageV2/DashboardContainer/constants';
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
const DEFAULT_NAME = 'Sample Dashboard';
|
||||
@@ -80,7 +79,6 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. Sample Dashboard"
|
||||
testId="create-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
export const VIEW_NAME_MAX_LENGTH = 64;
|
||||
export const VIEW_NAME_MAX_LENGTH = 128;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
|
||||
@@ -68,8 +68,7 @@
|
||||
display: flex;
|
||||
|
||||
.filter {
|
||||
// Width is owned by ResizableBox (inline style).
|
||||
flex-shrink: 0;
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
@@ -77,14 +76,9 @@
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +93,6 @@
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
@@ -40,8 +41,6 @@ import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { TOOLBAR_VIEWS } from 'pages/TracesExplorer/constants';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Warning } from 'types/api';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -63,10 +62,6 @@ import TimeSeriesView from './TimeSeriesView';
|
||||
|
||||
import './TracesExplorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function TracesExplorer(): JSX.Element {
|
||||
const {
|
||||
panelType,
|
||||
@@ -122,16 +117,6 @@ function TracesExplorer(): JSX.Element {
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
const [isOpen, setOpen] = useState<boolean>(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_TRACES,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
@@ -265,29 +250,16 @@ function TracesExplorer(): JSX.Element {
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="trace-explorer-page">
|
||||
{isOpen && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="filter"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.resizable-box {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&--disabled {
|
||||
flex: 1;
|
||||
|
||||
Reference in New Issue
Block a user