mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-03 21:30:34 +01:00
Compare commits
3 Commits
feat/trace
...
fix/panel-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e5b68e43 | ||
|
|
093eda7deb | ||
|
|
77f7a2cceb |
@@ -52,8 +52,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
createPanel,
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
@@ -153,6 +158,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
defaultLayoutIndex={targetLayoutIndex}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,69 @@
|
||||
.panelTypeSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grid {
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.typeButton {
|
||||
.panelTypeCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
padding: 12px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--l1-border);
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
border-radius: 4px;
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
border-color 180ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
background-color: var(--l2-background-hover);
|
||||
border-color: var(--bg-robin-400);
|
||||
}
|
||||
&:active {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.panelTypeCardSelected {
|
||||
border-color: var(--bg-robin-400);
|
||||
background-color: var(--l2-background-hover);
|
||||
box-shadow: inset 0 0 0 1px var(--bg-robin-400);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerPicker {
|
||||
// Take all the width left over by the (natural-width) confirm button.
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pickerLabel {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,135 @@
|
||||
import { Modal } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useDashboardSections } from '../../../hooks/useDashboardSections';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from './constants';
|
||||
import SectionPicker from './SectionPicker';
|
||||
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
|
||||
import styles from './PanelTypeSelectionModal.module.scss';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
|
||||
interface PanelTypeSelectionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (panelKind: PanelKind) => void;
|
||||
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
|
||||
/** Section the picker opens on; omit → the untitled root / first section. */
|
||||
defaultLayoutIndex?: number;
|
||||
}
|
||||
|
||||
/** Fake loader shown on the confirm button before navigating to the editor. */
|
||||
const CONFIRM_LOADER_MS = 500;
|
||||
|
||||
function PanelTypeSelectionModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
defaultLayoutIndex,
|
||||
}: PanelTypeSelectionModalProps): JSX.Element {
|
||||
const sections = useDashboardSections();
|
||||
const options = useMemo(() => buildSectionOptions(sections), [sections]);
|
||||
const hasSectionPicker = options.length > 1;
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState('');
|
||||
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
|
||||
null,
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimer = useCallback((): void => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Seed the target section on open; cancel a pending navigation on close.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
|
||||
setSelectedPanelKind(null);
|
||||
setIsSubmitting(false);
|
||||
} else {
|
||||
clearTimer();
|
||||
}
|
||||
}, [open, options, defaultLayoutIndex, clearTimer]);
|
||||
|
||||
useEffect(() => clearTimer, [clearTimer]);
|
||||
|
||||
const handleConfirm = (): void => {
|
||||
if (selectedPanelKind === null || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSelect(selectedPanelKind, layoutIndex);
|
||||
}, CONFIRM_LOADER_MS);
|
||||
};
|
||||
|
||||
// Footer is always shown; the confirm button is disabled until a panel type is picked.
|
||||
const footer = (
|
||||
<div className={styles.footerActions}>
|
||||
{hasSectionPicker && (
|
||||
<div className={styles.footerPicker}>
|
||||
<span className={styles.pickerLabel}>Add panel to</span>
|
||||
<SectionPicker
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={setSelectedValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={selectedPanelKind === null}
|
||||
loading={isSubmitting}
|
||||
prefix={<Plus size={16} />}
|
||||
onClick={handleConfirm}
|
||||
testId="panel-type-confirm"
|
||||
>
|
||||
Add Panel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
title="Select a panel type"
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="New Panel"
|
||||
footer={footer}
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
|
||||
<Button
|
||||
key={panelKind}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={styles.typeButton}
|
||||
data-testid={`panel-type-${panelKind}`}
|
||||
onClick={(): void => onSelect(panelKind)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
<div className={styles.panelTypeSection}>
|
||||
<span className={styles.pickerLabel}>Select panel type</span>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
|
||||
<button
|
||||
key={panelKind}
|
||||
type="button"
|
||||
className={cx(styles.panelTypeCard, {
|
||||
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
|
||||
})}
|
||||
data-testid={`panel-type-${panelKind}`}
|
||||
aria-pressed={panelKind === selectedPanelKind}
|
||||
onClick={(): void => setSelectedPanelKind(panelKind)}
|
||||
>
|
||||
<Icon size={24} color={Color.BG_ROBIN_400} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
.select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.rootIcon {
|
||||
color: var(--bg-robin-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sectionIcon {
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.triggerValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.triggerLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.optionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.optionText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.optionLabel {
|
||||
color: var(--l1-foreground);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.optionDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components
|
||||
import { Select } from 'antd';
|
||||
|
||||
import type { SectionOption } from './types';
|
||||
import styles from './SectionPicker.module.scss';
|
||||
|
||||
interface SectionPickerProps {
|
||||
options: SectionOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function SectionPicker({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: SectionPickerProps): JSX.Element {
|
||||
// `selectedLabel` (one line) shows in the trigger; `label` (two lines) in the list.
|
||||
const selectOptions = useMemo(
|
||||
() =>
|
||||
options.map((option) => {
|
||||
const iconClass = option.isRoot ? styles.rootIcon : styles.sectionIcon;
|
||||
return {
|
||||
value: option.value,
|
||||
selectedLabel: (
|
||||
<span className={styles.triggerValue}>
|
||||
<option.Icon size={16} className={iconClass} />
|
||||
<span className={styles.triggerLabel}>{option.label}</span>
|
||||
</span>
|
||||
),
|
||||
label: (
|
||||
<span
|
||||
className={styles.optionRow}
|
||||
data-testid={`panel-section-option-${option.layoutIndex}`}
|
||||
>
|
||||
<option.Icon size={16} className={iconClass} />
|
||||
<span className={styles.optionText}>
|
||||
<span className={styles.optionLabel}>{option.label}</span>
|
||||
<span className={styles.optionDescription}>{option.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}),
|
||||
[options],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select<string>
|
||||
className={styles.select}
|
||||
popupClassName={styles.dropdown}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data-testid="panel-section-select"
|
||||
optionLabelProp="selectedLabel"
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement ?? document.body
|
||||
}
|
||||
options={selectOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionPicker;
|
||||
@@ -14,3 +14,16 @@ export interface PanelType {
|
||||
/** Icon component — the consumer renders it and controls size/color/etc. */
|
||||
Icon: ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
export interface SectionOption {
|
||||
/** The section's `layoutIndex`, stringified for the Select value. */
|
||||
value: string;
|
||||
layoutIndex: number;
|
||||
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
|
||||
label: string;
|
||||
/** Caption under the label. */
|
||||
description: string;
|
||||
/** Untitled top-level layout (has no section header). */
|
||||
isRoot: boolean;
|
||||
Icon: ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { LayoutDashboard, Rows2 } from '@signozhq/icons';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { SectionOption } from './types';
|
||||
|
||||
const ROOT_LABEL = 'Dashboard (root)';
|
||||
const ROOT_DESCRIPTION = 'Top level — no section';
|
||||
const SECTION_DESCRIPTION = 'Section';
|
||||
|
||||
/** Maps dashboard sections to section-picker options (untitled → "root"). */
|
||||
export function buildSectionOptions(
|
||||
sections: DashboardSection[],
|
||||
): SectionOption[] {
|
||||
return sections.map((section) => {
|
||||
const isRoot = !section.title;
|
||||
return {
|
||||
value: String(section.layoutIndex),
|
||||
layoutIndex: section.layoutIndex,
|
||||
label: isRoot ? ROOT_LABEL : (section.title as string),
|
||||
description: isRoot ? ROOT_DESCRIPTION : SECTION_DESCRIPTION,
|
||||
isRoot,
|
||||
Icon: isRoot ? LayoutDashboard : Rows2,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the option the picker should open on: the section the "Add panel" was
|
||||
* triggered from when present and still valid, otherwise the first option.
|
||||
*/
|
||||
export function resolveDefaultSectionValue(
|
||||
options: SectionOption[],
|
||||
defaultLayoutIndex: number | undefined,
|
||||
): string {
|
||||
const fallback = options[0]?.value ?? '';
|
||||
if (defaultLayoutIndex === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const target = String(defaultLayoutIndex);
|
||||
return options.some((option) => option.value === target) ? target : fallback;
|
||||
}
|
||||
@@ -29,8 +29,13 @@ interface SectionProps {
|
||||
|
||||
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
createPanel,
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
@@ -141,6 +146,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
defaultLayoutIndex={targetLayoutIndex}
|
||||
/>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
|
||||
@@ -12,7 +12,10 @@ interface UseCreatePanelResult {
|
||||
/** Pass the target section's layout index; omit → last/new section. */
|
||||
openPicker: (layoutIndex?: number) => void;
|
||||
closePicker: () => void;
|
||||
createPanel: (panelKind: PanelKind) => void;
|
||||
/** The section the picker was opened against — seeds its section dropdown. */
|
||||
targetLayoutIndex: number | undefined;
|
||||
/** `layoutIndex` overrides the opened-against target (the dropdown's choice). */
|
||||
createPanel: (panelKind: PanelKind, layoutIndex?: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,16 +41,23 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
}, []);
|
||||
|
||||
const createPanel = useCallback(
|
||||
(panelKind: PanelKind): void => {
|
||||
(panelKind: PanelKind, targetIndex?: number): void => {
|
||||
setIsPickerOpen(false);
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
);
|
||||
|
||||
return { isPickerOpen, openPicker, closePicker, createPanel };
|
||||
return {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
targetLayoutIndex: layoutIndex,
|
||||
createPanel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { type DashboardSection, layoutsToSections } from '../utils';
|
||||
|
||||
/**
|
||||
* The current dashboard's sections, read from the already-loaded dashboard
|
||||
* query. The page fetches via useGetDashboardV2 keyed by id, so this reuses that
|
||||
* cache (no extra request) instead of prop-drilling the section list.
|
||||
*/
|
||||
export function useDashboardSections(): DashboardSection[] {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { data } = useGetDashboardV2({ id: dashboardId });
|
||||
const spec = data?.data?.spec;
|
||||
|
||||
return useMemo(
|
||||
() => layoutsToSections(spec?.layouts, spec?.panels),
|
||||
[spec?.layouts, spec?.panels],
|
||||
);
|
||||
}
|
||||
@@ -60,21 +60,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The Badge is already pill-rounded, so an 18x18 box renders a circle for a
|
||||
// single digit and a capsule for more.
|
||||
.eventsBadge {
|
||||
// l3 background (!important beats the Badge's [data-color] rule).
|
||||
--badge-background: var(--l3-background) !important;
|
||||
--badge-padding: 0 5px;
|
||||
// Static count, not interactive — cancel the Badge's hover background change.
|
||||
--badge-hover-background: var(--badge-background) !important;
|
||||
|
||||
margin-left: 6px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tabsScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import {
|
||||
TabsContent,
|
||||
TabsList,
|
||||
@@ -282,8 +281,6 @@ function SpanDetailsContent({
|
||||
// .map((key) => ({ key, value: allAttrs[key] }));
|
||||
// }, [selectedSpan]);
|
||||
|
||||
const eventsCount = selectedSpan.events?.length || 0;
|
||||
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<div className={styles.detailsSection}>
|
||||
@@ -400,10 +397,7 @@ function SpanDetailsContent({
|
||||
<Bookmark size={14} /> Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" variant="secondary">
|
||||
<ScrollText size={14} /> Events
|
||||
<Badge color="secondary" className={styles.eventsBadge}>
|
||||
{eventsCount}
|
||||
</Badge>
|
||||
<ScrollText size={14} /> Events ({selectedSpan.events?.length || 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" variant="secondary">
|
||||
<List size={14} /> Logs
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
.backBtn {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.traceIdSection {
|
||||
@@ -27,11 +26,6 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
// Tabular figures so the trace ID's digits line up at a fixed width.
|
||||
:global(.key-value-label__value) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
|
||||
@@ -133,7 +133,7 @@ function TraceDetailsHeader({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
size="md"
|
||||
className={styles.backBtn}
|
||||
onClick={handlePreviousBtnClick}
|
||||
aria-label="Back"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { ArrowRightFromLine, Search, X } from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { ChevronsRight, Copy, Search, X } from '@signozhq/icons';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
TooltipRoot,
|
||||
@@ -19,7 +21,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
@@ -88,6 +89,7 @@ function Filters({
|
||||
onExpand: () => void;
|
||||
onCollapse: () => void;
|
||||
}): JSX.Element {
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const [filters, setFilters] = useState<TagFilter>(
|
||||
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
|
||||
);
|
||||
@@ -299,7 +301,20 @@ function Filters({
|
||||
<div className={styles.pillPopover}>
|
||||
<div className={styles.pillPopoverHeader}>
|
||||
<Typography.Text>Search query</Typography.Text>
|
||||
<CopyButton value={expression} size={12} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void => {
|
||||
setCopy(expression);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: false,
|
||||
position: 'top-right',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.pillPopoverExpression}>{expression}</div>
|
||||
</div>
|
||||
@@ -406,7 +421,7 @@ function Filters({
|
||||
color="secondary"
|
||||
onClick={onCollapse}
|
||||
>
|
||||
<ArrowRightFromLine size={14} />
|
||||
<ChevronsRight size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Collapse filters</TooltipContent>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// Square copy button whose icon cross-fades between copy and check.
|
||||
.copyButton {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Both icons occupy the same box; only one is visible at a time.
|
||||
.iconStack {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transition:
|
||||
opacity 300ms ease,
|
||||
filter 300ms ease,
|
||||
transform 300ms ease;
|
||||
}
|
||||
|
||||
// Idle state: copy visible, check blurred/rotated/faded out.
|
||||
.copyIcon {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: rotate(-90deg);
|
||||
// Green checkmark to signal a successful copy; eases in a touch slower.
|
||||
color: var(--bg-forest-500);
|
||||
transition-duration: 500ms;
|
||||
}
|
||||
|
||||
// Copied state: copy fades/blurs/rotates out, check animates in.
|
||||
.iconStack[data-copied='true'] {
|
||||
.copyIcon {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { CSSProperties, useCallback } from 'react';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
|
||||
import styles from './CopyButton.module.scss';
|
||||
|
||||
export interface CopyButtonProps {
|
||||
/** Text written to the clipboard on click. */
|
||||
value: string;
|
||||
/** Icon size in px. Default 14. */
|
||||
size?: number;
|
||||
/** Accessible label for the idle (not-yet-copied) state. Default "Copy". */
|
||||
ariaLabel?: string;
|
||||
/** Extra class merged onto the button. */
|
||||
className?: string;
|
||||
/** Fired after a successful copy (e.g. to show a toast). */
|
||||
onCopy?: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Square, icon-only copy button. Shows a copy icon that cross-fades to a
|
||||
* checkmark (blur + rotate + fade) on copy, reverting after 2s. The checkmark
|
||||
* uses the hover-state icon colour.
|
||||
*/
|
||||
function CopyButton({
|
||||
value,
|
||||
size = 14,
|
||||
ariaLabel = 'Copy',
|
||||
className,
|
||||
onCopy,
|
||||
testId,
|
||||
}: CopyButtonProps): JSX.Element {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard();
|
||||
|
||||
const handleClick = useCallback((): void => {
|
||||
copyToClipboard(value);
|
||||
onCopy?.();
|
||||
}, [copyToClipboard, value, onCopy]);
|
||||
|
||||
const stackStyle: CSSProperties = { width: size, height: size };
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={cx(styles.copyButton, className)}
|
||||
onClick={handleClick}
|
||||
aria-label={isCopied ? 'Copied' : ariaLabel}
|
||||
testId={testId}
|
||||
>
|
||||
<span className={styles.iconStack} style={stackStyle} data-copied={isCopied}>
|
||||
<Copy size={size} className={cx(styles.icon, styles.copyIcon)} />
|
||||
<Check size={size} className={cx(styles.icon, styles.checkIcon)} />
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
CopyButton.defaultProps = {
|
||||
size: 14,
|
||||
ariaLabel: 'Copy',
|
||||
className: undefined,
|
||||
onCopy: undefined,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default CopyButton;
|
||||
@@ -22,6 +22,23 @@
|
||||
--dropdown-menu-content-z-index: 1000;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared content container — no scroll, each view handles its own
|
||||
&__content {
|
||||
flex: 1;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { ChevronDown, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { PrettyView, PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
import { PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
|
||||
import './DataViewer.styles.scss';
|
||||
|
||||
@@ -31,6 +33,7 @@ function DataViewer({
|
||||
prettyViewProps,
|
||||
}: DataViewerProps): JSX.Element {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('pretty');
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
@@ -48,6 +51,14 @@ function DataViewer({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (): void => {
|
||||
const text = JSON.stringify(data, null, 2);
|
||||
setCopy(text);
|
||||
toast.success('Copied to clipboard', {
|
||||
position: 'top-right',
|
||||
});
|
||||
};
|
||||
|
||||
const currentLabel =
|
||||
VIEW_MODE_OPTIONS.find((opt) => opt.value === viewMode)?.label ?? 'Pretty';
|
||||
|
||||
@@ -83,7 +94,14 @@ function DataViewer({
|
||||
{currentLabel}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<CopyButton value={jsonString} ariaLabel="Copy JSON" />
|
||||
<button
|
||||
type="button"
|
||||
className="data-viewer__copy-btn"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy JSON"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="data-viewer__content">
|
||||
|
||||
Reference in New Issue
Block a user