Compare commits

...

3 Commits

21 changed files with 1926 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
.badgesContainer {
display: flex;
align-items: center;
flex-flow: wrap;
gap: 6px;
}
.badgeContainer {
color: var(--bg-sienna-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.52px;
height: 24px;
flex-shrink: 0;
border-radius: 50px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
padding: 2px 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.inputContainer {
> div {
margin: 0;
}
padding-left: 0px !important;
padding-right: 0px !important;
}
.tagsInput {
display: flex;
border: none;
padding: 0px;
width: 183px;
height: 24px;
padding: 3px 1px;
flex-shrink: 0;
}
.editInput {
.ant-form-item {
margin-bottom: 0px;
}
}

View File

@@ -0,0 +1,115 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { Col, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import Input from 'components/Input';
import styles from './AddBadges.module.scss';
function AddTags({ tags, setTags }: AddTagsProps): JSX.Element {
const [inputValue, setInputValue] = useState<string>('');
const [editInputIndex, setEditInputIndex] = useState(-1);
const [editInputValue, setEditInputValue] = useState('');
const handleInputConfirm = (): void => {
if (inputValue) {
setTags([...tags, inputValue]);
}
setInputValue('');
};
const handleEditInputConfirm = (): void => {
const newTags = [...tags];
newTags[editInputIndex] = editInputValue;
setTags(newTags);
setEditInputIndex(-1);
setInputValue('');
};
const handleClose = (removedTag: string): void => {
const newTags = tags.filter((tag) => tag !== removedTag);
setTags(newTags);
};
const onChangeHandler = (
value: string,
func: Dispatch<SetStateAction<string>>,
): void => {
func(value);
};
return (
<div className={styles.badgesContainer}>
{tags.map((tag, index) => {
if (editInputIndex === index) {
return (
<Col key={tag} lg={4} className={styles.editInput}>
<Input
size="small"
value={editInputValue}
onChangeHandler={(event): void =>
onChangeHandler(event.target.value, setEditInputValue)
}
onBlurHandler={handleEditInputConfirm}
onPressEnterHandler={handleEditInputConfirm}
/>
</Col>
);
}
const isLongTag = tag.length > 20;
const tagElem = (
<Badge
key={tag}
color="vanilla"
className={styles.badgeContainer}
closable
onClose={(e): void => {
e.preventDefault();
handleClose(tag);
}}
>
<span
onDoubleClick={(e): void => {
setEditInputIndex(index);
setEditInputValue(tag);
e.preventDefault();
}}
>
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
</span>
</Badge>
);
return isLongTag ? (
<Tooltip title={tag} key={tag}>
{tagElem}
</Tooltip>
) : (
tagElem
);
})}
<Col className={styles.inputContainer}>
<Input
type="text"
value={inputValue}
rootClassName={styles.tagsInput}
placeholder="Start typing your tag name"
onChangeHandler={(event): void =>
onChangeHandler(event.target.value, setInputValue)
}
onBlurHandler={handleInputConfirm}
onPressEnterHandler={handleInputConfirm}
/>
</Col>
</div>
);
}
interface AddTagsProps {
tags: string[];
setTags: Dispatch<SetStateAction<string[]>>;
}
export default AddTags;

View File

@@ -0,0 +1,43 @@
.settings-container-root {
.ant-drawer-wrapper-body {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
.ant-drawer-header {
height: 48px;
border-bottom: 1px solid var(--l1-border);
padding: 14px 14px 14px 11px;
.ant-drawer-header-title {
gap: 16px;
.ant-drawer-title {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding-left: 16px;
border-left: 1px solid var(--l1-border);
}
.ant-drawer-close {
height: 16px;
width: 16px;
margin-inline-end: 0px !important;
}
}
}
.ant-drawer-body {
padding: 16px;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
}
}

View File

@@ -0,0 +1,34 @@
import { memo, PropsWithChildren, ReactElement } from 'react';
import { Drawer } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import './SettingsDrawer.styles.scss';
type SettingsDrawerProps = PropsWithChildren<{
drawerTitle: string;
isOpen: boolean;
onClose: () => void;
}>;
function SettingsDrawer({
children,
drawerTitle,
isOpen,
onClose,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
title={drawerTitle}
placement="right"
width="50%"
onClose={onClose}
open={isOpen}
rootClassName="settings-container-root"
>
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
</Drawer>
);
}
export default memo(SettingsDrawer);

View File

@@ -0,0 +1,395 @@
import { useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import {
Check,
ClipboardCopy,
Ellipsis,
FileJson,
Fullscreen,
Globe,
LockKeyhole,
PenLine,
Plus,
X,
} from '@signozhq/icons';
import { Button, Card, Input, Modal, Popover, Tag, Tooltip } from 'antd';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import {
lockDashboardV2,
patchDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import useComponentPermission from 'hooks/useComponentPermission';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { Base64Icons } from '../../DashboardContainer/DashboardSettings/General/utils';
import DashboardSettingsV2 from '../DashboardSettings';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import SettingsDrawer from './SettingsDrawer';
import '../../DashboardContainer/DashboardDescription/Description.styles.scss';
import type { V2Dashboard } from '../utils';
interface DashboardDescriptionV2Props {
dashboard: V2Dashboard | undefined;
handle: FullScreenHandle;
onRefetch: () => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function DashboardDescriptionV2(props: DashboardDescriptionV2Props): JSX.Element {
const { dashboard, handle, onRefetch } = props;
const id = dashboard?.id ?? '';
const isDashboardLocked = !!dashboard?.locked;
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const title = dashboard?.spec?.display?.name ?? '';
const description = dashboard?.spec?.display?.description ?? '';
const image = dashboard?.image || Base64Icons[0];
const tags = useMemo(
() =>
(dashboard?.tags ?? []).map((t) =>
t.key === t.value ? t.key : `${t.key}:${t.value}`,
),
[dashboard?.tags],
);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
useState<boolean>(false);
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
useState<boolean>(false);
const isAuthor =
!!user?.email && !!dashboard?.createdBy && dashboard.createdBy === user.email;
const addPanelPermission = !isDashboardLocked;
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
const isPublicDashboard = false;
const { showErrorModal } = useErrorModal();
const { t } = useTranslation(['dashboard', 'common']);
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
useEffect(() => {
if (dashboard) {setUpdatedTitle(title);}
}, [dashboard, title]);
const handleLockDashboardToggle = async (): Promise<void> => {
if (!id) {return;}
setIsDashbordSettingsOpen(false);
try {
if (isDashboardLocked) {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
} else {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
}
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
}
};
const onNameChangeHandler = async (): Promise<void> => {
const trimmed = updatedTitle.trim();
if (!id || !trimmed || trimmed === title) {
setIsRenameDashboardOpen(false);
return;
}
try {
setIsRenameLoading(true);
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: trimmed,
},
];
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
setIsRenameDashboardOpen(false);
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
setIsRenameDashboardOpen(true);
} finally {
setIsRenameLoading(false);
}
};
const onEmptyWidgetHandler = (): void => {
logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
toast.info('V2 panel editor coming next');
};
const [state, setCopy] = useCopyToClipboard();
useEffect(() => {
if (state.error) {
toast.error(t('something_went_wrong', { ns: 'common' }));
}
if (state.value) {
toast.success(t('success', { ns: 'common' }));
}
}, [state.error, state.value, t]);
const dashboardDataJSON = (): string =>
JSON.stringify(dashboard ?? {}, null, 2);
const exportJSON = (): void => {
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${title || 'dashboard'}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const onConfigureClick = (): void => {
setIsSettingsDrawerOpen(true);
};
const onSettingsDrawerClose = (): void => {
setIsSettingsDrawerOpen(false);
};
return (
<Card className="dashboard-description-container">
<DashboardHeader title={title} image={image} />
<section className="dashboard-details">
<div className="left-section">
<img src={image} alt="dashboard-img" className="dashboard-img" />
<Tooltip title={title.length > 30 ? title : ''}>
<Typography.Text
className="dashboard-title"
data-testid="dashboard-title"
>
{title}
</Typography.Text>
</Tooltip>
{isPublicDashboard && (
<Tooltip title="This dashboard is publicly accessible">
<Globe size={14} className="public-dashboard-icon" />
</Tooltip>
)}
{isDashboardLocked && (
<Tooltip title="This dashboard is locked">
<LockKeyhole size={14} className="lock-dashboard-icon" />
</Tooltip>
)}
</div>
<div className="right-section">
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<Popover
open={isDashboardSettingsOpen}
arrow={false}
onOpenChange={(visible): void => setIsDashbordSettingsOpen(visible)}
rootClassName="dashboard-settings"
content={
<div className="menu-content">
<section className="section-1">
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<Tooltip
title={
dashboard?.createdBy === 'integration' &&
'Dashboards created by integrations cannot be unlocked'
}
>
<Button
type="text"
icon={<LockKeyhole size={14} />}
disabled={dashboard?.createdBy === 'integration'}
onClick={handleLockDashboardToggle}
data-testid="lock-unlock-dashboard"
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</Tooltip>
)}
{!isDashboardLocked && editDashboard && (
<Button
type="text"
icon={<PenLine size={14} />}
onClick={(): void => {
setIsRenameDashboardOpen(true);
setIsDashbordSettingsOpen(false);
}}
>
Rename
</Button>
)}
<Button
type="text"
icon={<Fullscreen size={14} />}
onClick={handle.enter}
>
Full screen
</Button>
</section>
<section className="section-2">
<Button
type="text"
icon={<FileJson size={14} />}
onClick={(): void => {
exportJSON();
setIsDashbordSettingsOpen(false);
}}
>
Export JSON
</Button>
<Button
type="text"
icon={<ClipboardCopy size={14} />}
onClick={(): void => {
setCopy(dashboardDataJSON());
setIsDashbordSettingsOpen(false);
}}
>
Copy as JSON
</Button>
</section>
<section className="delete-dashboard">
<DeleteButton
createdBy={dashboard?.createdBy || ''}
name={title}
id={id}
isLocked={isDashboardLocked}
routeToListPage
/>
</section>
</div>
}
trigger="click"
placement="bottomRight"
>
<Button
icon={<Ellipsis size={14} />}
type="text"
className="icons"
data-testid="options"
/>
</Popover>
{!isDashboardLocked && editDashboard && (
<>
<Button
type="text"
className="configure-button"
icon={<ConfigureIcon />}
data-testid="show-drawer"
onClick={onConfigureClick}
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={onSettingsDrawerClose}
>
<DashboardSettingsV2
dashboard={dashboard}
onRefetch={onRefetch}
/>
</SettingsDrawer>
</>
)}
{!isDashboardLocked && addPanelPermission && (
<Button
className="add-panel-btn"
onClick={onEmptyWidgetHandler}
icon={<Plus size="md" />}
type="primary"
data-testid="add-panel-header"
>
New Panel
</Button>
)}
</div>
</section>
{tags.length > 0 && (
<div className="dashboard-tags">
{tags.map((tag) => (
<Tag key={tag} className="tag">
{tag}
</Tag>
))}
</div>
)}
{!isEmpty(description) && (
<section className="dashboard-description-section">{description}</section>
)}
<Modal
open={isRenameDashboardOpen}
title="Rename Dashboard"
onOk={onNameChangeHandler}
onCancel={(): void => {
setIsRenameDashboardOpen(false);
}}
rootClassName="rename-dashboard"
footer={
<div className="dashboard-rename">
<Button
type="primary"
icon={<Check size={14} />}
className="rename-btn"
onClick={onNameChangeHandler}
disabled={isRenameLoading}
>
Rename Dashboard
</Button>
<Button
type="text"
icon={<X size={14} />}
className="cancel-btn"
onClick={(): void => setIsRenameDashboardOpen(false)}
>
Cancel
</Button>
</div>
}
>
<div className="dashboard-content">
<Typography.Text className="name-text">Enter a new name</Typography.Text>
<Input
data-testid="dashboard-name"
className="dashboard-name-input"
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
</div>
</Modal>
</Card>
);
}
export default DashboardDescriptionV2;

View File

@@ -0,0 +1,227 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.overviewSettings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -0,0 +1,363 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
import { Col, Input, Radio, Select, Space, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesJSONPatchOperationDTO,
TagtypesPostableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import styles from './GeneralSettings.module.scss';
import { Button } from './styles';
import { Base64Icons } from './utils';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
import type { V2Dashboard } from '../../utils';
const { Option } = Select;
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
// Convert V2 tags ({key, value}[]) into "key:value" strings for the V1
// AddTags component (which expects string[]), and back on save.
//
// V2 tags require both `key` and `value` to be non-empty server-side
// (returns `tag_invalid_value` otherwise). To preserve the V1 single-word
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.
function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
}
function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
return tagStrings
.map((s) => {
const trimmed = s.trim();
const idx = trimmed.indexOf(':');
if (idx === -1) {
return { key: trimmed, value: trimmed };
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
return { key, value: value || key };
})
.filter((t) => t.key.length > 0);
}
function GeneralDashboardSettingsV2({
dashboard,
onRefetch,
}: Props): JSX.Element {
const id = dashboard?.id ?? '';
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(id);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(id);
const title = dashboard?.spec?.display?.name ?? '';
const description = dashboard?.spec?.display?.description ?? '';
const image = dashboard?.image || Base64Icons[0];
const tagsAsStrings = useMemo(
() => tagsToStrings(dashboard?.tags ?? []),
[dashboard?.tags],
);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
const [updatedDescription, setUpdatedDescription] =
useState<string>(description);
const [updatedImage, setUpdatedImage] = useState<string>(image);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] =
useState<number>(0);
const { t } = useTranslation('common');
const { showErrorModal } = useErrorModal();
// Sync state when dashboard refetches after a save
useEffect(() => {
setUpdatedTitle(title);
setUpdatedDescription(description);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard?.updatedAt]);
const buildPatch = (): DashboardtypesJSONPatchOperationDTO[] => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
const replace = (
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO => ({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path,
value,
});
if (updatedTitle !== title) {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
ops.push(replace('/spec/display/description', updatedDescription));
}
if (updatedImage !== image) {
ops.push(replace('/image', updatedImage));
}
if (!isEqual(updatedTags, tagsAsStrings)) {
ops.push(replace('/tags', stringsToTags(updatedTags)));
}
return ops;
};
const onSaveHandler = async (): Promise<void> => {
if (!id) {
return;
}
const ops = buildPatch();
if (ops.length === 0) {
return;
}
try {
setIsSaving(true);
await patchDashboardV2({ id }, ops);
toast.success('Dashboard updated');
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
};
useEffect(() => {
let n = 0;
const initialValues = [title, description, tagsAsStrings, image];
const updatedValues = [
updatedTitle,
updatedDescription,
updatedTags,
updatedImage,
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) {
n += 1;
}
});
setNumberOfUnsavedChanges(n);
}, [
description,
image,
tagsAsStrings,
title,
updatedDescription,
updatedImage,
updatedTags,
updatedTitle,
]);
const discardHandler = (): void => {
setUpdatedTitle(title);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
setUpdatedDescription(description);
};
return (
<div className={styles.overviewContent}>
<Col className={styles.overviewSettings}>
<Space
direction="vertical"
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '21px',
}}
>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
value={updatedImage}
onChange={(value: string): void => setUpdatedImage(value)}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
</section>
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={updatedDescription}
className={styles.descriptionTextArea}
onChange={(e): void => setUpdatedDescription(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
</div>
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
Cross-Panel Sync
</strong>
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
className={styles.crossPanelSyncTooltipDocLink}
>
Learn more
<ExternalLink size={12} />
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</Col>
{numberOfUnsavedChanges > 0 && (
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
{numberOfUnsavedChanges} unsaved change
{numberOfUnsavedChanges > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionBtns}>
<Button
disabled={isSaving}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className={styles.discardBtn}
>
Discard
</Button>
<Button
style={{ margin: '16px 0' }}
disabled={isSaving}
loading={isSaving}
icon={<Check size={14} />}
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className={styles.saveBtn}
>
{t('save')}
</Button>
</div>
</div>
)}
</div>
);
}
export default GeneralDashboardSettingsV2;

View File

@@ -0,0 +1,20 @@
import { Button as ButtonComponent, Drawer } from 'antd';
import styled from 'styled-components';
export const Container = styled.div`
margin-top: 0.5rem;
`;
export const Button = styled(ButtonComponent)`
&&& {
display: flex;
align-items: center;
}
`;
export const DrawerContainer = styled(Drawer)`
.ant-drawer-header {
padding: 0;
border: none;
}
`;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,67 @@
import { Button, Empty, Tabs } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Braces, Globe, Table } from '@signozhq/icons';
import '../../DashboardContainer/DashboardSettings/DashboardSettingsContent.styles.scss';
import GeneralDashboardSettingsV2 from './General';
import type { V2Dashboard } from '../utils';
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
function Placeholder({ message }: { message: string }): JSX.Element {
return (
<div style={{ padding: 24 }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<Typography.Text>{message}</Typography.Text>}
/>
</div>
);
}
function DashboardSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
const items = [
{
label: (
<Button type="text" icon={<Table size={14} />}>
General
</Button>
),
key: 'general',
children: (
<GeneralDashboardSettingsV2
dashboard={dashboard}
onRefetch={onRefetch}
/>
),
},
{
label: (
<Button type="text" icon={<Braces size={14} />}>
Variables
</Button>
),
key: 'variables',
children: <Placeholder message="V2 dashboard variables coming next." />,
},
{
label: (
<Button type="text" icon={<Globe size={14} />}>
Publish
</Button>
),
key: 'public-dashboard',
children: (
<Placeholder message="V2 public dashboard publishing coming next." />
),
},
];
return <Tabs items={items} />;
}
export default DashboardSettingsV2;

