mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 15:10:37 +01:00
Compare commits
12 Commits
feat/dashb
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
854d1b83bc | ||
|
|
05ad1b1104 | ||
|
|
458ba01293 | ||
|
|
6eef4dcba0 | ||
|
|
e8a211781e | ||
|
|
7d7a58bcf7 | ||
|
|
3aaaeb4ae2 | ||
|
|
b076e9a502 | ||
|
|
6a7679b415 | ||
|
|
96fda6788d | ||
|
|
909c2666ea | ||
|
|
79b0c0ccea |
@@ -5,3 +5,9 @@
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Actions / Configure / Edit-as-JSON: a flat l3 outline — no depth/shadow. */
|
||||
.toolbarButton {
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import {
|
||||
@@ -59,6 +59,8 @@ function DashboardActions({
|
||||
onOpenRename,
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
@@ -72,6 +74,14 @@ function DashboardActions({
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
|
||||
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
|
||||
|
||||
// Open the settings drawer when something in the tree requests it (e.g. the
|
||||
// variables bar's "Add variable" button).
|
||||
useEffect(() => {
|
||||
if (settingsRequest) {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
}
|
||||
}, [settingsRequest]);
|
||||
|
||||
const { addSection, isSaving: isAddingSection } = useAddSection({
|
||||
layouts: dashboard.spec.layouts,
|
||||
});
|
||||
@@ -201,6 +211,7 @@ function DashboardActions({
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
@@ -212,6 +223,7 @@ function DashboardActions({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
@@ -222,7 +234,11 @@ function DashboardActions({
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
destroyOnClose
|
||||
onClose={(): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
clearSettingsRequest();
|
||||
}}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
@@ -231,6 +247,7 @@ function DashboardActions({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={(): void => setIsJsonEditorOpen(true)}
|
||||
|
||||
@@ -8,6 +8,8 @@ type SettingsDrawerProps = PropsWithChildren<{
|
||||
drawerTitle: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Unmount the content on close so it re-initializes on the next open. */
|
||||
destroyOnClose?: boolean;
|
||||
}>;
|
||||
|
||||
function SettingsDrawer({
|
||||
@@ -15,6 +17,7 @@ function SettingsDrawer({
|
||||
drawerTitle,
|
||||
isOpen,
|
||||
onClose,
|
||||
destroyOnClose = false,
|
||||
}: SettingsDrawerProps): JSX.Element {
|
||||
return (
|
||||
<Drawer
|
||||
@@ -23,6 +26,7 @@ function SettingsDrawer({
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
destroyOnClose={destroyOnClose}
|
||||
rootClassName={styles.settingsContainerRoot}
|
||||
>
|
||||
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
|
||||
|
||||
@@ -40,7 +40,7 @@ function DynamicVariableFields({
|
||||
attributeError,
|
||||
}: DynamicVariableFieldsProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const apiSignal = signalForApi(signal);
|
||||
|
||||
const {
|
||||
@@ -82,38 +82,14 @@ function DynamicVariableFields({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
|
||||
<Typography.Text className={styles.label}>Source</Typography.Text>
|
||||
<TextToolTip
|
||||
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.sortSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={signal}
|
||||
options={DYNAMIC_SIGNALS.map((s) => ({
|
||||
label: DYNAMIC_SIGNAL_LABEL[s],
|
||||
value: s,
|
||||
}))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as DynamicSignalOption })
|
||||
}
|
||||
data-testid="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Attribute</Typography.Text>
|
||||
</div>
|
||||
{/* Combined row retained from V1: the field on the left, `from` + the
|
||||
telemetry source on the right. */}
|
||||
<div className={cx(styles.row, styles.dynamicCombinedRow)}>
|
||||
<CustomSelect
|
||||
className={styles.searchSelect}
|
||||
className={styles.dynamicFieldSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a telemetry field"
|
||||
placeholder="Select a field"
|
||||
loading={isLoading}
|
||||
options={options}
|
||||
onSearch={setSearch}
|
||||
@@ -126,6 +102,25 @@ function DynamicVariableFields({
|
||||
showRetryButton={error ? isRetryableError(error) : true}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
<Typography.Text className={styles.fromText}>from</Typography.Text>
|
||||
<TextToolTip
|
||||
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
<Select
|
||||
className={styles.dynamicSourceSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={signal}
|
||||
options={DYNAMIC_SIGNALS.map((s) => ({
|
||||
label: DYNAMIC_SIGNAL_LABEL[s],
|
||||
value: s,
|
||||
}))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as DynamicSignalOption })
|
||||
}
|
||||
data-testid="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
{attributeError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
padding: 6px 14px;
|
||||
padding: 4px 16px !important;
|
||||
white-space: nowrap;
|
||||
border-radius: 0;
|
||||
color: var(--l2-foreground);
|
||||
@@ -157,6 +157,7 @@
|
||||
|
||||
.betaTag {
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Query */
|
||||
@@ -264,6 +265,7 @@
|
||||
flex-flow: wrap;
|
||||
gap: 8px;
|
||||
padding: 4.5px 11px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -329,15 +331,41 @@
|
||||
/* All variable selects (Source / Attribute / Sort / Default Value) share width
|
||||
and a consistent --l2-border outline. */
|
||||
.sortSelect,
|
||||
.searchSelect {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.searchSelect,
|
||||
.dynamicFieldSelect,
|
||||
.dynamicSourceSelect {
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sortSelect,
|
||||
.searchSelect {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dynamic variable: field | "from" | info | source, retained from V1. */
|
||||
.dynamicCombinedRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 16px 180px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dynamicFieldSelect,
|
||||
.dynamicSourceSelect {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fromText {
|
||||
font-family: 'Space Mono';
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -23,6 +23,11 @@ interface VariablesSettingsProps {
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
// The drawer destroys on close, so reading this once on mount is enough to
|
||||
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
|
||||
const openAddOnMount = useDashboardStore(
|
||||
(s) => s.settingsRequest?.addVariable ?? false,
|
||||
);
|
||||
const { save, isSaving } = useSaveVariables();
|
||||
|
||||
const initialFormModels = useMemo(
|
||||
@@ -38,7 +43,9 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.updatedAt]);
|
||||
|
||||
const [isEditing, setIsEditing] = useState<EditingState>(null);
|
||||
const [isEditing, setIsEditing] = useState<EditingState>(
|
||||
openAddOnMount && isEditable ? { type: 'new' } : null,
|
||||
);
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
|
||||
interface DashboardSettingsProps {
|
||||
@@ -38,6 +39,9 @@ const prefixIcons: Record<TabKeys, JSX.Element> = {
|
||||
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
// Opened once per drawer mount (the drawer destroys on close); a deep-link
|
||||
// request lands us on the right tab.
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
|
||||
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
|
||||
|
||||
@@ -69,7 +73,7 @@ function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
|
||||
<TabsRoot defaultValue={settingsRequest?.tab ?? TabKeys.OVERVIEW}>
|
||||
<TabsList variant="primary">
|
||||
{Object.values(TabKeys).map((key) => (
|
||||
<TabsTrigger value={key} key={key}>
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
|
||||
.welcome {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
@@ -34,52 +33,56 @@
|
||||
|
||||
.welcomeInfo {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.addPanel {
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--l1-border);
|
||||
border: 1px dashed var(--l3-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.addPanelText {
|
||||
.stepText {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
.icon {
|
||||
.stepIcon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.addPanelCopy {
|
||||
.stepCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.addPanelTitle {
|
||||
.stepTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.addPanelInfo {
|
||||
.stepInfo {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Configure, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
@@ -6,6 +6,7 @@ import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
|
||||
import landscapeUrl from '@/assets/Icons/landscape.svg';
|
||||
|
||||
import { useCreatePanel } from '../../hooks/useCreatePanel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import styles from './DashboardEmptyState.module.scss';
|
||||
|
||||
@@ -18,6 +19,8 @@ function DashboardEmptyState({
|
||||
}: DashboardEmptyStateProps): JSX.Element {
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
|
||||
return (
|
||||
<section className={styles.emptyState}>
|
||||
@@ -32,28 +35,55 @@ function DashboardEmptyState({
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.addPanel}>
|
||||
<div className={styles.addPanelText}>
|
||||
<img src={landscapeUrl} alt="" className={styles.icon} />
|
||||
<div className={styles.addPanelCopy}>
|
||||
<Typography.Text className={styles.addPanelTitle}>
|
||||
Add panels
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.addPanelInfo}>
|
||||
Add panels to visualize your data
|
||||
</Typography.Text>
|
||||
<div className={styles.steps}>
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<Configure size={14} className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Configure your new dashboard
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Give it a name, add description, tags and variables
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
onClick={(): void => requestSettings({ tab: 'Overview' })}
|
||||
testId="empty-configure"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Add panels
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Add panels to visualize your data
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{canAddPanel && (
|
||||
<Button
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{canAddPanel && (
|
||||
<Button
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PanelTypeSelectionModal
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
// Block (not flex) so the strip wraps around the floated time selector: one line
|
||||
// beside it when collapsed, and — via `.stripExpanded`'s `clear: both` — dropping
|
||||
// full-width below it when expanded. A flex `.bar` would be a BFC that sits beside
|
||||
// the float and makes the child strip's `clear` inert.
|
||||
.bar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.addVariable {
|
||||
flex: none;
|
||||
border-style: dashed !important;
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
.strip {
|
||||
display: flow-root;
|
||||
}
|
||||
@@ -15,7 +25,8 @@
|
||||
clear: both;
|
||||
|
||||
.variableSlot,
|
||||
.moreButton {
|
||||
.moreButton,
|
||||
.addSlot {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -48,6 +59,12 @@
|
||||
}
|
||||
|
||||
.moreButton {
|
||||
display: inline-flex;
|
||||
margin-right: 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.addSlot {
|
||||
display: inline-flex;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft } from '@signozhq/icons';
|
||||
import { ChevronLeft, Plus } 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 { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
import { useVariableSelection } from './useVariableSelection';
|
||||
import VariableSelector from './VariableSelector';
|
||||
@@ -44,18 +45,59 @@ interface VariablesBarProps {
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const { variables, selection, setSelection, autoSelect } =
|
||||
useVariableSelection(dashboard);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
itemCount: variables.length,
|
||||
gap: 8,
|
||||
reserveWidth: 48,
|
||||
// Reserve the trailing line space: the "+N" trigger, plus the always-present
|
||||
// add-variable "+" when editable, so the collapse math accounts for both.
|
||||
reserveWidth: isEditable ? 88 : 48,
|
||||
enabled: !expanded,
|
||||
});
|
||||
|
||||
if (variables.length === 0) {
|
||||
// Editors can add a variable even before any exist; viewers with no variables
|
||||
// have nothing to show.
|
||||
if (variables.length === 0 && !isEditable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openAddVariable = (): void =>
|
||||
requestSettings({ tab: 'Variables', addVariable: true });
|
||||
|
||||
// No variables yet: a full-width labelled button. Once variables exist it
|
||||
// shrinks to a `+` icon (with the label on hover) that sits after them.
|
||||
const addVariableFull = (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className={styles.addVariable}
|
||||
prefix={<Plus size={14} />}
|
||||
testId="dashboard-variables-add"
|
||||
onClick={openAddVariable}
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
);
|
||||
|
||||
const addVariableIcon = (
|
||||
<TooltipSimple side="top" title="Add variable">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.addVariable}
|
||||
aria-label="Add variable"
|
||||
testId="dashboard-variables-add"
|
||||
onClick={openAddVariable}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
|
||||
const hasOverflow = overflowCount > 0;
|
||||
const hiddenVariables =
|
||||
!expanded && hasOverflow ? variables.slice(visibleCount) : [];
|
||||
@@ -74,6 +116,14 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
{addVariableFull}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
<div
|
||||
@@ -128,6 +178,11 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* After the more/less trigger, in every state. Kept inline (not block)
|
||||
so the row still flows under the floated time selector, and always
|
||||
mounted so measuring never toggles it. */}
|
||||
{isEditable && <span className={styles.addSlot}>{addVariableIcon}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -15,12 +15,14 @@ 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];
|
||||
@@ -111,9 +113,34 @@ export function usePanelQuery({
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
// Resolved variable values for this dashboard, published by useResolvedVariables.
|
||||
// Substituted into the request and keyed into the cache so a selection change refetches.
|
||||
// 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.
|
||||
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.
|
||||
@@ -188,8 +215,9 @@ export function usePanelQuery({
|
||||
// Each page is its own cache entry (0/default for non-paged kinds).
|
||||
offset,
|
||||
pageSize,
|
||||
// Variable selection changes the request, so it must re-key the cache (refetch).
|
||||
variables,
|
||||
// Only the variables this panel references re-key the cache, so an unrelated
|
||||
// variable change doesn't refetch it.
|
||||
scopedVariables,
|
||||
],
|
||||
[
|
||||
panelId,
|
||||
@@ -205,14 +233,14 @@ export function usePanelQuery({
|
||||
queries,
|
||||
offset,
|
||||
pageSize,
|
||||
variables,
|
||||
scopedVariables,
|
||||
],
|
||||
);
|
||||
|
||||
const response = useGetQueryRangeV5({
|
||||
requestPayload,
|
||||
queryKey,
|
||||
enabled: enabled && runnable,
|
||||
enabled: enabled && runnable && !isWaitingOnVariable,
|
||||
// Hold the current page while the next loads (offset re-keys) so the pager doesn't flash.
|
||||
keepPreviousData: isPaginated,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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]);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import DashboardPageToolbar from './DashboardPageToolbar';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
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';
|
||||
@@ -51,15 +52,25 @@ 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);
|
||||
|
||||
// In full screen show only the sections and panels — the header/toolbar chrome
|
||||
// is hidden for a clean presentation view (exit with Esc).
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
<DashboardPageToolbar
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
{!fullScreenHandle.active && (
|
||||
<>
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
<DashboardPageToolbar
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
|
||||
</div>
|
||||
</FullScreen>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
/** Which settings-drawer tab to open. Mirrors the DashboardSettings tab keys. */
|
||||
export type SettingsTab = 'Overview' | 'Variables' | 'Publish';
|
||||
|
||||
export interface SettingsRequest {
|
||||
tab: SettingsTab;
|
||||
/** Open the add-variable form immediately (Variables tab only). */
|
||||
addVariable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A transient request to open the settings drawer at a given tab/action, so a
|
||||
* control anywhere in the tree (e.g. the "Add variable" button in the variables
|
||||
* bar) can deep-link into the drawer without prop-threading. Not persisted;
|
||||
* cleared when the drawer closes.
|
||||
*/
|
||||
export interface SettingsRequestSlice {
|
||||
settingsRequest: SettingsRequest | null;
|
||||
requestSettings: (request: SettingsRequest) => void;
|
||||
clearSettingsRequest: () => void;
|
||||
}
|
||||
|
||||
export const createSettingsRequestSlice: StateCreator<
|
||||
DashboardStore,
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
SettingsRequestSlice
|
||||
> = (set) => ({
|
||||
settingsRequest: null,
|
||||
requestSettings: (settingsRequest): void => {
|
||||
set({ settingsRequest });
|
||||
},
|
||||
clearSettingsRequest: (): void => {
|
||||
set({ settingsRequest: null });
|
||||
},
|
||||
});
|
||||
@@ -17,11 +17,16 @@ import {
|
||||
createVariableFetchSlice,
|
||||
type VariableFetchSlice,
|
||||
} from './slices/variableFetchSlice';
|
||||
import {
|
||||
createSettingsRequestSlice,
|
||||
type SettingsRequestSlice,
|
||||
} from './slices/settingsRequestSlice';
|
||||
|
||||
export type DashboardStore = EditContextSlice &
|
||||
CollapseSlice &
|
||||
VariableSelectionSlice &
|
||||
VariableFetchSlice;
|
||||
VariableFetchSlice &
|
||||
SettingsRequestSlice;
|
||||
|
||||
/**
|
||||
* V2 dashboard session store. Holds cross-cutting client state only — never the
|
||||
@@ -37,6 +42,7 @@ export const useDashboardStore = create<DashboardStore>()(
|
||||
...createCollapseSlice(...a),
|
||||
...createVariableSelectionSlice(...a),
|
||||
...createVariableFetchSlice(...a),
|
||||
...createSettingsRequestSlice(...a),
|
||||
}),
|
||||
{
|
||||
name: '@signoz/dashboard-v2',
|
||||
|
||||
@@ -21,6 +21,7 @@ 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,6 +44,9 @@ function PanelEditorPage(): JSX.Element {
|
||||
const { dashboard, isLoading, isError, error } =
|
||||
useDashboardFetch(dashboardId);
|
||||
|
||||
// Feed variables to the query builder autocomplete inside the editor.
|
||||
useSyncVariablesForSuggestions(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.
|
||||
const newKind = parseNewPanelKind(panelId, search);
|
||||
|
||||
Reference in New Issue
Block a user