Compare commits

...

21 Commits

Author SHA1 Message Date
Ashwin Bhatkal
1cb88b8a4e fix(dashboard-v2): tooltip on the pin button showing the click action
Replace the native title with an antd tooltip that reads 'Pin dashboard' /
'Unpin dashboard' based on the current state.
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
03a98a244f fix(dashboard-v2): header tooltips update when moving between icons
Set disableHoverableContent on the title/description/public/lock tooltips so
leaving a trigger closes its tooltip immediately, letting the next icon's tooltip
open in both directions (was sticking when moving public → lock).
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
208c86a4d6 fix(dashboard-v2): show the lock tooltip on disabled Rename/Delete items
antd tooltips don't fire on disabled buttons; wrap them in a span the tooltip can
attach to so the 'dashboard is locked' reason shows on hover.
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
9bf27dba2e revert(dashboard-v2): restore multi-line clamp for list row title
Single-line truncation broke the row's responsiveness; revert to the 3-line clamp.
Keeps the bottom-anchored tooltip for long names.
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
205e182b87 fix(dashboard-v2): make the filter Clear button prominent
Use the primary (outlined) style for the Clear-filters button so it stands out
once a filter is applied.
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
bfa9c54023 feat(dashboard-v2): run search with a Run query button + Cmd/Ctrl+Enter
Show a 'Run query' affordance with the OS-aware ⌘/Ctrl ⏎ hint in the search bar
(matching the query builder) and run the search on Cmd/Ctrl+Enter.
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
18cdc79610 fix(dashboard-v2): add top margin above the public-dashboard variables hint 2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
578037ae95 feat(dashboard-v2): session-local lock toggle in the header
The header lock control now reflects a session-local state: it appears once the
dashboard is locked (on load or during the session) and stays as a lock/unlock
toggle for the rest of the page. A dashboard that loads unlocked shows no icon
until it's locked. Clicking flips state optimistically so it no longer depends on
the refetch to update.
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
37b5d887b9 fix(dashboard-v2): single-line row title + bottom-anchored tooltip
Truncate the list row title to a single line with an ellipsis (was a 3-line clamp),
and show the full name in a tooltip anchored to the bottom (auto-flips to top, with
an arrow) instead of the left.
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
bbc0c2b720 fix(dashboard-v2): list row menu — lock casing, disabled Rename tooltip, stop nav on menu click
- Capitalize 'Lock/Unlock Dashboard'.
- Show Rename disabled (not hidden) on locked dashboards with a tooltip explaining
  why (matches Delete).
- Stop clicks inside the actions menu from bubbling to the row's navigate handler
  (disabled items were opening the dashboard).
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
944dc5770d feat(dashboard-v2): add Rename and Lock/Unlock to the list row actions
Rename opens a small dialog that patches /spec/display/name and refreshes the
list (shown for unlocked dashboards). Lock/Unlock toggles via lock/unlockDashboardV2
and refreshes the list, gated to author/admin on non-integration dashboards
(mirroring the detail-page gate).
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
e4f656da07 feat(dashboard-v2): click-to-unlock header lock icon + keep header icons on long names
The header lock icon is now a click-to-unlock control for author/admin (routes to
the existing lock toggle; static for others). Give it flex-shrink:0 (like the
public globe and description icons) so the description/public/lock icons and the
tag overflow stay visible when the title is long and truncates.
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
3e51b3fca1 fix(dashboard-v2): embed the V1 template gallery in the new-dashboard modal
The V2 templates tab rendered a mock gallery. Until the templates BE API lands,
show the existing V1 gallery instead: extract it into DashboardTemplatesContent
(shared by the V1 modal and the V2 tab, no modal-in-modal) and embed it inline in
the V2 'From a template' tab. The V1 templates are placeholders, so the action
creates a blank dashboard and closes the modal. Drops the now-unused mock
templatesData.
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
bf314e7f59 feat(dashboard-v2): open the public dashboard from the header globe
The header globe now reflects the real public state (via usePublicDashboardMeta,
a deduped read) and is clickable — it opens the public dashboard page in a new
tab, with the tooltip updated to say so alongside the existing text.
2026-07-02 16:49:32 +05:30
Ashwin Bhatkal
deb9807f24 fix(dashboard-v2): close the new-dashboard modal after creating
Blank/Import/Template create flows navigated to the new dashboard but left the
modal mounted, so it lingered over the detail page. Call onClose() on success
(Import/Template now take the onClose prop the modal already passes to Blank).
2026-07-02 16:48:07 +05:30
Ashwin Bhatkal
41c24ddf98 fix(dashboard-v2): use a columns icon for the list columns control
The columns/metadata popover trigger used the HdmiPort icon, which doesn't read as
'columns'. Swap it for the Columns3 icon.
2026-07-02 16:48:07 +05:30
Ashwin Bhatkal
4612382d14 fix(dashboard-v2): use sienna tags in the dashboard header
The create modal and settings tag inputs already render sienna chips (matching the
list rows); the detail-page header still showed amber/warning badges. Switch them
to sienna for consistency across create, configure and display.
2026-07-02 16:48:07 +05:30
Ashwin Bhatkal
ae6b4c4a07 fix(dashboard-v2): break long unbroken dashboard names in the list
The list title clamps to 3 lines but a long unbroken string could still overflow
the row horizontally; add overflow-wrap so it wraps within the clamp. (The detail
header already single-line truncates with a tooltip.)
2026-07-02 16:48:07 +05:30
Ashwin Bhatkal
317c4e01d9 fix(dashboard-v2): show pinned icon (not unpin) until the pin is hovered
A pinned row rendered the PinOff ("unpin") icon at rest, so it read as an action
rather than a state. Show the filled Pin by default and reveal PinOff only on
hover of the pin button.
2026-07-02 16:48:07 +05:30
Ashwin Bhatkal
7fe6509467 feat(dashboard-v2): show a lock icon on locked dashboards in the list
The list rows already carry `locked`; surface it with a LockKeyhole icon (with a
tooltip) next to the row actions, mirroring the detail-page header.
2026-07-02 16:48:07 +05:30
Ashwin Bhatkal
e6fbbbd0ad fix(dashboard-v2): refresh the list after deleting a dashboard
Delete invalidated invalidateListDashboardsV2, but the list renders from
useListDashboardsForUserV2 (as does pin/unpin), so the deleted row lingered until
a manual reload. Invalidate the for-user list instead.
2026-07-02 16:48:07 +05:30
21 changed files with 815 additions and 549 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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"
>

View File

@@ -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}

View File

@@ -2,6 +2,7 @@
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
padding-top: 2px;
color: var(--l3-foreground);
}

View File

@@ -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;
}

View File

@@ -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)}
/>
</>
);
}

View File

@@ -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}
</>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -102,8 +102,8 @@ function FilterZone({
/>
{!isEmpty && (
<Button
variant="ghost"
color="secondary"
variant="outlined"
color="primary"
size="sm"
prefix={<X size={12} />}
onClick={onClearAll}

View File

@@ -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>

View File

@@ -58,6 +58,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
variables: [],
},
});
onClose();
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
);

View File

@@ -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 }),
);

View File

@@ -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} />,
},
]}
/>

View File

@@ -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>
);

View File

@@ -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,
});
}

View File

@@ -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);
}

View File

@@ -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}