View File

@@ -0,0 +1,97 @@
import { useMemo } from 'react';
import { Tag, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
interface Props {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
}
function PanelV2({ panel, panelId }: Props): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel?.spec?.queries?.length ?? 0;
const headerTitle = useMemo(() => {
if (!description) {return name;}
return (
<Tooltip title={description}>
<span>{name}</span>
</Tooltip>
);
}, [name, description]);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
background: 'var(--bg-ink-400, #0b0c0e)',
border: '1px solid var(--bg-slate-400, #1d212d)',
borderRadius: 4,
overflow: 'hidden',
}}
>
<div
className="drag-handle"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: '1px solid var(--bg-slate-400, #1d212d)',
cursor: 'grab',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
}}
>
<Typography.Text
style={{
margin: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{headerTitle}
</Typography.Text>
<Tag style={{ marginInlineEnd: 0 }}>{kind}</Tag>
</div>
<EllipsisVertical size={14} />
</div>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 12,
color: 'var(--bg-vanilla-400, #8993ae)',
fontSize: 12,
textAlign: 'center',
}}
>
<div>
<div style={{ marginBottom: 6 }}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering coming next
</div>
</div>
</div>
</div>
);
}
export default PanelV2;

View File

@@ -0,0 +1,95 @@
import { useMemo, useState } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import type { DashboardSectionV2 } from '../utils';
import PanelV2 from './PanelV2';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface Props {
section: DashboardSectionV2;
}
function SectionGrid({ items }: { items: DashboardSectionV2['items'] }): JSX.Element {
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
})),
[items],
);
return (
<ResponsiveGridLayout
cols={12}
rowHeight={45}
autoSize
useCSSTransforms
layout={rglLayout}
draggableHandle=".drag-handle"
isDraggable={false}
isResizable={false}
margin={[8, 8]}
>
{items.map((item) => (
<div key={item.id}>
<PanelV2 panel={item.panel} panelId={item.id} />
</div>
))}
</ResponsiveGridLayout>
);
}
function Section({ section }: Props): JSX.Element {
// Local toggle override — initial state from layout spec; user can
// expand/collapse without persisting.
const [open, setOpen] = useState<boolean>(section.open);
if (!section.title) {
// Untitled section — render just the grid (no header chrome).
return <SectionGrid items={section.items} />;
}
return (
<div
style={{
marginBottom: 12,
border: '1px solid var(--bg-slate-500)',
borderRadius: 4,
}}
data-testid={`dashboard-section-${section.id}`}
>
<Button
type="text"
onClick={(): void => setOpen((v) => !v)}
icon={open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
style={{
width: '100%',
justifyContent: 'flex-start',
padding: '8px 12px',
borderBottom: open ? '1px solid var(--bg-slate-500)' : 'none',
}}
data-testid={`dashboard-section-toggle-${section.id}`}
>
<Typography.Text style={{ marginLeft: 4 }}>
{section.title}
</Typography.Text>
{section.repeatVariable ? (
<Typography.Text style={{ marginLeft: 8, opacity: 0.6 }}>
(repeats per ${section.repeatVariable})
</Typography.Text>
) : null}
</Button>
{open ? <SectionGrid items={section.items} /> : null}
</div>
);
}
export default Section;

View File

@@ -0,0 +1,51 @@
import { useMemo } from 'react';
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { layoutsToSections } from '../utils';
import Section from './Section';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
interface Props {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined;
}
function GridCardLayoutV2({ layouts, panels }: Props): JSX.Element {
const sections = useMemo(() => layoutsToSections(layouts, panels), [
layouts,
panels,
]);
const isEmpty = sections.length === 0 || sections.every((s) => s.items.length === 0);
if (isEmpty) {
return (
<div style={{ padding: 48, textAlign: 'center' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Typography.Text>No panels in this dashboard yet</Typography.Text>
}
/>
</div>
);
}
return (
<>
{sections.map((section) => (
<Section key={section.id} section={section} />
))}
</>
);
}
export default GridCardLayoutV2;

View File

@@ -0,0 +1,63 @@
.dashboard-breadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
.dashboard-btn {
display: flex;
align-items: center;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding: 0px;
height: 20px;
}
.dashboard-btn:hover {
background-color: unset;
}
.id-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 0px 2px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
height: 20px;
max-width: calc(100% - 120px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ant-btn-icon {
margin-inline-end: 4px;
}
}
.id-btn:hover {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-300);
}
.dashboard-icon-image {
height: 14px;
width: 14px;
}
}

