mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-02 12:50:37 +01:00
Compare commits
21 Commits
main
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cb88b8a4e | ||
|
|
03a98a244f | ||
|
|
208c86a4d6 | ||
|
|
9bf27dba2e | ||
|
|
205e182b87 | ||
|
|
bfa9c54023 | ||
|
|
18cdc79610 | ||
|
|
578037ae95 | ||
|
|
37b5d887b9 | ||
|
|
bbc0c2b720 | ||
|
|
944dc5770d | ||
|
|
e4f656da07 | ||
|
|
3e51b3fca1 | ||
|
|
bf314e7f59 | ||
|
|
deb9807f24 | ||
|
|
41c24ddf98 | ||
|
|
4612382d14 | ||
|
|
ae6b4c4a07 | ||
|
|
317c4e01d9 | ||
|
|
7fe6509467 | ||
|
|
e6fbbbd0ad |
@@ -0,0 +1,228 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
|
||||
import DockerIcon from 'assets/CustomIcons/DockerIcon';
|
||||
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
|
||||
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
|
||||
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
|
||||
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
|
||||
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
|
||||
import NginxIcon from 'assets/CustomIcons/NginxIcon';
|
||||
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
|
||||
import RedisIcon from 'assets/CustomIcons/RedisIcon';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
ConciergeBell,
|
||||
DraftingCompass,
|
||||
Drill,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { DashboardTemplate } from 'types/api/dashboard/getAll';
|
||||
|
||||
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
|
||||
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
|
||||
|
||||
import { filterTemplates } from '../utils';
|
||||
|
||||
import './DashboardTemplatesModal.styles.scss';
|
||||
|
||||
const templatesList: DashboardTemplate[] = [
|
||||
{
|
||||
name: 'Blank dashboard',
|
||||
icon: <Drill />,
|
||||
id: 'blank',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Alert Manager',
|
||||
icon: <ConciergeBell />,
|
||||
id: 'alertManager',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Apache',
|
||||
icon: <ApacheIcon />,
|
||||
id: 'apache',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Docker',
|
||||
icon: <DockerIcon />,
|
||||
id: 'docker',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Elasticsearch',
|
||||
icon: <ElasticSearchIcon />,
|
||||
id: 'elasticSearch',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MongoDB',
|
||||
icon: <MongoDBIcon />,
|
||||
id: 'mongoDB',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Heroku',
|
||||
icon: <HerokuIcon />,
|
||||
id: 'heroku',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Nginx',
|
||||
icon: <NginxIcon />,
|
||||
id: 'nginx',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Kubernetes',
|
||||
icon: <KubernetesIcon />,
|
||||
id: 'kubernetes',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MySQL',
|
||||
icon: <MySQLIcon />,
|
||||
id: 'mySQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'PostgreSQL',
|
||||
icon: <PostgreSQLIcon />,
|
||||
id: 'postgreSQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Redis',
|
||||
icon: <RedisIcon />,
|
||||
id: 'redis',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: redisTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'AWS',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
id: 'aws',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
];
|
||||
|
||||
interface DashboardTemplatesContentProps {
|
||||
onCreateNewDashboard: () => void;
|
||||
/** When provided, renders the modal-style header with a close affordance. Omitted for inline use. */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
// The template gallery (search + list + preview + create), extracted from the
|
||||
// modal so it can be embedded inline (e.g. the V2 new-dashboard modal's template
|
||||
// tab) as well as inside DashboardTemplatesModal. Styles live under the global
|
||||
// `.new-dashboard-templates-modal` scope, so inline callers wrap it in that class.
|
||||
export default function DashboardTemplatesContent({
|
||||
onCreateNewDashboard,
|
||||
onCancel,
|
||||
}: DashboardTemplatesContentProps): JSX.Element {
|
||||
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
|
||||
templatesList[0],
|
||||
);
|
||||
|
||||
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
|
||||
|
||||
const handleDashboardTemplateSearch = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const searchText = event.target.value;
|
||||
const filteredTemplates = filterTemplates(searchText, templatesList);
|
||||
setDashboardTemplates(filteredTemplates);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="new-dashboard-templates-content-container">
|
||||
{onCancel && (
|
||||
<div className="new-dashboard-templates-content-header">
|
||||
<Typography.Text>New Dashboard</Typography.Text>
|
||||
|
||||
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="new-dashboard-templates-content">
|
||||
<div className="new-dashboard-templates-list">
|
||||
<Input
|
||||
className="new-dashboard-templates-search"
|
||||
placeholder="🔍 Search..."
|
||||
onChange={handleDashboardTemplateSearch}
|
||||
/>
|
||||
|
||||
<div className="templates-list">
|
||||
{dashboardTemplates.map((template) => (
|
||||
<div
|
||||
className={cx(
|
||||
'template-list-item',
|
||||
selectedDashboardTemplate.id === template.id ? 'active' : '',
|
||||
)}
|
||||
key={template.name}
|
||||
onClick={() => setSelectedDashboardTemplate(template)}
|
||||
>
|
||||
<div className="template-icon">{template.icon}</div>
|
||||
<div className="template-name">{template.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-template-preview">
|
||||
<div className="template-preview-header">
|
||||
<div className="template-preview-title">
|
||||
<div className="template-preview-icon">
|
||||
{selectedDashboardTemplate.icon}
|
||||
</div>
|
||||
|
||||
<div className="template-info">
|
||||
<div className="template-name">{selectedDashboardTemplate.name}</div>
|
||||
|
||||
<div className="template-description">
|
||||
{selectedDashboardTemplate.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="create-dashboard-btn">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={onCreateNewDashboard}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-preview-image">
|
||||
<img
|
||||
src={selectedDashboardTemplate.previewImage}
|
||||
alt={`${selectedDashboardTemplate.name}-preview`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
|
||||
import DockerIcon from 'assets/CustomIcons/DockerIcon';
|
||||
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
|
||||
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
|
||||
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
|
||||
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
|
||||
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
|
||||
import NginxIcon from 'assets/CustomIcons/NginxIcon';
|
||||
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
|
||||
import RedisIcon from 'assets/CustomIcons/RedisIcon';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
ConciergeBell,
|
||||
DraftingCompass,
|
||||
Drill,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { DashboardTemplate } from 'types/api/dashboard/getAll';
|
||||
import { Modal } from 'antd';
|
||||
|
||||
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
|
||||
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
|
||||
|
||||
import { filterTemplates } from '../utils';
|
||||
import DashboardTemplatesContent from './DashboardTemplatesContent';
|
||||
|
||||
import './DashboardTemplatesModal.styles.scss';
|
||||
|
||||
const templatesList: DashboardTemplate[] = [
|
||||
{
|
||||
name: 'Blank dashboard',
|
||||
icon: <Drill />,
|
||||
id: 'blank',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Alert Manager',
|
||||
icon: <ConciergeBell />,
|
||||
id: 'alertManager',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Apache',
|
||||
icon: <ApacheIcon />,
|
||||
id: 'apache',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Docker',
|
||||
icon: <DockerIcon />,
|
||||
id: 'docker',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Elasticsearch',
|
||||
icon: <ElasticSearchIcon />,
|
||||
id: 'elasticSearch',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MongoDB',
|
||||
icon: <MongoDBIcon />,
|
||||
id: 'mongoDB',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Heroku',
|
||||
icon: <HerokuIcon />,
|
||||
id: 'heroku',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Nginx',
|
||||
icon: <NginxIcon />,
|
||||
id: 'nginx',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Kubernetes',
|
||||
icon: <KubernetesIcon />,
|
||||
id: 'kubernetes',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MySQL',
|
||||
icon: <MySQLIcon />,
|
||||
id: 'mySQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'PostgreSQL',
|
||||
icon: <PostgreSQLIcon />,
|
||||
id: 'postgreSQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Redis',
|
||||
icon: <RedisIcon />,
|
||||
id: 'redis',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: redisTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'AWS',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
id: 'aws',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
];
|
||||
|
||||
interface DashboardTemplatesModalProps {
|
||||
showNewDashboardTemplatesModal: boolean;
|
||||
onCreateNewDashboard: () => void;
|
||||
@@ -135,20 +15,6 @@ export default function DashboardTemplatesModal({
|
||||
onCreateNewDashboard,
|
||||
onCancel,
|
||||
}: DashboardTemplatesModalProps): JSX.Element {
|
||||
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
|
||||
templatesList[0],
|
||||
);
|
||||
|
||||
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
|
||||
|
||||
const handleDashboardTemplateSearch = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const searchText = event.target.value;
|
||||
const filteredTemplates = filterTemplates(searchText, templatesList);
|
||||
setDashboardTemplates(filteredTemplates);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
wrapClassName="new-dashboard-templates-modal"
|
||||
@@ -159,75 +25,10 @@ export default function DashboardTemplatesModal({
|
||||
destroyOnClose
|
||||
width="60vw"
|
||||
>
|
||||
<div className="new-dashboard-templates-content-container">
|
||||
<div className="new-dashboard-templates-content-header">
|
||||
<Typography.Text>New Dashboard</Typography.Text>
|
||||
|
||||
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-templates-content">
|
||||
<div className="new-dashboard-templates-list">
|
||||
<Input
|
||||
className="new-dashboard-templates-search"
|
||||
placeholder="🔍 Search..."
|
||||
onChange={handleDashboardTemplateSearch}
|
||||
/>
|
||||
|
||||
<div className="templates-list">
|
||||
{dashboardTemplates.map((template) => (
|
||||
<div
|
||||
className={cx(
|
||||
'template-list-item',
|
||||
selectedDashboardTemplate.id === template.id ? 'active' : '',
|
||||
)}
|
||||
key={template.name}
|
||||
onClick={() => setSelectedDashboardTemplate(template)}
|
||||
>
|
||||
<div className="template-icon">{template.icon}</div>
|
||||
<div className="template-name">{template.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-template-preview">
|
||||
<div className="template-preview-header">
|
||||
<div className="template-preview-title">
|
||||
<div className="template-preview-icon">
|
||||
{selectedDashboardTemplate.icon}
|
||||
</div>
|
||||
|
||||
<div className="template-info">
|
||||
<div className="template-name">{selectedDashboardTemplate.name}</div>
|
||||
|
||||
<div className="template-description">
|
||||
{selectedDashboardTemplate.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="create-dashboard-btn">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={onCreateNewDashboard}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-preview-image">
|
||||
<img
|
||||
src={selectedDashboardTemplate.previewImage}
|
||||
alt={`${selectedDashboardTemplate.name}-preview`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardTemplatesContent
|
||||
onCreateNewDashboard={onCreateNewDashboard}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,34 @@
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.publicLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lockButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lockButton:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import {
|
||||
Check,
|
||||
Globe,
|
||||
LockKeyhole,
|
||||
LockKeyholeOpen,
|
||||
SolidInfoCircle,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -7,6 +14,7 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useVisibleTagCount } from './useVisibleTagCount';
|
||||
@@ -18,7 +26,13 @@ interface DashboardInfoProps {
|
||||
tags: string[];
|
||||
description: string;
|
||||
isPublicDashboard: boolean;
|
||||
/** Absolute URL of the public dashboard page; opened when the globe is clicked. */
|
||||
publicUrl: string;
|
||||
isDashboardLocked: boolean;
|
||||
/** Whether to render the lock toggle at all (hidden for never-locked dashboards). */
|
||||
showLockToggle: boolean;
|
||||
/** When provided, the lock icon toggles lock/unlock (author/admin only). */
|
||||
onToggleLock?: () => void;
|
||||
isEditing: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
@@ -33,7 +47,10 @@ function DashboardInfo({
|
||||
tags,
|
||||
description,
|
||||
isPublicDashboard,
|
||||
publicUrl,
|
||||
isDashboardLocked,
|
||||
showLockToggle,
|
||||
onToggleLock,
|
||||
isEditing,
|
||||
draft,
|
||||
onDraftChange,
|
||||
@@ -51,6 +68,17 @@ function DashboardInfo({
|
||||
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
|
||||
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
|
||||
|
||||
let lockTooltip: string;
|
||||
if (onToggleLock) {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'Locked — click to unlock'
|
||||
: 'Unlocked — click to lock';
|
||||
} else {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'This dashboard is locked'
|
||||
: 'This dashboard is unlocked';
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -101,7 +129,7 @@ function DashboardInfo({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title}>
|
||||
<TooltipSimple title={title} disableHoverableContent>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
@@ -115,7 +143,7 @@ function DashboardInfo({
|
||||
)}
|
||||
|
||||
{hasDescription && (
|
||||
<TooltipSimple title={description}>
|
||||
<TooltipSimple title={description} disableHoverableContent>
|
||||
<SolidInfoCircle
|
||||
className={styles.descriptionIcon}
|
||||
size={14}
|
||||
@@ -125,14 +153,38 @@ function DashboardInfo({
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} />
|
||||
<TooltipSimple
|
||||
title="This dashboard is publicly accessible. Click to open the public page."
|
||||
disableHoverableContent
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.publicLink}
|
||||
aria-label="Open public dashboard"
|
||||
data-testid="dashboard-public-link"
|
||||
onClick={(): void => openInNewTab(publicUrl)}
|
||||
>
|
||||
<Globe size={14} />
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
{showLockToggle && (
|
||||
<TooltipSimple title={lockTooltip} disableHoverableContent>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.lockButton}
|
||||
aria-label={isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard'}
|
||||
data-testid="dashboard-lock"
|
||||
disabled={!onToggleLock}
|
||||
onClick={onToggleLock}
|
||||
>
|
||||
{isDashboardLocked ? (
|
||||
<LockKeyhole size={14} />
|
||||
) : (
|
||||
<LockKeyholeOpen size={14} />
|
||||
)}
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
@@ -145,14 +197,14 @@ function DashboardInfo({
|
||||
data-testid="dashboard-tags"
|
||||
>
|
||||
{visibleTags.map((tag) => (
|
||||
<Badge key={tag} color="warning" variant="outline">
|
||||
<Badge key={tag} color="sienna" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<TooltipSimple title={remainingTags.join(', ')}>
|
||||
<Badge
|
||||
color="warning"
|
||||
color="sienna"
|
||||
variant="outline"
|
||||
data-testid="dashboard-tags-overflow"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -15,9 +15,12 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
@@ -36,7 +39,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { dashboard, handle, refetch } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
const isDashboardLocked = !!dashboard.locked;
|
||||
|
||||
// Session-local lock state: the toggle appears once locked and persists for the page.
|
||||
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
|
||||
const [showLockToggle, setShowLockToggle] = useState(!!dashboard.locked);
|
||||
useEffect(() => {
|
||||
setIsDashboardLocked(!!dashboard.locked);
|
||||
setShowLockToggle(!!dashboard.locked);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.id]);
|
||||
|
||||
const title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
@@ -58,20 +69,36 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
|
||||
// Author/admin can lock-unlock (mirrors the Actions menu gate); integration-owned
|
||||
// dashboards are never toggleable.
|
||||
const canToggleLock =
|
||||
(isAuthor || user.role === USER_ROLES.ADMIN) &&
|
||||
dashboard.createdBy !== 'integration';
|
||||
|
||||
// Public-sharing meta (deduped react-query read); drives the header globe.
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
|
||||
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
|
||||
|
||||
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const next = !isDashboardLocked;
|
||||
setIsDashboardLocked(next);
|
||||
if (next) {
|
||||
setShowLockToggle(true);
|
||||
}
|
||||
try {
|
||||
if (isDashboardLocked) {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
} else {
|
||||
if (next) {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
} else {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
}
|
||||
refetch();
|
||||
} catch (error) {
|
||||
setIsDashboardLocked(!next);
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [id, isDashboardLocked, refetch, showErrorModal]);
|
||||
@@ -119,8 +146,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={false}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={canToggleLock ? handleLockDashboardToggle : undefined}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
@@ -26,3 +26,19 @@
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.renameFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Wrap so the tooltip has a hoverable target even when the button is disabled. */
|
||||
.menuItemWrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menuItemWrap button:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation } from 'react-query';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Popover } from 'antd';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
@@ -8,18 +9,28 @@ import {
|
||||
Expand,
|
||||
EllipsisVertical,
|
||||
Link2,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
SquareArrowOutUpRight,
|
||||
} from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
cloneDashboardV2,
|
||||
invalidateListDashboardsForUserV2,
|
||||
lockDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import DeleteActionItem from './DeleteActionItem';
|
||||
import RenameDashboardModal from './RenameDashboardModal';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
interface Props {
|
||||
@@ -42,6 +53,7 @@ function ActionsPopover({
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
|
||||
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
|
||||
// by the caller; open the copy so it can be tweaked right away.
|
||||
@@ -58,85 +70,159 @@ function ActionsPopover({
|
||||
},
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAppContext();
|
||||
const isAuthor = user?.email === createdBy;
|
||||
// Author/admin can lock-unlock (mirrors the detail-page gate); integration-owned
|
||||
// dashboards are never toggleable.
|
||||
const canToggleLock =
|
||||
(isAuthor || user.role === USER_ROLES.ADMIN) && createdBy !== 'integration';
|
||||
|
||||
const { mutate: runLockToggle, isLoading: isTogglingLock } = useMutation({
|
||||
mutationFn: () =>
|
||||
isLocked
|
||||
? unlockDashboardV2({ id: dashboardId })
|
||||
: lockDashboardV2({ id: dashboardId }),
|
||||
onSuccess: async () => {
|
||||
toast.success(isLocked ? 'Dashboard unlocked' : 'Dashboard locked');
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<div className={styles.content}>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Expand size={14} />}
|
||||
onClick={onView}
|
||||
testId="dashboard-action-view"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<SquareArrowOutUpRight size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
openInNewTab(link);
|
||||
}}
|
||||
testId="dashboard-action-open-new-tab"
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Link2 size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(getAbsoluteUrl(link));
|
||||
}}
|
||||
testId="dashboard-action-copy-link"
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Copy size={14} />}
|
||||
loading={isCloning}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
runClone();
|
||||
}}
|
||||
testId="dashboard-action-duplicate"
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
<DeleteActionItem
|
||||
dashboardId={dashboardId}
|
||||
dashboardName={dashboardName}
|
||||
createdBy={createdBy}
|
||||
isLocked={isLocked}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
rootClassName="dashboardActionsPopover"
|
||||
trigger="click"
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
testId="dashboard-action-icon"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
<>
|
||||
<Popover
|
||||
content={
|
||||
// Stop clicks inside the menu (incl. disabled items) from bubbling to the
|
||||
// row's onClick, which would navigate to the dashboard.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- wrapper only guards propagation, not an interactive control
|
||||
<div className={styles.content} onClick={(e): void => e.stopPropagation()}>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Expand size={14} />}
|
||||
onClick={onView}
|
||||
testId="dashboard-action-view"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<SquareArrowOutUpRight size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
openInNewTab(link);
|
||||
}}
|
||||
testId="dashboard-action-open-new-tab"
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Link2 size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(getAbsoluteUrl(link));
|
||||
}}
|
||||
testId="dashboard-action-copy-link"
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={
|
||||
isLocked ? 'This dashboard is locked, so it cannot be renamed.' : ''
|
||||
}
|
||||
>
|
||||
<span className={styles.menuItemWrap}>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<PenLine size={14} />}
|
||||
disabled={isLocked}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isLocked) {
|
||||
setIsRenameOpen(true);
|
||||
}
|
||||
}}
|
||||
testId="dashboard-action-rename"
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Copy size={14} />}
|
||||
loading={isCloning}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
runClone();
|
||||
}}
|
||||
testId="dashboard-action-duplicate"
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
{canToggleLock && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<LockKeyhole size={14} />}
|
||||
loading={isTogglingLock}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
runLockToggle();
|
||||
}}
|
||||
testId="dashboard-action-lock"
|
||||
>
|
||||
{isLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
)}
|
||||
<DeleteActionItem
|
||||
dashboardId={dashboardId}
|
||||
dashboardName={dashboardName}
|
||||
createdBy={createdBy}
|
||||
isLocked={isLocked}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
rootClassName="dashboardActionsPopover"
|
||||
trigger="click"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</Button>
|
||||
</Popover>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
testId="dashboard-action-icon"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</Button>
|
||||
</Popover>
|
||||
<RenameDashboardModal
|
||||
open={isRenameOpen}
|
||||
dashboardId={dashboardId}
|
||||
currentName={dashboardName}
|
||||
onClose={(): void => setIsRenameOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import deleteDashboard from 'api/v1/dashboards/id/delete';
|
||||
import { invalidateListDashboardsV2 } from 'api/generated/services/dashboard';
|
||||
import { invalidateListDashboardsForUserV2 } from 'api/generated/services/dashboard';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -44,7 +44,7 @@ function DeleteActionItem({
|
||||
toast.success(
|
||||
t('dashboard:delete_dashboard_success', { name: dashboardName }),
|
||||
);
|
||||
await invalidateListDashboardsV2(queryClient);
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
@@ -101,23 +101,25 @@ function DeleteActionItem({
|
||||
<>
|
||||
<Divider />
|
||||
<Tooltip placement="left" title={tooltip}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className={styles.menuItem}
|
||||
prefix={<Trash2 size={14} />}
|
||||
disabled={isDisabled}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isDisabled) {
|
||||
openConfirm();
|
||||
}
|
||||
}}
|
||||
testId="dashboard-action-delete"
|
||||
>
|
||||
Delete Dashboard
|
||||
</Button>
|
||||
<span className={styles.menuItemWrap}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className={styles.menuItem}
|
||||
prefix={<Trash2 size={14} />}
|
||||
disabled={isDisabled}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isDisabled) {
|
||||
openConfirm();
|
||||
}
|
||||
}}
|
||||
testId="dashboard-action-delete"
|
||||
>
|
||||
Delete Dashboard
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{contextHolder}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateListDashboardsForUserV2,
|
||||
// eslint-disable-next-line no-restricted-imports -- list rename targets another dashboard by id; useOptimisticPatch is bound to the open dashboard's store/cache.
|
||||
patchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
dashboardId: string;
|
||||
currentName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Renames a dashboard from the list via a `/spec/display/name` patch, then refreshes the list. */
|
||||
function RenameDashboardModal({
|
||||
open,
|
||||
dashboardId,
|
||||
currentName,
|
||||
onClose,
|
||||
}: Props): JSX.Element {
|
||||
const [name, setName] = useState(currentName);
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Reset the field to the current name whenever the modal (re)opens.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(currentName);
|
||||
}
|
||||
}, [open, currentName]);
|
||||
|
||||
const { mutate: runRename, isLoading } = useMutation({
|
||||
mutationFn: () => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/display/name',
|
||||
value: name.trim(),
|
||||
},
|
||||
];
|
||||
return patchDashboardV2({ id: dashboardId }, ops);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('Dashboard renamed');
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
onClose();
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
});
|
||||
|
||||
const trimmed = name.trim();
|
||||
const canSave = trimmed.length > 0 && trimmed !== currentName && !isLoading;
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="Rename dashboard"
|
||||
open={open}
|
||||
width="narrow"
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
<div className={styles.renameFooter}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="rename-dashboard-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={!canSave}
|
||||
loading={isLoading}
|
||||
onClick={(): void => runRename()}
|
||||
testId="rename-dashboard-submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
placeholder="Dashboard name"
|
||||
testId="rename-dashboard-input"
|
||||
onChange={(e): void => setName(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' && canSave) {
|
||||
runRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameDashboardModal;
|
||||
@@ -52,6 +52,7 @@
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tagsWithActions {
|
||||
@@ -62,6 +63,14 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.pinBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -96,6 +105,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Pinned rows show the filled pin by default; the unpin action only appears when
|
||||
the pin button itself is hovered. */
|
||||
.pinnedIcon {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.unpinIcon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pinBtn:hover .pinnedIcon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pinBtn:hover .unpinIcon {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { CalendarClock, Pin, PinOff } from '@signozhq/icons';
|
||||
import { CalendarClock, LockKeyhole, Pin, PinOff } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
@@ -79,7 +79,7 @@ function DashboardRow({
|
||||
<div className={styles.titleBlock}>
|
||||
<Tooltip
|
||||
title={name.length > 50 ? name : ''}
|
||||
placement="left"
|
||||
placement="bottom"
|
||||
overlayClassName="titleTooltipOverlay"
|
||||
>
|
||||
<div className={styles.titleLink} onClick={onClickHandler}>
|
||||
@@ -111,17 +111,36 @@ function DashboardRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.pinBtn, { [styles.pinBtnOn]: isPinned })}
|
||||
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
|
||||
{isLocked && (
|
||||
<Tooltip title="This dashboard is locked" placement="top">
|
||||
<span className={styles.lockIcon} data-testid={`dashboard-lock-${index}`}>
|
||||
<LockKeyhole size={14} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
title={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
|
||||
data-testid={`dashboard-pin-${index}`}
|
||||
disabled={isUpdating}
|
||||
onClick={onTogglePin}
|
||||
placement="top"
|
||||
>
|
||||
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.pinBtn, { [styles.pinBtnOn]: isPinned })}
|
||||
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
|
||||
data-testid={`dashboard-pin-${index}`}
|
||||
disabled={isUpdating}
|
||||
onClick={onTogglePin}
|
||||
>
|
||||
{isPinned ? (
|
||||
<>
|
||||
<Pin size={14} className={styles.pinnedIcon} />
|
||||
<PinOff size={14} className={styles.unpinIcon} />
|
||||
</>
|
||||
) : (
|
||||
<Pin size={14} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{canAct && (
|
||||
<ActionsPopover
|
||||
|
||||
@@ -102,8 +102,8 @@ function FilterZone({
|
||||
/>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<X size={12} />}
|
||||
onClick={onClearAll}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ArrowDown, ArrowUp, Check, HdmiPort } from '@signozhq/icons';
|
||||
import { ArrowDown, ArrowUp, Check, Columns3 } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
@@ -191,7 +191,7 @@ function ListHeader({
|
||||
aria-label="Columns"
|
||||
testId="configure-columns-trigger"
|
||||
>
|
||||
<HdmiPort size={14} />
|
||||
<Columns3 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
|
||||
@@ -58,6 +58,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
variables: [],
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
|
||||
@@ -20,7 +20,11 @@ import JsonEditor from './JsonEditor';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
function ImportJsonPanel(): JSX.Element {
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
@@ -59,6 +63,7 @@ function ImportJsonPanel(): JSX.Element {
|
||||
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
|
||||
const payload = normalizeToPostable(parsed);
|
||||
const response = await createDashboardV2(payload);
|
||||
onClose();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
|
||||
@@ -43,12 +43,12 @@ function NewDashboardModal({ open, onClose }: Props): JSX.Element {
|
||||
{
|
||||
key: 'template',
|
||||
label: 'From a template',
|
||||
children: <TemplatesPanel />,
|
||||
children: <TemplatesPanel onClose={onClose} />,
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
label: 'Import JSON',
|
||||
children: <ImportJsonPanel />,
|
||||
children: <ImportJsonPanel onClose={onClose} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,136 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ExternalLink, LoaderCircle } from '@signozhq/icons';
|
||||
import { AxiosError } from 'axios';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
|
||||
import DashboardTemplatesContent from 'container/ListOfDashboard/DashboardTemplates/DashboardTemplatesContent';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { normalizeToPostable } from './importUtils';
|
||||
import JsonEditor from './JsonEditor';
|
||||
import { useDashboardTemplates } from './templatesData';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
// Browse the template gallery (mock data until the API lands): pick one on the
|
||||
// left to preview its JSON on the right, then use it or open the docs.
|
||||
function TemplatesPanel(): JSX.Element {
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Until the templates BE API lands, the V2 "From a template" tab embeds the V1
|
||||
// template gallery inline (no modal-in-modal). The V1 templates are placeholders,
|
||||
// so the action creates a blank dashboard.
|
||||
function TemplatesPanel({ onClose }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { data, isLoading } = useDashboardTemplates(true);
|
||||
const templates = data ?? [];
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const selected = templates.find((t) => t.id === selectedId) ?? templates[0];
|
||||
|
||||
const handleUse = async (): Promise<void> => {
|
||||
if (!selected) {
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (creating) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCreating(true);
|
||||
logEvent('Dashboard List: Use template clicked', { template: selected.id });
|
||||
const parsed = JSON.parse(selected.json) as Record<string, unknown>;
|
||||
const created = await createDashboardV2(normalizeToPostable(parsed));
|
||||
logEvent('Dashboard List: Use template clicked', {});
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: 'Sample Dashboard' },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error(
|
||||
(e as AxiosError).toString() || 'Failed to create from template',
|
||||
);
|
||||
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.loading}>
|
||||
<LoaderCircle size={18} className={styles.spinner} />
|
||||
<span>Loading templates…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.templatesLayout}>
|
||||
<div className={styles.templatesList}>
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
className={cx(styles.templateItem, {
|
||||
[styles.templateItemActive]: selected?.id === template.id,
|
||||
})}
|
||||
data-testid={`template-${template.id}`}
|
||||
onClick={(): void => setSelectedId(template.id)}
|
||||
>
|
||||
<span className={styles.templateName}>{template.name}</span>
|
||||
<span className={styles.templateCat}>{template.category}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className={styles.templatesPreview}>
|
||||
<div className={styles.previewHead}>
|
||||
<div>
|
||||
<Typography.Text className={styles.cardName}>
|
||||
{selected.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.cardDesc}>
|
||||
{selected.description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
suffix={<ExternalLink size={13} />}
|
||||
onClick={(): void => openInNewTab(selected.href)}
|
||||
testId="template-docs"
|
||||
>
|
||||
Docs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<JsonEditor value={selected.json} readOnly height="240px" />
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={creating}
|
||||
testId="use-template"
|
||||
onClick={(): void => {
|
||||
void handleUse();
|
||||
}}
|
||||
>
|
||||
Use template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.requestRow}>
|
||||
<RequestDashboardBtn />
|
||||
<div className="new-dashboard-templates-modal">
|
||||
<DashboardTemplatesContent
|
||||
onCreateNewDashboard={(): void => {
|
||||
void handleCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useQuery, type UseQueryResult } from 'react-query';
|
||||
|
||||
export interface DashboardTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
href: string;
|
||||
// Importable dashboard definition previewed in the gallery (mock for now).
|
||||
json: string;
|
||||
}
|
||||
|
||||
// A representative dashboard definition for a template — mock until the API
|
||||
// returns real ones.
|
||||
const buildTemplateJson = (
|
||||
name: string,
|
||||
description: string,
|
||||
category: string,
|
||||
): string =>
|
||||
JSON.stringify(
|
||||
{
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: [{ key: 'category', value: category.toLowerCase() }],
|
||||
spec: {
|
||||
display: { name, description },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
// Mock catalogue until the templates API lands. Mirrors the public gallery at
|
||||
// https://signoz.io/docs/dashboards/dashboard-templates/overview/
|
||||
const BASE_TEMPLATES: Omit<DashboardTemplate, 'json'>[] = [
|
||||
{
|
||||
id: 'apm',
|
||||
name: 'APM Metrics',
|
||||
description: 'Latency, error rate, and throughput across your services.',
|
||||
category: 'APM',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/apm/',
|
||||
},
|
||||
{
|
||||
id: 'hostmetrics',
|
||||
name: 'Host Metrics',
|
||||
description: 'CPU, memory, disk, and network for your hosts.',
|
||||
category: 'Infra',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/hostmetrics/',
|
||||
},
|
||||
{
|
||||
id: 'kubernetes',
|
||||
name: 'Kubernetes Pod Metrics',
|
||||
description: 'Pod, node, and container health for your clusters.',
|
||||
category: 'Infra',
|
||||
href:
|
||||
'https://signoz.io/docs/dashboards/dashboard-templates/kubernetes-pod-metrics-detailed/',
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
name: 'PostgreSQL',
|
||||
description: 'Connections, throughput, and query performance.',
|
||||
category: 'Databases',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/postgresql/',
|
||||
},
|
||||
{
|
||||
id: 'redis',
|
||||
name: 'Redis',
|
||||
description: 'Memory, commands, and hit-rate for Redis instances.',
|
||||
category: 'Databases',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/redis/',
|
||||
},
|
||||
{
|
||||
id: 'nginx',
|
||||
name: 'NGINX',
|
||||
description: 'Request rate, connections, and error responses.',
|
||||
category: 'Web servers',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/nginx/',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_TEMPLATES: DashboardTemplate[] = BASE_TEMPLATES.map((t) => ({
|
||||
...t,
|
||||
json: buildTemplateJson(t.name, t.description, t.category),
|
||||
}));
|
||||
|
||||
// TODO(@AshwinBhatkal): replace with the real templates API when available.
|
||||
// The small delay simulates the network round-trip so the loading state is
|
||||
// exercised (a real API call won't resolve instantly).
|
||||
const fetchDashboardTemplates = (): Promise<DashboardTemplate[]> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve(MOCK_TEMPLATES), 600);
|
||||
});
|
||||
|
||||
export function useDashboardTemplates(
|
||||
enabled: boolean,
|
||||
): UseQueryResult<DashboardTemplate[]> {
|
||||
return useQuery({
|
||||
queryKey: ['dashboard-templates'],
|
||||
queryFn: fetchDashboardTemplates,
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -54,5 +54,17 @@
|
||||
}
|
||||
|
||||
.submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.cmdHint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { CornerDownLeft, Search } from '@signozhq/icons';
|
||||
import { ChevronUp, Command, CornerDownLeft, Search } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
|
||||
import {
|
||||
applyKeySuggestion,
|
||||
@@ -40,6 +41,8 @@ function SearchBar({
|
||||
// than picking a suggestion (arrow keys engage selection).
|
||||
const [highlighted, setHighlighted] = useState(-1);
|
||||
|
||||
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
|
||||
|
||||
const active = useMemo(() => getActiveKeyToken(value), [value]);
|
||||
const suggestions = useMemo(
|
||||
() => (active ? matchKeys(suggestionKeys, active.token) : []),
|
||||
@@ -55,6 +58,11 @@ function SearchBar({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
return;
|
||||
}
|
||||
if (showSuggestions && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlighted((h) => Math.min(h + 1, suggestions.length - 1));
|
||||
@@ -90,7 +98,7 @@ function SearchBar({
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
size="sm"
|
||||
className={styles.submit}
|
||||
aria-label="Run search"
|
||||
testId="dashboards-list-search-submit"
|
||||
@@ -100,7 +108,15 @@ function SearchBar({
|
||||
}}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
|
||||
Run query
|
||||
<span className={styles.cmdHint}>
|
||||
{isMac ? (
|
||||
<Command size={12} color={Color.BG_VANILLA_400} />
|
||||
) : (
|
||||
<ChevronUp size={12} color={Color.BG_VANILLA_400} />
|
||||
)}
|
||||
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
value={value}
|
||||
|
||||
Reference in New Issue
Block a user