mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-25 21:00:45 +01:00
Compare commits
2 Commits
main
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34342f2360 | ||
|
|
dc0de1bf2c |
@@ -47,5 +47,4 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
|
||||
}
|
||||
|
||||
@@ -53,10 +53,6 @@
|
||||
z-index: 0;
|
||||
background: var(--l1-background);
|
||||
|
||||
// Column so the bottom strip sits under the scrolling content, not inside it.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.full-screen-content {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -74,9 +70,7 @@
|
||||
|
||||
.chat-support-gateway {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: calc(20px + var(--bottom-strip-height, 0px));
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
|
||||
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import BottomStrip from 'container/BottomStrip';
|
||||
import SideNav from 'container/SideNav';
|
||||
import TopNav from 'container/TopNav';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -52,7 +51,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import useTabVisibility from 'hooks/useTabFocus';
|
||||
import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
@@ -404,7 +402,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}, [pathname]);
|
||||
|
||||
const isToDisplayLayout = isLoggedIn;
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
|
||||
const pageTitle = t(routeKey);
|
||||
@@ -871,10 +868,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
</OverlayScrollbar>
|
||||
</LayoutContent>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
|
||||
<BottomStrip />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoggedIn && isAIAssistantEnabled && (
|
||||
|
||||
@@ -12,12 +12,8 @@ export const Layout = styled(LayoutComponent)`
|
||||
}
|
||||
`;
|
||||
|
||||
// Takes the height left in `.app-content` after the bottom strip.
|
||||
// `min-height: 0` is not needed right now, overlayscrollbars already sets
|
||||
// `overflow: auto` here. Kept so this does not break if that goes away.
|
||||
export const LayoutContent = styled(LayoutComponent.Content)`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
|
||||
flex-shrink: 0;
|
||||
height: var(--bottom-strip-height);
|
||||
padding: 0 var(--spacing-6);
|
||||
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
|
||||
font-family: var(--font-family-sf-mono, monospace);
|
||||
|
||||
// Above page content, below the body-portalled overlays that are meant to
|
||||
// cover the strip.
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Temporary placeholder for the left slot. Replaced later.
|
||||
.version {
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import BottomStrip, {
|
||||
BOTTOM_STRIP_HEIGHT,
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
BOTTOM_STRIP_ON_CLASS,
|
||||
} from '..';
|
||||
|
||||
describe('BottomStrip', () => {
|
||||
it('publishes the body class and height property while mounted', () => {
|
||||
const { unmount } = render(<BottomStrip />);
|
||||
|
||||
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(true);
|
||||
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
|
||||
`${BOTTOM_STRIP_HEIGHT}px`,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(false);
|
||||
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
// The string is whatever the Go build injected, so it is rendered untouched —
|
||||
// same as SideNav. Release tags carry the "v", local builds do not.
|
||||
it.each([['v0.134.67'], ['main-64f1c2a']])(
|
||||
'renders the build version %p exactly as given',
|
||||
(version) => {
|
||||
const { getByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: {
|
||||
versionData: { version, ee: 'Y', setupCompleted: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the strip without a version when none is available', () => {
|
||||
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: { versionData: null },
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip')).toBeInTheDocument();
|
||||
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import styles from './BottomStrip.module.scss';
|
||||
|
||||
export const BOTTOM_STRIP_HEIGHT = 24;
|
||||
|
||||
export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
|
||||
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
|
||||
|
||||
function BottomStrip(): JSX.Element {
|
||||
const { versionData } = useAppContext();
|
||||
const version = versionData?.version?.trim();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.setProperty(
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
`${BOTTOM_STRIP_HEIGHT}px`,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
document.body.classList.remove(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.removeProperty(BOTTOM_STRIP_HEIGHT_VAR);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.strip} data-testid="bottom-strip">
|
||||
<div className={styles.left}>
|
||||
{version && (
|
||||
<span className={styles.version} data-testid="bottom-strip-version">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BottomStrip;
|
||||
@@ -1,8 +1,6 @@
|
||||
.create-alert-v2-footer {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
left: 63px;
|
||||
right: 0;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
.explorer-options-container {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0px;
|
||||
left: calc(50% + 240px);
|
||||
transform: translate(calc(-50% - 120px), 0);
|
||||
transition: left 0.2s linear;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
.explorer-option-droppable-container {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
width: -webkit-fill-available;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.licenses-page {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.licenses-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
@@ -29,6 +32,7 @@
|
||||
|
||||
.licenses-page-content {
|
||||
flex: 1;
|
||||
height: calc(100vh - 48px);
|
||||
background: var(--l1-background);
|
||||
padding: 10px 8px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
height: calc(100vh - 62px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
|
||||
@@ -181,9 +181,7 @@
|
||||
|
||||
.ant-pagination {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new
|
||||
// fixed-bottom UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
width: calc(100% - 54px);
|
||||
background: var(--l1-background);
|
||||
padding: 16px;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
height: calc(100vh - 62px);
|
||||
min-height: 400px;
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.version-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.version-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useSavedViewEnabled(): boolean {
|
||||
const [isEnabled] = useState(
|
||||
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
|
||||
);
|
||||
|
||||
return isEnabled;
|
||||
}
|
||||
@@ -1,29 +1,4 @@
|
||||
.alerts-container {
|
||||
// Hands the page height down to the active tab so its content can bound itself
|
||||
// instead of guessing with 100vh. Child combinators only, nested Tabs
|
||||
// (Configuration) must not be caught.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
> .ant-tabs-content-holder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-tabs-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-tabs-tabpane-active {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-level-tab.periscope-tab {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@@ -65,9 +40,5 @@
|
||||
|
||||
.alert-rules-container {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ import type { EQueryType } from 'types/common/dashboard';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import PanelTitleInput from './PanelTitleInput/PanelTitleInput';
|
||||
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 {
|
||||
@@ -94,12 +94,9 @@ function ConfigPane({
|
||||
<div className={styles.group}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Title</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-title"
|
||||
<PanelTitleInput
|
||||
value={spec.display.name}
|
||||
placeholder="Panel title"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => setDisplayField('name', e.target.value)}
|
||||
onChange={(value): void => setDisplayField('name', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.autoComplete {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { AutoComplete, Input } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
|
||||
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
|
||||
import { findVariableToken, insertVariable } from './variableToken';
|
||||
|
||||
import styles from './PanelTitleInput.module.scss';
|
||||
|
||||
interface PanelTitleInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
interface VariableOption {
|
||||
/** Whole title after insertion, so antd's change event carries it. */
|
||||
value: string;
|
||||
label: string;
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
function PanelTitleInput({
|
||||
value,
|
||||
onChange,
|
||||
}: PanelTitleInputProps): JSX.Element {
|
||||
const variableNames = useDashboardVariableNames();
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const pendingCursor = useRef<number | null>(null);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
const options = useMemo<VariableOption[]>(() => {
|
||||
const token = findVariableToken(value, cursor);
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
const query = token.query.toLowerCase();
|
||||
return variableNames
|
||||
.filter((name) => name.toLowerCase().startsWith(query))
|
||||
.map((name) => {
|
||||
const next = insertVariable(value, token, name);
|
||||
return { value: next.text, label: name, cursor: next.cursor };
|
||||
});
|
||||
}, [value, cursor, variableNames]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (pendingCursor.current === null) {
|
||||
return;
|
||||
}
|
||||
inputRef.current?.input?.setSelectionRange(
|
||||
pendingCursor.current,
|
||||
pendingCursor.current,
|
||||
);
|
||||
setCursor(pendingCursor.current);
|
||||
pendingCursor.current = null;
|
||||
}, [value]);
|
||||
|
||||
const syncCursor = (): void => {
|
||||
setCursor(inputRef.current?.input?.selectionStart ?? 0);
|
||||
};
|
||||
|
||||
const handleChange = (next: string): void => {
|
||||
const picked = options.find((option) => option.value === next);
|
||||
if (picked) {
|
||||
pendingCursor.current = picked.cursor;
|
||||
setDismissed(true);
|
||||
} else {
|
||||
setDismissed(false);
|
||||
syncCursor();
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Escape') {
|
||||
setDismissed(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
className={styles.autoComplete}
|
||||
value={value}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
options={options}
|
||||
open={focused && !dismissed && options.length > 0}
|
||||
filterOption={false}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
data-testid="panel-editor-v2-title"
|
||||
placeholder="Panel title"
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={syncCursor}
|
||||
onClick={syncCursor}
|
||||
onFocus={(): void => setFocused(true)}
|
||||
onBlur={(): void => setFocused(false)}
|
||||
/>
|
||||
</AutoComplete>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTitleInput;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import PanelTitleInput from '../PanelTitleInput';
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
|
||||
() => ({
|
||||
useDashboardVariableNames: (): string[] => ['service.name', 'env'],
|
||||
}),
|
||||
);
|
||||
|
||||
function Harness({ initial = '' }: { initial?: string }): JSX.Element {
|
||||
const [value, setValue] = useState(initial);
|
||||
return <PanelTitleInput value={value} onChange={setValue} />;
|
||||
}
|
||||
|
||||
describe('PanelTitleInput', () => {
|
||||
it('suggests no variables until a `$` is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.type(screen.getByTestId('panel-editor-v2-title'), 'Latency');
|
||||
|
||||
expect(screen.queryByText('env')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('suggests variables matching the text after `$`', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('panel-editor-v2-title'),
|
||||
'Latency of $se',
|
||||
);
|
||||
|
||||
await expect(screen.findByText('service.name')).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText('env')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('inserts the picked variable into the title', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
const input = screen.getByTestId('panel-editor-v2-title');
|
||||
|
||||
await user.type(input, 'Latency of $se');
|
||||
await user.click(await screen.findByText('service.name'));
|
||||
|
||||
expect(input).toHaveValue('Latency of $service.name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { findVariableToken, insertVariable } from '../variableToken';
|
||||
|
||||
describe('findVariableToken', () => {
|
||||
it('finds the token being typed before the cursor', () => {
|
||||
expect(findVariableToken('Latency of $ser', 15)).toStrictEqual({
|
||||
start: 11,
|
||||
query: 'ser',
|
||||
});
|
||||
});
|
||||
|
||||
it('matches a bare `$`', () => {
|
||||
expect(findVariableToken('by $', 4)).toStrictEqual({ start: 3, query: '' });
|
||||
});
|
||||
|
||||
it('ignores a token the cursor has moved past', () => {
|
||||
expect(findVariableToken('$env in prod', 12)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertVariable', () => {
|
||||
it('replaces the whole token, including text after the cursor', () => {
|
||||
const token = { start: 4, query: 'ser' };
|
||||
expect(
|
||||
insertVariable('p99 $service_x for', token, 'service.name'),
|
||||
).toStrictEqual({
|
||||
text: 'p99 $service.name for',
|
||||
cursor: 17,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const TOKEN_BEFORE_CURSOR = /\$([\w.]*)$/;
|
||||
const IDENTIFIER_PREFIX = /^[\w.]*/;
|
||||
|
||||
export interface VariableToken {
|
||||
start: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export function findVariableToken(
|
||||
text: string,
|
||||
cursor: number,
|
||||
): VariableToken | null {
|
||||
const match = TOKEN_BEFORE_CURSOR.exec(text.slice(0, cursor));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return { start: match.index, query: match[1] };
|
||||
}
|
||||
|
||||
/** Also replaces identifier chars after the cursor: `$ser|vice` leaves no `vice`. */
|
||||
export function insertVariable(
|
||||
text: string,
|
||||
token: VariableToken,
|
||||
name: string,
|
||||
): { text: string; cursor: number } {
|
||||
const before = text.slice(0, token.start);
|
||||
const after = text.slice(token.start + 1).replace(IDENTIFIER_PREFIX, '');
|
||||
const inserted = `${before}$${name}`;
|
||||
return { text: `${inserted}${after}`, cursor: inserted.length };
|
||||
}
|
||||
@@ -23,6 +23,11 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
|
||||
() => ({ useDashboardVariableNames: (): string[] => [] }),
|
||||
);
|
||||
|
||||
function textSpec(): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Runbook', description: 'steps' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
|
||||
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
|
||||
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
|
||||
|
||||
import type { VariableItem } from './types';
|
||||
|
||||
@@ -22,15 +21,7 @@ const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
|
||||
export function useContextLinkVariables(): VariableItem[] {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const { variables: variableDtos } = useDashboardFetchRequired();
|
||||
|
||||
const dashboardVariableNames = useMemo(
|
||||
() =>
|
||||
variableDtos
|
||||
.map((dto) => dtoToFormModel(dto).name)
|
||||
.filter((name): name is string => !!name),
|
||||
[variableDtos],
|
||||
);
|
||||
const dashboardVariableNames = useDashboardVariableNames();
|
||||
|
||||
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
|
||||
const fieldVariableNames = useMemo(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/quer
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
|
||||
import { EMPTY_PANEL_QUERY_DATA } from '../utils/emptyPanelQueryData';
|
||||
import { usePanelTitle } from '../hooks/usePanelTitle';
|
||||
import PanelHeaderSearch from './PanelHeaderSearch';
|
||||
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
|
||||
import {
|
||||
@@ -67,7 +68,7 @@ function PanelHeader(props: PanelHeaderProps): JSX.Element {
|
||||
const { panelId, panel, panelActions, hideActions } = props;
|
||||
const query = props.mode === 'query' ? props : null;
|
||||
|
||||
const name = panel.spec.display.name;
|
||||
const name = usePanelTitle(panel);
|
||||
const description = panel.spec.display.description;
|
||||
const errorDetail = useMemo(
|
||||
() => panelStatusFromError(query?.error),
|
||||
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@signozhq/ui/dialog';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import ViewPanelModalContent from './ViewPanelModalContent';
|
||||
import ViewPanelModalTitle from './ViewPanelModalTitle';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalProps {
|
||||
@@ -31,8 +31,6 @@ function ViewPanelModal({
|
||||
open,
|
||||
onClose,
|
||||
}: ViewPanelModalProps): JSX.Element {
|
||||
const name = panel?.spec.display.name ?? '';
|
||||
|
||||
// Render antd popups into the dialog (not document.body) so they stay inside the
|
||||
// modal's interactive, focus-trapped layer instead of being blocked by Radix.
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -54,11 +52,11 @@ function ViewPanelModal({
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name ? `${name} - (View mode)` : 'View mode'}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
{panel ? (
|
||||
<ViewPanelModalTitle panel={panel} />
|
||||
) : (
|
||||
<Typography.Text className={styles.title}>View mode</Typography.Text>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogCloseButton />
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelTitle } from '../hooks/usePanelTitle';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalTitleProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
function ViewPanelModalTitle({ panel }: ViewPanelModalTitleProps): JSX.Element {
|
||||
const name = usePanelTitle(panel);
|
||||
|
||||
return (
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name ? `${name} - (View mode)` : 'View mode'}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModalTitle;
|
||||
@@ -1,8 +1,12 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
Querybuildertypesv5VariableTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
@@ -100,6 +104,30 @@ describe('PanelHeader title and description', () => {
|
||||
expect(screen.getByText('My panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('substitutes dashboard variables into the panel name', () => {
|
||||
useDashboardStore.setState({
|
||||
dashboardId: 'dash-1',
|
||||
resolvedVariables: {
|
||||
'dash-1': {
|
||||
service: {
|
||||
type: Querybuildertypesv5VariableTypeDTO.query,
|
||||
value: ['cart', 'api'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
renderWithProvider(
|
||||
<PanelHeader
|
||||
{...baseProps}
|
||||
panel={makePanel({ name: 'Latency of $service ({{missing}})' })}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Latency of cart, api ({{missing}})'),
|
||||
).toBeInTheDocument();
|
||||
useDashboardStore.setState({ dashboardId: '', resolvedVariables: {} });
|
||||
});
|
||||
|
||||
it('shows the description info icon when a description is provided', () => {
|
||||
renderWithProvider(
|
||||
<PanelHeader
|
||||
|
||||
@@ -89,7 +89,7 @@ jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
selector({ dashboardId: 'dash-1', resolvedVariables: {} }),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
selector({ dashboardId: 'dash-1', resolvedVariables: {} }),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/
|
||||
import { downloadCsv } from 'pages/DashboardPage/DashboardContainer/Panels/utils/downloadCsv';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import { usePanelTitle } from './usePanelTitle';
|
||||
|
||||
interface UseDownloadPanelCsvArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
data: PanelQueryData;
|
||||
@@ -29,7 +31,7 @@ export function useDownloadPanelCsv({
|
||||
data,
|
||||
canDownloadCsv,
|
||||
}: UseDownloadPanelCsvArgs): () => void {
|
||||
const fileName = panel.spec.display.name;
|
||||
const fileName = usePanelTitle(panel);
|
||||
|
||||
return useCallback((): void => {
|
||||
if (!canDownloadCsv) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/quer
|
||||
import { buildDownloadMenuItem } from '../utils/buildDownloadMenuItem';
|
||||
import { useDownloadPanelCsv } from './useDownloadPanelCsv';
|
||||
import { useDownloadPanelImage } from './useDownloadPanelImage';
|
||||
import { usePanelTitle } from './usePanelTitle';
|
||||
|
||||
interface UseDownloadPanelMenuItemArgs {
|
||||
panelId: string;
|
||||
@@ -28,7 +29,7 @@ export function useDownloadPanelMenuItem({
|
||||
data,
|
||||
actions,
|
||||
}: UseDownloadPanelMenuItemArgs): MenuItem | null {
|
||||
const panelName = panel.spec.display.name;
|
||||
const panelName = usePanelTitle(panel);
|
||||
const downloadPanelCsv = useDownloadPanelCsv({
|
||||
panel,
|
||||
data,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { interpolateVariables } from 'pages/DashboardPage/DashboardContainer/Panels/utils/interpolateVariables';
|
||||
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
export function usePanelTitle(panel: DashboardtypesPanelDTO): string {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
|
||||
const name = panel.spec.display.name;
|
||||
|
||||
return useMemo(() => interpolateVariables(name, variables), [name, variables]);
|
||||
}
|
||||
@@ -2,9 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMemo } from 'react';
|
||||
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
|
||||
|
||||
import { useDashboardFetchRequired } from './useDashboardFetchRequired';
|
||||
|
||||
export function useDashboardVariableNames(): string[] {
|
||||
const { variables } = useDashboardFetchRequired();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
variables
|
||||
.map((dto) => dtoToFormModel(dto).name)
|
||||
.filter((name): name is string => !!name),
|
||||
[variables],
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
.support-page-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.support-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
.traces-funnel-details {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
// 45px -> height of the tab bar
|
||||
height: calc(100vh - 45px);
|
||||
|
||||
&__steps-config {
|
||||
flex-shrink: 0;
|
||||
width: 600px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
// Positioning context for the absolute .steps-footer.
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Scoped here so the modal usage of FunnelConfiguration on trace details
|
||||
// stays in normal flow.
|
||||
.funnel-configuration {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
&__steps-results {
|
||||
width: 100%;
|
||||
|
||||
@@ -4,17 +4,14 @@
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
&.funnel-details-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// .steps-footer is absolute against the config column, so its 64px is
|
||||
// reserved rather than laid out.
|
||||
margin-bottom: 64px;
|
||||
height: calc(
|
||||
100vh - 170px
|
||||
); // 64px bottom bar + 61px configuration header + 45px page navbar
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user