View File

@@ -0,0 +1,58 @@
import { useCallback } from 'react';
import { Button } from 'antd';
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import ROUTES from 'constants/routes';
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { LayoutGrid } from '@signozhq/icons';
import { Base64Icons } from '../../../DashboardContainer/DashboardSettings/General/utils';
import './DashboardBreadcrumbs.styles.scss';
interface Props {
title: string;
image?: string;
}
function DashboardBreadcrumbs({ title, image }: Props): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const goToListPage = useCallback(() => {
const dashboardsListQueryParamsString = getSessionStorageApi(
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
);
if (dashboardsListQueryParamsString) {
safeNavigate({
pathname: ROUTES.ALL_DASHBOARD,
search: `?${dashboardsListQueryParamsString}`,
});
} else {
safeNavigate(ROUTES.ALL_DASHBOARD);
}
}, [safeNavigate]);
return (
<div className="dashboard-breadcrumbs">
<Button
type="text"
icon={<LayoutGrid size={14} />}
className="dashboard-btn"
onClick={goToListPage}
>
Dashboard /
</Button>
<Button type="text" className="id-btn dashboard-name-btn">
<img
src={image || Base64Icons[0]}
alt="dashboard-icon"
className="dashboard-icon-image"
/>
{title}
</Button>
</div>
);
}
export default DashboardBreadcrumbs;

View File

@@ -0,0 +1,9 @@
.dashboard-header {
border-bottom: 1px solid var(--l1-border);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 0 8px;
box-sizing: border-box;
}

View File

@@ -0,0 +1,22 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import './DashboardHeader.styles.scss';
interface Props {
title: string;
image?: string;
}
function DashboardHeader({ title, image }: Props): JSX.Element {
return (
<div className="dashboard-header">
<DashboardBreadcrumbs title={title} image={image} />
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}
export default memo(DashboardHeader);

View File

@@ -0,0 +1,35 @@
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import DashboardDescriptionV2 from './DashboardDescriptionV2';
import GridCardLayoutV2 from './GridCardLayoutV2';
import type { V2Dashboard } from './utils';
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
function DashboardContainerV2({ dashboard, onRefetch }: Props): JSX.Element {
const fullScreenHandle = useFullScreenHandle();
const spec = dashboard?.spec;
return (
<FullScreen handle={fullScreenHandle}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<DashboardDescriptionV2
dashboard={dashboard}
handle={fullScreenHandle}
onRefetch={onRefetch}
/>
<div style={{ flex: 1, padding: '12px 24px', overflow: 'auto' }}>
<GridCardLayoutV2
layouts={spec?.layouts}
panels={spec?.panels ?? undefined}
/>
</div>
</div>
</FullScreen>
);
}
export default DashboardContainerV2;

View File

@@ -0,0 +1,111 @@
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
export type V2Dashboard = DashboardtypesGettableDashboardV2DTO;
export interface GridItemV2 {
id: string;
x: number;
y: number;
width: number;
height: number;
panel: DashboardtypesPanelDTO | undefined;
}
const PANEL_REF_PREFIX = '#/spec/panels/';
export function extractPanelIdFromRef(ref: string | undefined): string | null {
if (!ref) {return null;}
if (!ref.startsWith(PANEL_REF_PREFIX)) {return null;}
return ref.slice(PANEL_REF_PREFIX.length);
}
export function flattenGridLayout(
layouts: DashboardtypesLayoutDTO[] | undefined | null,
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
): GridItemV2[] {
if (!layouts?.length) {return [];}
const items: GridItemV2[] = [];
layouts.forEach((layoutEnvelope) => {
if (layoutEnvelope?.kind !== 'Grid') {return;}
const gridItems = layoutEnvelope.spec?.items ?? [];
gridItems.forEach((item) => {
const id = extractPanelIdFromRef(item.content?.$ref);
if (!id) {return;}
items.push({
id,
x: item.x ?? 0,
y: item.y ?? 0,
width: item.width ?? 6,
height: item.height ?? 6,
panel: panels?.[id],
});
});
});
return items;
}
/**
* A section corresponds to one entry in `spec.layouts`. If the Grid has a
* `display.title`, it renders with a collapsible header; otherwise it is a
* "default" untitled section (visually just the grid).
*/
export interface DashboardSectionV2 {
id: string;
title: string | undefined;
open: boolean;
items: GridItemV2[];
repeatVariable: string | undefined;
}
export function layoutsToSections(
layouts: DashboardtypesLayoutDTO[] | undefined | null,
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
): DashboardSectionV2[] {
if (!layouts?.length) {return [];}
return layouts
.map((layoutEnvelope, idx) => {
if (layoutEnvelope?.kind !== 'Grid') {return null;}
const spec = layoutEnvelope.spec;
const items: GridItemV2[] = (spec?.items ?? [])
.map((item) => {
const id = extractPanelIdFromRef(item.content?.$ref);
if (!id) {return null;}
return {
id,
x: item.x ?? 0,
y: item.y ?? 0,
width: item.width ?? 6,
height: item.height ?? 6,
panel: panels?.[id],
};
})
.filter((it): it is GridItemV2 => it !== null);
const title = spec?.display?.title;
// `open` defaults to true when no collapse field is set (the section
// is expanded by default).
const open = spec?.display?.collapse?.open !== false;
return {
id: `section-${idx}`,
title,
open,
items,
repeatVariable: spec?.repeatVariable,
};
})
.filter((s): s is DashboardSectionV2 => s !== null);
}
export function getPanelKindLabel(panel: DashboardtypesPanelDTO | undefined): string {
const kind = panel?.spec?.plugin?.kind;
if (!kind) {return 'unknown';}
return kind.replace(/^signoz\//, '');
}

View File

@@ -0,0 +1,47 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
import Spinner from 'components/Spinner';
import DashboardContainerV2 from 'container/DashboardContainerV2';
function DashboardPageV2(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
id: dashboardId,
});
const dashboard = data?.data;
const name = dashboard?.spec?.display?.name;
useEffect(() => {
if (name) {
document.title = name;
}
}, [name]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
}
if (isError) {
return (
<div style={{ padding: 24 }}>
<Typography.Title>Failed to load dashboard</Typography.Title>
<Typography.Text>{(error as Error)?.message}</Typography.Text>
</div>
);
}
return (
<DashboardContainerV2
dashboard={dashboard}
onRefetch={(): void => {
refetch();
}}
/>
);
}
export default DashboardPageV2;

View File

@@ -0,0 +1,3 @@
import DashboardPageV2 from './DashboardPageV2';
export default DashboardPageV2;