mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-06 02:20:31 +01:00
Compare commits
13 Commits
nv/dashboa
...
feat/servi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b1aba04f4 | ||
|
|
5c8d33290b | ||
|
|
18d5e92ae2 | ||
|
|
5eaca31759 | ||
|
|
30424a3829 | ||
|
|
d2ede7bb16 | ||
|
|
8b0ccc8ddc | ||
|
|
1118136b69 | ||
|
|
ae3f5114c4 | ||
|
|
c622bbd112 | ||
|
|
b92d5e934b | ||
|
|
c20520da5f | ||
|
|
0ccb26cbfc |
@@ -36,6 +36,7 @@
|
||||
"@ant-design/icons": "4.8.0",
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/lang-javascript": "6.2.3",
|
||||
"@dagrejs/dagre": "3.0.0",
|
||||
"@dnd-kit/core": "6.1.0",
|
||||
"@dnd-kit/modifiers": "7.0.0",
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
@@ -63,6 +64,7 @@
|
||||
"@visx/shape": "3.5.0",
|
||||
"@visx/tooltip": "3.3.0",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"@xyflow/react": "12.10.2",
|
||||
"ansi-to-html": "0.7.2",
|
||||
"antd": "5.11.0",
|
||||
"antd-table-saveas-excel": "2.2.1",
|
||||
@@ -115,7 +117,6 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-drag-listview": "2.0.0",
|
||||
"react-error-boundary": "4.0.11",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-full-screen": "1.1.1",
|
||||
"react-grid-layout": "^1.3.4",
|
||||
"react-helmet-async": "1.3.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum Events {
|
||||
TOOLTIP_PINNED = 'TOOLTIP_PINNED',
|
||||
TOOLTIP_UNPINNED = 'TOOLTIP_UNPINNED',
|
||||
TOOLTIP_CONTENT_SCROLLED = 'TOOLTIP_CONTENT_SCROLLED',
|
||||
TOOLTIP_SYNC_MODE_CHANGED = 'TOOLTIP_SYNC_MODE_CHANGED',
|
||||
}
|
||||
|
||||
export enum InfraMonitoringEvents {
|
||||
|
||||
@@ -38,4 +38,5 @@ export enum LOCALSTORAGE {
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('BillingContainer', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.findByText('Cancel your subscription', { selector: 'span' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('BillingContainer', () => {
|
||||
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.findByText('Cancel your subscription', { selector: 'span' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -162,7 +162,7 @@ describe('BillingContainer', () => {
|
||||
it('should render when license is ACTIVATED and platform is CLOUD', async () => {
|
||||
render(<BillingContainer />);
|
||||
await expect(
|
||||
screen.findByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.findByText('Cancel your subscription', { selector: 'span' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -186,7 +186,7 @@ describe('BillingContainer', () => {
|
||||
);
|
||||
await screen.findByText('billing');
|
||||
expect(
|
||||
screen.queryByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.queryByText('Cancel your subscription', { selector: 'span' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('BillingContainer', () => {
|
||||
render(<BillingContainer />, {}, { appContextOverrides: overrides });
|
||||
await screen.findByText('billing');
|
||||
expect(
|
||||
screen.queryByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.queryByText('Cancel your subscription', { selector: 'span' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
.banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
padding: var(--padding-4);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--callout-error-border);
|
||||
background-color: var(--callout-error-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l2-background);
|
||||
margin: var(--spacing-4) 0 var(--spacing-12);
|
||||
}
|
||||
|
||||
@@ -15,21 +15,55 @@
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
color: var(--callout-error-icon);
|
||||
color: var(--l2-foreground);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.dialogBody {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-relaxed);
|
||||
color: var(--l2-foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.dialogDescription {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
color: var(--l2-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialogConfirmLabel {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
color: var(--l2-foreground);
|
||||
margin: 0;
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
background: var(--secondary-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
|
||||
|
||||
@@ -13,14 +13,16 @@ describe('CancelSubscriptionBanner', () => {
|
||||
it('renders banner with title and subtitle', () => {
|
||||
render(<CancelSubscriptionBanner />);
|
||||
expect(
|
||||
screen.getByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.getByText('Cancel your subscription', { selector: 'span' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Cancel your SigNoz subscription.'),
|
||||
screen.getByText(
|
||||
/When you cancel your SigNoz subscription, all your data will be deleted/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dialog with correct content when Cancel Subscription is clicked', async () => {
|
||||
it('opens dialog with content when Cancel Subscription is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
@@ -30,17 +32,62 @@ describe('CancelSubscriptionBanner', () => {
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/reach out to our support team/i),
|
||||
screen.getByText(/Cancelling your subscription would stop your data/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Type/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /keep subscription/i }),
|
||||
screen.getByPlaceholderText(/Enter the word cancel/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /contact support/i }),
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends mailto to cloud-support with correct subject on Contact Support', async () => {
|
||||
it('keeps Cancel subscription button disabled until "cancel" is typed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
const confirmButton = screen.getByRole('button', {
|
||||
name: /cancel subscription/i,
|
||||
});
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
await user.type(input, 'canc');
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
await user.type(input, 'el');
|
||||
expect(confirmButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('closes dialog and resets input when Go back is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
await user.type(input, 'cancel');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /go back/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
expect(screen.getByPlaceholderText(/Enter the word cancel/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('sends mailto to cloud-support with correct subject after typing "cancel"', async () => {
|
||||
const realCreateElement = document.createElement.bind(document);
|
||||
const mockClick = jest.fn();
|
||||
const mockAnchor = { href: '', click: mockClick };
|
||||
@@ -57,7 +104,13 @@ describe('CancelSubscriptionBanner', () => {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /contact support/i }));
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
await user.type(input, 'cancel');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
|
||||
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button, DialogWrapper } from '@signozhq/ui';
|
||||
import { SolidInfoCircle, Undo2, X } from '@signozhq/icons';
|
||||
import { Button, DialogWrapper, Input } from '@signozhq/ui';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { pick } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
import styles from './CancelSubscriptionBanner.module.scss';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
function CancelSubscriptionBanner(): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const { user, org } = useAppContext();
|
||||
|
||||
const handleOpenCancelDialog = (): void => {
|
||||
@@ -53,6 +55,12 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
|
||||
link.click();
|
||||
setOpen(false);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setOpen(false);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const footer = (
|
||||
@@ -60,12 +68,19 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={(): void => setOpen(false)}
|
||||
prefix={<Undo2 size={14} />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Keep Subscription
|
||||
Go back
|
||||
</Button>
|
||||
<Button variant="solid" color="destructive" onClick={handleContactSupport}>
|
||||
Contact Support
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
prefix={<X size={14} />}
|
||||
disabled={confirmText !== 'cancel'}
|
||||
onClick={handleContactSupport}
|
||||
>
|
||||
Cancel subscription
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
@@ -74,30 +89,47 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
<>
|
||||
<div className={styles.banner}>
|
||||
<div className={styles.info}>
|
||||
<span className={styles.title}>Cancel Subscription</span>
|
||||
<span className={styles.subtitle}>Cancel your SigNoz subscription.</span>
|
||||
<div className={styles.titleRow}>
|
||||
<SolidInfoCircle color={Color.BG_SAKURA_500} size={12} />
|
||||
<span className={styles.title}>Cancel your subscription</span>
|
||||
</div>
|
||||
<span className={styles.subtitle}>
|
||||
When you cancel your SigNoz subscription, all your data will be deleted
|
||||
immediately and removed from our servers.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
color="secondary"
|
||||
prefix={<X size={12} />}
|
||||
onClick={handleOpenCancelDialog}
|
||||
className={styles.cancelButton}
|
||||
>
|
||||
Cancel Subscription
|
||||
</Button>
|
||||
</div>
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Cancel your subscription"
|
||||
onOpenChange={handleClose}
|
||||
title="Cancel your subscription?"
|
||||
width="narrow"
|
||||
showCloseButton={false}
|
||||
footer={footer}
|
||||
>
|
||||
<p className={styles.dialogBody}>
|
||||
To cancel your SigNoz subscription, please reach out to our support team.
|
||||
We'll be happy to assist you.
|
||||
</p>
|
||||
<div className={styles.dialogBody}>
|
||||
<p className={styles.dialogDescription}>
|
||||
Cancelling your subscription would stop your data from being ingested to
|
||||
SigNoz. All the data that has been already sent will also be deleted.
|
||||
</p>
|
||||
<p className={styles.dialogConfirmLabel}>
|
||||
Type <code>cancel</code> to confirm the cancellation.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Enter the word cancel..."
|
||||
value={confirmText}
|
||||
onChange={(e): void => setConfirmText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
.overview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.overview-settings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
|
||||
.name-icon-input {
|
||||
display: flex;
|
||||
.dashboard-image-input {
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
|
||||
.ant-select-selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.list-item-image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-name-input {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-name {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.description-text-area {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.overview-settings-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.unsaved-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
.unsaved-changes {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px; /* 171.429% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
.footer-action-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.discard-btn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-image-input {
|
||||
&.ant-select-dropdown {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.ant-select-item {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.ant-select-item-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.list-item-image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Col, Input, Select, Space, Typography } from 'antd';
|
||||
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
import { Button } from './styles';
|
||||
import { Base64Icons } from './utils';
|
||||
|
||||
import './GeneralSettings.styles.scss';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
@@ -19,6 +27,13 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
|
||||
dashboardData?.id,
|
||||
);
|
||||
|
||||
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
|
||||
useSyncTooltipFilterMode(dashboardData?.id);
|
||||
|
||||
const selectedData = dashboardData?.data;
|
||||
|
||||
const {
|
||||
@@ -100,8 +115,8 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overview-content">
|
||||
<Col className="overview-settings">
|
||||
<div className={styles.overviewContent}>
|
||||
<Col className={styles.overviewSettings}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{
|
||||
@@ -112,27 +127,29 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Dashboard Name
|
||||
</Typography>
|
||||
<section className="name-icon-input">
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName="dashboard-image-input"
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
value={updatedImage}
|
||||
onChange={(value: string): void => setUpdatedImage(value)}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img src={icon} alt="dashboard-icon" className="list-item-image" />
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className="dashboard-name-input"
|
||||
className={styles.dashboardNameInput}
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
@@ -140,41 +157,92 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Description
|
||||
</Typography>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={updatedDescription}
|
||||
className="description-text-area"
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => setUpdatedDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Tags
|
||||
</Typography>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Sync Mode
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Synced Tooltip Series
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Show only series that intersect on group-by, or every series with the
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(e): void => {
|
||||
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: e.target.value,
|
||||
});
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<div className="overview-settings-footer">
|
||||
<div className="unsaved">
|
||||
<div className="unsaved-dot" />
|
||||
<Typography.Text className="unsaved-changes">
|
||||
<div className={styles.overviewSettingsFooter}>
|
||||
<div className={styles.unsaved}>
|
||||
<div className={styles.unsavedDot} />
|
||||
<Typography.Text className={styles.unsavedChanges}>
|
||||
{numberOfUnsavedChanges} unsaved change
|
||||
{numberOfUnsavedChanges > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="footer-action-btns">
|
||||
<div className={styles.footerActionBtns}>
|
||||
<Button
|
||||
disabled={updateDashboardMutation.isLoading}
|
||||
icon={<X size={14} />}
|
||||
onClick={discardHandler}
|
||||
type="text"
|
||||
className="discard-btn"
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
@@ -188,7 +256,7 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
data-testid="save-dashboard-config"
|
||||
onClick={onSaveHandler}
|
||||
type="primary"
|
||||
className="save-btn"
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
|
||||
@@ -33,11 +33,13 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
}
|
||||
const tooltipProps: BarTooltipProps = {
|
||||
...props,
|
||||
id: config.getId(),
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
isStackedBarChart: isStackedBarChart,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <BarChartTooltip {...tooltipProps} />;
|
||||
},
|
||||
@@ -48,6 +50,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
rest.decimalPrecision,
|
||||
isStackedBarChart,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -29,11 +29,12 @@ export default function ChartWrapper({
|
||||
onClick,
|
||||
syncMode,
|
||||
syncKey,
|
||||
syncFilterMode,
|
||||
onDestroy = noop,
|
||||
children,
|
||||
layoutChildren,
|
||||
yAxisUnit,
|
||||
groupBy,
|
||||
groupByPerQuery,
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
'data-testid': testId,
|
||||
@@ -69,9 +70,10 @@ export default function ChartWrapper({
|
||||
const syncMetadata = useMemo(
|
||||
() => ({
|
||||
yAxisUnit,
|
||||
groupBy,
|
||||
groupByPerQuery,
|
||||
filterMode: syncFilterMode,
|
||||
}),
|
||||
[yAxisUnit, groupBy],
|
||||
[yAxisUnit, groupByPerQuery, syncFilterMode],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,13 +24,21 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
|
||||
}
|
||||
const tooltipProps: HistogramTooltipProps = {
|
||||
...props,
|
||||
id: rest.config.getId(),
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <HistogramTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
|
||||
[
|
||||
customTooltip,
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { TimeSeriesChartProps } from '../types';
|
||||
|
||||
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
const { children, customTooltip, pinnedTooltipElement, ...rest } = props;
|
||||
const { children, customTooltip, ...rest } = props;
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
@@ -18,10 +18,12 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
}
|
||||
const tooltipProps: TimeSeriesTooltipProps = {
|
||||
...props,
|
||||
id: rest.config.getId(),
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <TimeSeriesTooltip {...tooltipProps} />;
|
||||
},
|
||||
@@ -31,15 +33,12 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
<ChartWrapper {...rest} customTooltip={renderTooltip}>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendConfig,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
TooltipClickData,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
@@ -21,6 +26,7 @@ interface BaseChartProps {
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
@@ -30,6 +36,7 @@ interface UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
plotRef?: (plot: uPlot | null) => void;
|
||||
onDestroy?: (plot: uPlot) => void;
|
||||
children?: React.ReactNode;
|
||||
@@ -39,7 +46,7 @@ interface UPlotBasedChartProps {
|
||||
interface UPlotChartDataProps {
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
@@ -14,7 +20,7 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
|
||||
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import get from 'lodash/get';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
|
||||
function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -24,6 +30,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onDragSelect,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
groupByPerQuery,
|
||||
} = props;
|
||||
const uPlotRef = useRef<uPlot | null>(null);
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
@@ -34,6 +41,10 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
|
||||
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
|
||||
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -75,6 +86,11 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
panelMode,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
syncMode,
|
||||
]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
@@ -114,14 +130,20 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
uPlotRef.current = plot;
|
||||
}, []);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return get(widget, 'query.builder.queryData[0].groupBy', []);
|
||||
}, [widget.query]);
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
|
||||
return (
|
||||
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
key={`${syncMode}-${syncFilterMode}`}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -133,11 +155,14 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
groupBy={groupBy}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
isStackedBarChart={widget.stackedBarChart ?? false}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
syncMode={syncMode}
|
||||
syncFilterMode={syncFilterMode}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import Histogram from '../../charts/Histogram/Histogram';
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
} from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
|
||||
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -75,6 +79,20 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
widget.mergeAllActiveQueries,
|
||||
]);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
|
||||
return (
|
||||
<TooltipFooter
|
||||
id={widget.id}
|
||||
isPinned={isPinned}
|
||||
dismiss={dismiss}
|
||||
canDrilldown={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
@@ -97,6 +115,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { ContextMenu } from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
|
||||
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -24,6 +30,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onDragSelect,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
groupByPerQuery,
|
||||
} = props;
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
@@ -33,6 +40,10 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
|
||||
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
|
||||
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -81,6 +92,11 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
syncMode,
|
||||
]);
|
||||
|
||||
const layoutChildren = useMemo(() => {
|
||||
@@ -105,14 +121,20 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
widget.decimalPrecision,
|
||||
]);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return get(widget, 'query.builder.queryData[0].groupBy', []);
|
||||
}, [widget.query]);
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
|
||||
return (
|
||||
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<TimeSeries
|
||||
key={`${syncMode}-${syncFilterMode}`}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -122,10 +144,13 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
groupBy={groupBy}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={syncMode}
|
||||
syncFilterMode={syncFilterMode}
|
||||
layoutChildren={layoutChildren}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
|
||||
@@ -7,22 +7,25 @@ import Styles from './TooltipFooter.module.scss';
|
||||
import { MousePointerClick } from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
interface TooltipFooterProps {
|
||||
id: string;
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
canDrilldown?: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
export default function TooltipFooter({
|
||||
id,
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
isPinned,
|
||||
canDrilldown = true,
|
||||
dismiss,
|
||||
}: TooltipFooterProps): JSX.Element {
|
||||
const handleUnpinClick = (): void => {
|
||||
logEvent(Events.TOOLTIP_UNPINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: id,
|
||||
});
|
||||
dismiss();
|
||||
};
|
||||
@@ -43,12 +46,14 @@ export default function TooltipFooter({
|
||||
</div>
|
||||
) : (
|
||||
<div className={Styles.hintList}>
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<Kbd>
|
||||
<MousePointerClick size={12} />
|
||||
</Kbd>
|
||||
<span>Click to drilldown</span>
|
||||
</div>
|
||||
{canDrilldown && (
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<Kbd>
|
||||
<MousePointerClick size={12} />
|
||||
</Kbd>
|
||||
<span>Click to drilldown</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<span>Press</span>
|
||||
<Kbd>{pinKey.toUpperCase()}</Kbd>
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Events } from 'constants/events';
|
||||
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import TooltipFooter from '../TooltipFooter';
|
||||
|
||||
const mockLogEvent = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): unknown => mockLogEvent(...args),
|
||||
}));
|
||||
|
||||
describe('TooltipFooter', () => {
|
||||
const defaultProps = {
|
||||
id: 'panel-123',
|
||||
isPinned: false,
|
||||
dismiss: jest.fn(),
|
||||
};
|
||||
|
||||
describe('when not pinned', () => {
|
||||
it('renders the drilldown and pin hints by default', () => {
|
||||
render(<TooltipFooter {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
|
||||
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the drilldown hint when canDrilldown is false', () => {
|
||||
render(<TooltipFooter {...defaultProps} canDrilldown={false} />);
|
||||
|
||||
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a custom pin key in uppercase', () => {
|
||||
render(<TooltipFooter {...defaultProps} pinKey="x" />);
|
||||
|
||||
expect(screen.getByText('X')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the unpin button', () => {
|
||||
render(<TooltipFooter {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when pinned', () => {
|
||||
it('renders the unpin hint with pin key and Esc', () => {
|
||||
render(<TooltipFooter {...defaultProps} isPinned />);
|
||||
|
||||
expect(screen.getByText('to unpin')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Esc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the unpin button', () => {
|
||||
render(<TooltipFooter {...defaultProps} isPinned />);
|
||||
|
||||
expect(screen.getByTestId('uplot-tooltip-unpin')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /unpin tooltip/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the drilldown and pin-instruction hints', () => {
|
||||
render(<TooltipFooter {...defaultProps} isPinned />);
|
||||
|
||||
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('to pin the tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls dismiss and logs the unpin event when the unpin button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const dismiss = jest.fn();
|
||||
|
||||
render(<TooltipFooter {...defaultProps} dismiss={dismiss} isPinned />);
|
||||
|
||||
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
|
||||
id: 'panel-123',
|
||||
});
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
|
||||
import { Button, PersistedAnnouncementBanner } from '@signozhq/ui';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricsOnboardingStatus } from 'api/generated/services/metrics';
|
||||
@@ -11,7 +11,6 @@ import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import Header from 'components/Header/Header';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
@@ -271,23 +270,6 @@ export default function Home(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
{user?.role === USER_ROLES.ADMIN && (
|
||||
<PersistedAnnouncementBanner
|
||||
type="info"
|
||||
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
|
||||
action={{
|
||||
label: 'Go to Service Accounts',
|
||||
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<strong>API keys</strong> have been deprecated in favour of{' '}
|
||||
<strong>Service accounts</strong>. The existing API Keys have been
|
||||
migrated to service accounts.
|
||||
</>
|
||||
</PersistedAnnouncementBanner>
|
||||
)}
|
||||
|
||||
<div className="sticky-header">
|
||||
<Header
|
||||
leftComponent={
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FC } from 'react';
|
||||
import { FC, useMemo } from 'react';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { PanelTypeVsPanelWrapper } from './constants';
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
@@ -30,6 +31,20 @@ function PanelWrapper({
|
||||
selectedGraph || widget.panelTypes
|
||||
] as FC<PanelWrapperProps>;
|
||||
|
||||
const groupByPerQuery = useMemo<Record<string, BaseAutocompleteData[]>>(() => {
|
||||
if (!widget.query.builder) {
|
||||
return {};
|
||||
}
|
||||
const { queryData } = widget.query.builder;
|
||||
return queryData.reduce<Record<string, BaseAutocompleteData[]>>(
|
||||
(acc, query) => {
|
||||
acc[query.queryName] = query.groupBy ?? [];
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}, [widget]);
|
||||
|
||||
if (!Component) {
|
||||
return <></>;
|
||||
}
|
||||
@@ -60,6 +75,7 @@ function PanelWrapper({
|
||||
customSeries={customSeries}
|
||||
enableDrillDown={enableDrillDown}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
|
||||
import { WidgetGraphComponentProps } from 'container/GridCardLayout/GridCard/types';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
@@ -30,6 +31,7 @@ export type PanelWrapperProps = {
|
||||
enableDrillDown?: boolean;
|
||||
panelMode: PanelMode;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
};
|
||||
|
||||
export type TooltipData = {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardCursorSyncMode } from '../useDashboardCursorSyncMode';
|
||||
import { useDashboardPreferencesStore } from '../useDashboardPreference';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.DASHBOARD_PREFERENCES;
|
||||
|
||||
describe('useDashboardCursorSyncMode', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('in DASHBOARD_VIEW mode', () => {
|
||||
it('uses Crosshair as the default cursor sync mode', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
|
||||
it('reads the stored cursor sync mode for the dashboard', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('writes the value under the cursorSyncMode key in the store', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the value to localStorage', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}');
|
||||
expect(persisted.state.preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
|
||||
it('treats the setter as a no-op when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a panelMode (e.g. dashboard settings call site)', () => {
|
||||
it('reads the stored value just like DASHBOARD_VIEW does', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('writes through the setter to the store', () => {
|
||||
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([[PanelMode.DASHBOARD_EDIT], [PanelMode.STANDALONE_VIEW]])(
|
||||
'in %s mode (cursor sync disabled)',
|
||||
(panelMode) => {
|
||||
it('returns None and ignores any stored value', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', panelMode),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
it('treats the setter as a no-op and does not write to the store', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', panelMode),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import {
|
||||
useDashboardPreference,
|
||||
useDashboardPreferencesStore,
|
||||
} from '../useDashboardPreference';
|
||||
|
||||
const DEFAULT_MODE = DashboardCursorSync.Crosshair;
|
||||
|
||||
describe('useDashboardPreference', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
});
|
||||
|
||||
it('returns the default value when no preference is stored', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('returns the default value when dashboardId is undefined', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('returns the stored value for the given dashboardId', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('persists the new value via the setter', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('keeps multiple hook instances in sync after a write', () => {
|
||||
const { result: writer } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
const { result: reader } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
writer.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(writer.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
expect(reader.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('isolates preferences across different dashboardIds', () => {
|
||||
const { result: dashOne } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
const { result: dashTwo } = renderHook(() =>
|
||||
useDashboardPreference('dash-2', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dashOne.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(dashOne.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(dashTwo.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('does not overwrite preferences for other dashboards when writing', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDashboardPreferencesStore.removePreferences', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
});
|
||||
|
||||
it('removes the preferences for the given dashboardId', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('leaves other dashboards untouched', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op when the dashboardId is not present', () => {
|
||||
const initial = {
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
};
|
||||
useDashboardPreferencesStore.setState({ preferences: initial });
|
||||
const before = useDashboardPreferencesStore.getState().preferences;
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
// Identity-preserving so subscribers reading `preferences` don't re-render.
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toBe(before);
|
||||
});
|
||||
|
||||
it('causes subsequent reads via useDashboardPreference to fall back to the default', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
});
|
||||
26
frontend/src/hooks/dashboard/useDashboardCursorSyncMode.ts
Normal file
26
frontend/src/hooks/dashboard/useDashboardCursorSyncMode.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardPreference } from './useDashboardPreference';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
|
||||
const NOOP = (): void => {};
|
||||
|
||||
export function useDashboardCursorSyncMode(
|
||||
dashboardId: string | undefined,
|
||||
panelMode?: PanelMode,
|
||||
): [DashboardCursorSync, (value: DashboardCursorSync) => void] {
|
||||
const [value, setValue] = useDashboardPreference(
|
||||
dashboardId,
|
||||
'cursorSyncMode',
|
||||
DashboardCursorSync.Crosshair,
|
||||
);
|
||||
|
||||
// Chart panels in edit / standalone modes don't participate in cross-panel
|
||||
// sync, so surface the default with a no-op setter for them. Callers without
|
||||
// a panelMode (e.g. dashboard settings) read/write the preference normally.
|
||||
if (panelMode && panelMode !== PanelMode.DASHBOARD_VIEW) {
|
||||
return [DashboardCursorSync.None, NOOP];
|
||||
}
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
88
frontend/src/hooks/dashboard/useDashboardPreference.ts
Normal file
88
frontend/src/hooks/dashboard/useDashboardPreference.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
// Per-dashboard preferences persisted in localStorage. Add new preference
|
||||
// fields here as they are introduced.
|
||||
export type DashboardPreferences = {
|
||||
cursorSyncMode?: DashboardCursorSync;
|
||||
syncTooltipFilterMode?: SyncTooltipFilterMode;
|
||||
};
|
||||
|
||||
interface DashboardPreferencesState {
|
||||
preferences: Record<string, DashboardPreferences>;
|
||||
setPreference: <K extends keyof DashboardPreferences>(
|
||||
dashboardId: string,
|
||||
key: K,
|
||||
value: NonNullable<DashboardPreferences[K]>,
|
||||
) => void;
|
||||
removePreferences: (dashboardId: string) => void;
|
||||
}
|
||||
|
||||
export const useDashboardPreferencesStore = create<DashboardPreferencesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
preferences: {},
|
||||
setPreference: (dashboardId, key, value): void => {
|
||||
set((state) => ({
|
||||
preferences: {
|
||||
...state.preferences,
|
||||
[dashboardId]: {
|
||||
...state.preferences[dashboardId],
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
removePreferences: (dashboardId): void => {
|
||||
set((state) => {
|
||||
if (!(dashboardId in state.preferences)) {
|
||||
return state;
|
||||
}
|
||||
const { [dashboardId]: _, ...rest } = state.preferences;
|
||||
return { preferences: rest };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ name: LOCALSTORAGE.DASHBOARD_PREFERENCES },
|
||||
),
|
||||
);
|
||||
|
||||
export function useDashboardPreference<K extends keyof DashboardPreferences>(
|
||||
dashboardId: string | undefined,
|
||||
key: K,
|
||||
defaultValue: NonNullable<DashboardPreferences[K]>,
|
||||
): [
|
||||
NonNullable<DashboardPreferences[K]>,
|
||||
(value: NonNullable<DashboardPreferences[K]>) => void,
|
||||
] {
|
||||
type Value = NonNullable<DashboardPreferences[K]>;
|
||||
|
||||
const value = useDashboardPreferencesStore((state): Value => {
|
||||
if (!dashboardId) {
|
||||
return defaultValue;
|
||||
}
|
||||
return (
|
||||
(state.preferences[dashboardId]?.[key] as Value | undefined) ?? defaultValue
|
||||
);
|
||||
});
|
||||
|
||||
const setPreference = useDashboardPreferencesStore((s) => s.setPreference);
|
||||
|
||||
const updateValue = useCallback(
|
||||
(next: Value): void => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
setPreference(dashboardId, key, next);
|
||||
},
|
||||
[dashboardId, key, setPreference],
|
||||
);
|
||||
|
||||
return [value, updateValue];
|
||||
}
|
||||
@@ -5,10 +5,15 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardPreferencesStore } from './useDashboardPreference';
|
||||
|
||||
export const useDeleteDashboard = (
|
||||
id: string,
|
||||
): UseMutationResult<SuccessResponseV2<null>, APIError, void, unknown> => {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const removePreferences = useDashboardPreferencesStore(
|
||||
(state) => state.removePreferences,
|
||||
);
|
||||
|
||||
return useMutation<SuccessResponseV2<null>, APIError>({
|
||||
mutationKey: REACT_QUERY_KEY.DELETE_DASHBOARD,
|
||||
@@ -16,6 +21,9 @@ export const useDeleteDashboard = (
|
||||
deleteDashboard({
|
||||
id,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
removePreferences(id);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
|
||||
15
frontend/src/hooks/dashboard/useSyncTooltipFilterMode.ts
Normal file
15
frontend/src/hooks/dashboard/useSyncTooltipFilterMode.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SyncTooltipFilterMode } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardPreference } from './useDashboardPreference';
|
||||
|
||||
const DEFAULT_SYNC_TOOLTIP_FILTER_MODE = SyncTooltipFilterMode.Filtered;
|
||||
|
||||
export function useSyncTooltipFilterMode(
|
||||
dashboardId: string | undefined,
|
||||
): [SyncTooltipFilterMode, (value: SyncTooltipFilterMode) => void] {
|
||||
return useDashboardPreference(
|
||||
dashboardId,
|
||||
'syncTooltipFilterMode',
|
||||
DEFAULT_SYNC_TOOLTIP_FILTER_MODE,
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
isStackedBarChart: props.isStackedBarChart,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
syncFilterMode: props.syncFilterMode,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -26,6 +27,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
props.decimalPrecision,
|
||||
props.isStackedBarChart,
|
||||
props.syncedSeriesIndexes,
|
||||
props.syncFilterMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function HistogramTooltip(
|
||||
yAxisUnit: props.yAxisUnit ?? '',
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
syncFilterMode: props.syncFilterMode,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -26,6 +27,7 @@ export default function HistogramTooltip(
|
||||
props.yAxisUnit,
|
||||
props.decimalPrecision,
|
||||
props.syncedSeriesIndexes,
|
||||
props.syncFilterMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function TimeSeriesTooltip(
|
||||
yAxisUnit: props.yAxisUnit ?? '',
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
syncFilterMode: props.syncFilterMode,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -26,6 +27,7 @@ export default function TimeSeriesTooltip(
|
||||
props.yAxisUnit,
|
||||
props.decimalPrecision,
|
||||
props.syncedSeriesIndexes,
|
||||
props.syncFilterMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,19 @@ import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { TooltipProps } from '../types';
|
||||
import TooltipFooter from './components/TooltipFooter/TooltipFooter';
|
||||
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
|
||||
import TooltipList from './components/TooltipList/TooltipList';
|
||||
|
||||
import Styles from './Tooltip.module.scss';
|
||||
|
||||
export default function Tooltip({
|
||||
id,
|
||||
uPlotInstance,
|
||||
timezone,
|
||||
content,
|
||||
showTooltipHeader = true,
|
||||
isPinned,
|
||||
canPinTooltip,
|
||||
renderTooltipFooter,
|
||||
dismiss,
|
||||
}: TooltipProps): JSX.Element {
|
||||
const tooltipContent = useMemo(() => content ?? [], [content]);
|
||||
@@ -31,7 +31,9 @@ export default function Tooltip({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, isPinned && Styles.pinned)}
|
||||
className={cx(Styles.container, {
|
||||
[Styles.pinned]: isPinned,
|
||||
})}
|
||||
data-testid="uplot-tooltip-container"
|
||||
>
|
||||
{showHeader && (
|
||||
@@ -46,9 +48,9 @@ export default function Tooltip({
|
||||
|
||||
{showDivider && <span className={Styles.divider} />}
|
||||
|
||||
{showList && <TooltipList content={tooltipContent} />}
|
||||
{showList && <TooltipList id={id} content={tooltipContent} />}
|
||||
|
||||
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
|
||||
{renderTooltipFooter && renderTooltipFooter({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { render, RenderResult, screen } from 'tests/test-utils';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { TooltipContentItem } from '../../types';
|
||||
import { IRenderTooltipFooterArgs, TooltipContentItem } from '../../types';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
type MockVirtuosoProps = {
|
||||
@@ -83,6 +83,7 @@ function createUPlotInstance(cursorIdx: number | null): uPlot {
|
||||
|
||||
function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
|
||||
const defaultProps: TooltipTestProps = {
|
||||
id: 'tooltip-1',
|
||||
uPlotInstance: createUPlotInstance(null),
|
||||
timezone: { value: 'UTC', name: 'UTC', offset: '0', searchIndex: '0' },
|
||||
content: [],
|
||||
@@ -192,63 +193,88 @@ describe('Tooltip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip footer hint', () => {
|
||||
describe('Tooltip renderTooltipFooter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('renders footer with "Press P to pin the tooltip" hint when not pinned', () => {
|
||||
renderTooltip({ isPinned: false, canPinTooltip: true });
|
||||
it('does not render footer content when renderTooltipFooter is not provided', () => {
|
||||
renderTooltip();
|
||||
|
||||
const footer = screen.getByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toBeInTheDocument();
|
||||
expect(footer).toHaveTextContent('Press');
|
||||
expect(footer).toHaveTextContent('P');
|
||||
expect(footer).toHaveTextContent('to pin the tooltip');
|
||||
expect(screen.queryByTestId('custom-tooltip-footer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footer with "Press P or Esc to unpin" hint when pinned', () => {
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true });
|
||||
it('renders content returned by renderTooltipFooter', () => {
|
||||
const renderTooltipFooter = jest.fn(
|
||||
(): JSX.Element => <div data-testid="custom-tooltip-footer">Footer</div>,
|
||||
);
|
||||
|
||||
const footer = screen.getByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toHaveTextContent('Press');
|
||||
expect(footer).toHaveTextContent('P');
|
||||
expect(footer).toHaveTextContent('Esc');
|
||||
expect(footer).toHaveTextContent('to unpin');
|
||||
renderTooltip({ renderTooltipFooter });
|
||||
|
||||
expect(screen.getByTestId('custom-tooltip-footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Unpin button when not pinned', () => {
|
||||
renderTooltip({ isPinned: false, canPinTooltip: true });
|
||||
it('calls renderTooltipFooter with isPinned=false when tooltip is not pinned', () => {
|
||||
const renderTooltipFooter = jest.fn(() => null);
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
|
||||
renderTooltip({ renderTooltipFooter, isPinned: false });
|
||||
|
||||
expect(renderTooltipFooter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isPinned: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders Unpin button when pinned', () => {
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true });
|
||||
it('calls renderTooltipFooter with isPinned=true when tooltip is pinned', () => {
|
||||
const renderTooltipFooter = jest.fn(() => null);
|
||||
|
||||
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
|
||||
expect(unpinBtn).toBeInTheDocument();
|
||||
expect(unpinBtn).toHaveAttribute('aria-label', 'Unpin tooltip');
|
||||
renderTooltip({ renderTooltipFooter, isPinned: true });
|
||||
|
||||
expect(renderTooltipFooter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isPinned: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls dismiss when Unpin button is clicked', async () => {
|
||||
it('calls renderTooltipFooter with the dismiss callback', () => {
|
||||
const dismiss = jest.fn();
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true, dismiss });
|
||||
const renderTooltipFooter = jest.fn(() => null);
|
||||
|
||||
renderTooltip({ renderTooltipFooter, dismiss });
|
||||
|
||||
expect(renderTooltipFooter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dismiss }),
|
||||
);
|
||||
});
|
||||
|
||||
it('footer content reflects pinned state via renderTooltipFooter args', () => {
|
||||
const renderTooltipFooter = jest.fn(
|
||||
({ isPinned }: IRenderTooltipFooterArgs): JSX.Element => (
|
||||
<div data-testid="footer-state">{isPinned ? 'Pinned' : 'Not pinned'}</div>
|
||||
),
|
||||
);
|
||||
|
||||
renderTooltip({ renderTooltipFooter, isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('footer-state')).toHaveTextContent('Pinned');
|
||||
});
|
||||
|
||||
it('dismiss is callable when invoked from renderTooltipFooter', async () => {
|
||||
const dismiss = jest.fn();
|
||||
const renderTooltipFooter = jest.fn(
|
||||
({ dismiss: onDismiss }: IRenderTooltipFooterArgs): JSX.Element => (
|
||||
<button data-testid="dismiss-btn" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
renderTooltip({ renderTooltipFooter, isPinned: true, dismiss });
|
||||
|
||||
const user = userEvent.setup();
|
||||
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
|
||||
await user.click(unpinBtn);
|
||||
await user.click(screen.getByTestId('dismiss-btn'));
|
||||
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('footer has role="status" for screen reader announcements', () => {
|
||||
renderTooltip({ canPinTooltip: true });
|
||||
|
||||
const footer = screen.getByRole('status');
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip header status pill', () => {
|
||||
|
||||
@@ -11,11 +11,10 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.pinnedItem {
|
||||
padding: var(--spacing-4) var(--spacing-4) 0 var(--spacing-4);
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.status {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
.container {
|
||||
padding-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.list {
|
||||
width: 100%;
|
||||
:global(div[data-viewport-type='element']) {
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0px var(--spacing-2) 0 var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-2) var(--spacing-4) var(--spacing-4);
|
||||
|
||||
[data-test-id='virtuoso-item-list'] > * + * {
|
||||
margin-top: var(--spacing-2);
|
||||
|
||||
@@ -9,17 +9,18 @@ import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
|
||||
import Styles from './TooltipList.module.scss';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
// Fallback per-item height before Virtuoso reports the real total.
|
||||
const TOOLTIP_ITEM_HEIGHT = 38;
|
||||
const LIST_MAX_HEIGHT = 300;
|
||||
|
||||
interface TooltipListProps {
|
||||
id: string;
|
||||
content: TooltipContentItem[];
|
||||
}
|
||||
|
||||
export default function TooltipList({
|
||||
id,
|
||||
content,
|
||||
}: TooltipListProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -41,23 +42,25 @@ export default function TooltipList({
|
||||
if (!isScrollEventTriggered.current) {
|
||||
// TODO: remove event in July 2026
|
||||
logEvent(Events.TOOLTIP_CONTENT_SCROLLED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id,
|
||||
});
|
||||
isScrollEventTriggered.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={content}
|
||||
onScroll={handleScroll}
|
||||
style={{ height }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={false} />
|
||||
)}
|
||||
/>
|
||||
<div className={Styles.container}>
|
||||
<Virtuoso
|
||||
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={content}
|
||||
onScroll={handleScroll}
|
||||
style={{ height }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import uPlot, { AlignedData, Series } from 'uplot';
|
||||
|
||||
import { SyncTooltipFilterMode } from '../../plugins/TooltipPlugin/types';
|
||||
import { TooltipContentItem } from '../types';
|
||||
|
||||
export const FALLBACK_SERIES_COLOR = '#000000';
|
||||
@@ -63,6 +64,7 @@ export function buildTooltipContent({
|
||||
decimalPrecision,
|
||||
isStackedBarChart,
|
||||
syncedSeriesIndexes,
|
||||
syncFilterMode,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
series: Series[];
|
||||
@@ -73,10 +75,16 @@ export function buildTooltipContent({
|
||||
decimalPrecision?: PrecisionOption;
|
||||
isStackedBarChart?: boolean;
|
||||
syncedSeriesIndexes?: number[] | null;
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
}): TooltipContentItem[] {
|
||||
const items: TooltipContentItem[] = [];
|
||||
const allowedIndexes =
|
||||
const matchedIndexes =
|
||||
syncedSeriesIndexes != null ? new Set(syncedSeriesIndexes) : null;
|
||||
const filterMode = syncFilterMode ?? SyncTooltipFilterMode.Filtered;
|
||||
// In Filtered mode the matched indexes act as a whitelist; in All mode every
|
||||
// series renders and matched indexes only drive row highlighting.
|
||||
const allowedIndexes =
|
||||
filterMode === SyncTooltipFilterMode.All ? null : matchedIndexes;
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < series.length; seriesIndex += 1) {
|
||||
const seriesItem = series[seriesIndex];
|
||||
@@ -89,6 +97,7 @@ export function buildTooltipContent({
|
||||
|
||||
const dataIndex = dataIndexes[seriesIndex];
|
||||
const isSync = allowedIndexes != null;
|
||||
const isHighlighted = matchedIndexes?.has(seriesIndex) ?? false;
|
||||
|
||||
if (dataIndex === null) {
|
||||
if (isSync) {
|
||||
@@ -98,6 +107,7 @@ export function buildTooltipContent({
|
||||
tooltipValue: 'No Data',
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: false,
|
||||
isHighlighted,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -118,6 +128,7 @@ export function buildTooltipContent({
|
||||
tooltipValue: getToolTipValue(baseValue, yAxisUnit, decimalPrecision),
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: seriesIndex === activeSeriesIndex,
|
||||
isHighlighted,
|
||||
});
|
||||
} else if (isSync) {
|
||||
items.push({
|
||||
@@ -126,6 +137,7 @@ export function buildTooltipContent({
|
||||
tooltipValue: 'No Data',
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: false,
|
||||
isHighlighted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrecisionOption } from 'components/Graph/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
* Props for the Plot component
|
||||
@@ -58,17 +59,31 @@ export interface TooltipRenderArgs {
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
viaSync: boolean;
|
||||
/** In Tooltip sync mode, limits which series are rendered in the receiver tooltip.
|
||||
* null = no filtering; [] = no matches (tooltip hidden upstream); [...] = allowed indexes */
|
||||
/** In Tooltip sync mode, identifies receiver series that match the source's
|
||||
* focused series on the shared groupBy keys.
|
||||
* Filtered mode: limits which series are rendered (null = no filter,
|
||||
* [] = no matches/tooltip hidden upstream, [...] = allowed indexes).
|
||||
* All mode: same indexes are interpreted as a highlight set; non-matching
|
||||
* series still render. */
|
||||
syncedSeriesIndexes?: number[] | null;
|
||||
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
}
|
||||
|
||||
export interface IRenderTooltipFooterArgs {
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
id: string;
|
||||
showTooltipHeader?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
content?: TooltipContentItem[];
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => ReactNode;
|
||||
timezone?: Timezone;
|
||||
}
|
||||
|
||||
@@ -106,4 +121,9 @@ export interface TooltipContentItem {
|
||||
tooltipValue: string;
|
||||
color: string;
|
||||
isActive: boolean;
|
||||
/** Synced receiver series whose metric matches the source's focused series
|
||||
* on the shared groupBy keys, in 'all' filter mode. List rendering uses this
|
||||
* to apply the active highlight to matching rows while non-matching rows
|
||||
* stay dimmed. */
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
DEFAULT_PIN_TOOLTIP_KEY,
|
||||
SyncTooltipFilterMode,
|
||||
TooltipControllerContext,
|
||||
TooltipControllerState,
|
||||
TooltipLayoutInfo,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
import { Events } from 'constants/events';
|
||||
|
||||
import Styles from './TooltipPlugin.module.scss';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
|
||||
// the plot – this avoids flicker when moving between nearby points.
|
||||
@@ -199,10 +199,14 @@ export default function TooltipPlugin({
|
||||
if (!controller.hoverActive || !plot) {
|
||||
return null;
|
||||
}
|
||||
// In Tooltip sync mode, suppress the receiver tooltip entirely when
|
||||
// no receiver series match the source panel's focused series.
|
||||
const filterMode =
|
||||
syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
|
||||
// In Filtered Tooltip sync mode, suppress the receiver tooltip entirely
|
||||
// when no receiver series match the source panel's focused series. In
|
||||
// All mode the tooltip still renders with every series visible.
|
||||
if (
|
||||
syncTooltipWithDashboard &&
|
||||
filterMode === SyncTooltipFilterMode.Filtered &&
|
||||
controller.cursorDrivenBySync &&
|
||||
Array.isArray(controller.syncedSeriesIndexes) &&
|
||||
controller.syncedSeriesIndexes.length === 0
|
||||
@@ -217,6 +221,7 @@ export default function TooltipPlugin({
|
||||
dismiss: dismissTooltip,
|
||||
viaSync: controller.cursorDrivenBySync,
|
||||
syncedSeriesIndexes: controller.syncedSeriesIndexes,
|
||||
syncFilterMode: filterMode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -304,7 +309,7 @@ export default function TooltipPlugin({
|
||||
if (event.key === 'Escape') {
|
||||
if (controller.pinned) {
|
||||
logEvent(Events.TOOLTIP_UNPINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: config.getId(),
|
||||
});
|
||||
dismissTooltip();
|
||||
}
|
||||
@@ -318,7 +323,7 @@ export default function TooltipPlugin({
|
||||
// Toggle off: P pressed while already pinned.
|
||||
if (controller.pinned) {
|
||||
logEvent(Events.TOOLTIP_UNPINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: config.getId(),
|
||||
});
|
||||
dismissTooltip();
|
||||
return;
|
||||
@@ -352,7 +357,7 @@ export default function TooltipPlugin({
|
||||
controller.clickData = buildClickData(syntheticEvent, plot);
|
||||
controller.pinned = true;
|
||||
logEvent(Events.TOOLTIP_PINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: config.getId(),
|
||||
});
|
||||
scheduleRender(true);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,33 @@ import uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { syncCursorRegistry } from './syncCursorRegistry';
|
||||
import type { TooltipControllerState, TooltipSyncMetadata } from './types';
|
||||
import {
|
||||
SyncTooltipFilterMode,
|
||||
type TooltipControllerState,
|
||||
type TooltipSyncMetadata,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Returns the dimension keys present in both groupBy arrays.
|
||||
* Flattens per-query groupBys into a deduped set of dimension keys.
|
||||
* A panel's effective groupBy is the union across all of its queries.
|
||||
*/
|
||||
function collectGroupByKeys(
|
||||
groupByPerQuery: TooltipSyncMetadata['groupByPerQuery'],
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
if (!groupByPerQuery) {
|
||||
return keys;
|
||||
}
|
||||
for (const groupBy of Object.values(groupByPerQuery)) {
|
||||
for (const dim of groupBy) {
|
||||
keys.add(dim.key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dimension keys present in both panels' groupBys.
|
||||
* An empty result means no overlap — series highlighting should not run.
|
||||
*
|
||||
* exact [A, B] vs [A, B] → [A, B] one match
|
||||
@@ -14,24 +37,28 @@ import type { TooltipControllerState, TooltipSyncMetadata } from './types';
|
||||
* partial [A, B] vs [B, C] → [B]
|
||||
*/
|
||||
function getCommonGroupByKeys(
|
||||
a: TooltipSyncMetadata['groupBy'],
|
||||
b: TooltipSyncMetadata['groupBy'],
|
||||
a: TooltipSyncMetadata['groupByPerQuery'],
|
||||
b: TooltipSyncMetadata['groupByPerQuery'],
|
||||
): string[] {
|
||||
if (
|
||||
!Array.isArray(a) ||
|
||||
a.length === 0 ||
|
||||
!Array.isArray(b) ||
|
||||
b.length === 0
|
||||
) {
|
||||
const aKeys = collectGroupByKeys(a);
|
||||
const bKeys = collectGroupByKeys(b);
|
||||
if (aKeys.size === 0 || bKeys.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const bKeys = new Set(b.map((g) => g.key));
|
||||
return a.filter((g) => bKeys.has(g.key)).map((g) => g.key);
|
||||
const common: string[] = [];
|
||||
aKeys.forEach((key) => {
|
||||
if (bKeys.has(key)) {
|
||||
common.push(key);
|
||||
}
|
||||
});
|
||||
return common;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 1-based indexes of every series whose metric matches
|
||||
* sourceMetric on all commonKeys.
|
||||
* Returns the 1-based indexes of every visible series whose metric matches
|
||||
* sourceMetric on all commonKeys. Hidden series (toggled off in the legend)
|
||||
* are excluded so the synced tooltip is suppressed when no visible series
|
||||
* would match.
|
||||
*/
|
||||
function findMatchingSeriesIndexes(
|
||||
series: uPlot.Series[],
|
||||
@@ -39,7 +66,7 @@ function findMatchingSeriesIndexes(
|
||||
commonKeys: string[],
|
||||
): number[] {
|
||||
return series.reduce<number[]>((acc, s, i) => {
|
||||
if (i === 0) {
|
||||
if (i === 0 || s.show === false) {
|
||||
return acc;
|
||||
}
|
||||
const metric = (s as ExtendedSeries).metric;
|
||||
@@ -76,10 +103,15 @@ function applySourceSync({
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns:
|
||||
* null – no groupBy filtering configured or cursor off-chart (no-op for tooltip)
|
||||
* [] – groupBy configured but no receiver series match the source (hide synced tooltip)
|
||||
* number[] – 1-based indexes of matching receiver series (show only these)
|
||||
* Computes receiver-side series filtering / highlighting for Tooltip sync.
|
||||
*
|
||||
* Returns the indexes that the tooltip render path should treat per
|
||||
* `syncMetadata.filterMode`:
|
||||
* - Filtered (default): null = no filter, [] = no matches (suppress tooltip),
|
||||
* number[] = allowed indexes (show only these).
|
||||
* - All: null = no highlight (show all), number[] = highlight set (show all,
|
||||
* emphasize matching rows). Never returns [] in this mode so the synced
|
||||
* tooltip is not suppressed when matches are missing.
|
||||
*/
|
||||
function applyReceiverSync({
|
||||
uPlotInstance,
|
||||
@@ -99,8 +131,13 @@ function applyReceiverSync({
|
||||
yCrosshairEl.style.display =
|
||||
sourceMetadata?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
|
||||
|
||||
const filterMode = syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
|
||||
const noMatchResult: number[] | null =
|
||||
filterMode === SyncTooltipFilterMode.All ? null : [];
|
||||
|
||||
if (commonKeys.length === 0) {
|
||||
return null;
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
}
|
||||
|
||||
if ((uPlotInstance.cursor.left ?? -1) < 0) {
|
||||
@@ -111,7 +148,7 @@ function applyReceiverSync({
|
||||
const sourceSeriesMetric = syncCursorRegistry.getActiveSeriesMetric(syncKey);
|
||||
if (sourceSeriesMetric == null) {
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
return noMatchResult;
|
||||
}
|
||||
|
||||
const matchingIdxs = findMatchingSeriesIndexes(
|
||||
@@ -122,7 +159,7 @@ function applyReceiverSync({
|
||||
|
||||
if (matchingIdxs.length === 0) {
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
return noMatchResult;
|
||||
}
|
||||
|
||||
uPlotInstance.setSeries(matchingIdxs[0], { focus: true });
|
||||
@@ -140,7 +177,7 @@ export function createSyncDisplayHook(
|
||||
|
||||
// groupBy on both panels is stable (set at config time). Recompute the
|
||||
// intersection only when the source panel's groupBy reference changes.
|
||||
let lastSourceGroupBy: TooltipSyncMetadata['groupBy'];
|
||||
let lastSourceGroupBy: TooltipSyncMetadata['groupByPerQuery'];
|
||||
let cachedCommonKeys: string[] = [];
|
||||
|
||||
return (u: uPlot): void => {
|
||||
@@ -165,11 +202,11 @@ export function createSyncDisplayHook(
|
||||
// inside applyReceiverSync.
|
||||
const sourceMetadata = syncCursorRegistry.getMetadata(syncKey);
|
||||
|
||||
if (sourceMetadata?.groupBy !== lastSourceGroupBy) {
|
||||
lastSourceGroupBy = sourceMetadata?.groupBy;
|
||||
if (sourceMetadata?.groupByPerQuery !== lastSourceGroupBy) {
|
||||
lastSourceGroupBy = sourceMetadata?.groupByPerQuery;
|
||||
cachedCommonKeys = getCommonGroupByKeys(
|
||||
sourceMetadata?.groupBy,
|
||||
syncMetadata?.groupBy,
|
||||
sourceMetadata?.groupByPerQuery,
|
||||
syncMetadata?.groupByPerQuery,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,18 @@ export const TOOLTIP_OFFSET = 10;
|
||||
export const DEFAULT_PIN_TOOLTIP_KEY = 'p';
|
||||
|
||||
export enum DashboardCursorSync {
|
||||
Crosshair,
|
||||
None,
|
||||
Tooltip,
|
||||
Crosshair = 'crosshair',
|
||||
None = 'none',
|
||||
Tooltip = 'tooltip',
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls whether a synced tooltip filters series by groupBy intersection
|
||||
* or shows every series with the matching ones highlighted.
|
||||
*/
|
||||
export enum SyncTooltipFilterMode {
|
||||
Filtered = 'filtered',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
export interface TooltipViewState {
|
||||
@@ -40,7 +49,8 @@ export interface TooltipLayoutInfo {
|
||||
|
||||
export interface TooltipSyncMetadata {
|
||||
yAxisUnit?: string;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
filterMode?: SyncTooltipFilterMode;
|
||||
}
|
||||
|
||||
export interface TooltipPluginProps {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { syncCursorRegistry } from '../TooltipPlugin/syncCursorRegistry';
|
||||
import { createSyncDisplayHook } from '../TooltipPlugin/syncDisplayHook';
|
||||
import type {
|
||||
TooltipControllerState,
|
||||
TooltipSyncMetadata,
|
||||
} from '../TooltipPlugin/types';
|
||||
|
||||
const SYNC_KEY = 'test-sync';
|
||||
|
||||
function makeController(): TooltipControllerState {
|
||||
return {
|
||||
plot: null,
|
||||
hoverActive: false,
|
||||
isAnySeriesActive: false,
|
||||
pinned: false,
|
||||
clickData: null,
|
||||
style: {},
|
||||
horizontalOffset: 0,
|
||||
verticalOffset: 0,
|
||||
seriesIndexes: [],
|
||||
focusedSeriesIndex: null,
|
||||
syncedSeriesIndexes: null,
|
||||
cursorDrivenBySync: false,
|
||||
plotWithinViewport: true,
|
||||
windowWidth: 1024,
|
||||
windowHeight: 768,
|
||||
pendingPinnedUpdate: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakePlot(
|
||||
series: ExtendedSeries[],
|
||||
cursorEvent: Record<string, unknown> | null = null,
|
||||
): uPlot {
|
||||
const root = document.createElement('div');
|
||||
const yCrosshair = document.createElement('div');
|
||||
yCrosshair.className = 'u-cursor-y';
|
||||
root.appendChild(yCrosshair);
|
||||
|
||||
return {
|
||||
root,
|
||||
series,
|
||||
cursor: { event: cursorEvent, left: 50 },
|
||||
setSeries: jest.fn(),
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
const SERVICE_NAME_KEY: BaseAutocompleteData = {
|
||||
key: 'service.name',
|
||||
type: 'tag',
|
||||
};
|
||||
|
||||
const groupByService: TooltipSyncMetadata = {
|
||||
groupByPerQuery: { queryName: [SERVICE_NAME_KEY] },
|
||||
};
|
||||
|
||||
function seedSourcePanel(activeMetric: Record<string, string>): void {
|
||||
syncCursorRegistry.setMetadata(SYNC_KEY, groupByService);
|
||||
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, activeMetric);
|
||||
}
|
||||
|
||||
function makeReceiverSeries(
|
||||
entries: { name: string; show?: boolean }[],
|
||||
): ExtendedSeries[] {
|
||||
return [
|
||||
{} as ExtendedSeries,
|
||||
...entries.map(
|
||||
(e) =>
|
||||
({
|
||||
show: e.show ?? true,
|
||||
metric: { 'service.name': e.name },
|
||||
}) as unknown as ExtendedSeries,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
describe('createSyncDisplayHook (receiver-side filtering)', () => {
|
||||
beforeEach(() => {
|
||||
syncCursorRegistry.setMetadata(SYNC_KEY, undefined);
|
||||
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, null);
|
||||
});
|
||||
|
||||
it('returns indexes of visible matching series only', () => {
|
||||
seedSourcePanel({ 'service.name': 'flagd' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: true },
|
||||
{ name: 'frontend', show: true },
|
||||
{ name: 'flagd', show: true },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('treats all matching series being hidden as no match → empty array', () => {
|
||||
seedSourcePanel({ 'service.name': 'frontendproxy' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: true },
|
||||
{ name: 'frontendproxy', show: false },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(null, { focus: false });
|
||||
});
|
||||
|
||||
it('excludes hidden series and keeps the visible matches', () => {
|
||||
seedSourcePanel({ 'service.name': 'flagd' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: false },
|
||||
{ name: 'frontend', show: true },
|
||||
{ name: 'flagd', show: true },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([3]);
|
||||
// Focuses the first visible match, not the hidden one at index 1.
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(3, { focus: true });
|
||||
});
|
||||
|
||||
it('returns null (no filtering) when the hook runs on the source panel', () => {
|
||||
const series = makeReceiverSeries([{ name: 'flagd', show: true }]);
|
||||
// cursor.event != null marks this invocation as the source panel.
|
||||
const plot = makeFakePlot(series, { type: 'mousemove' });
|
||||
const controller = makeController();
|
||||
controller.focusedSeriesIndex = 1;
|
||||
(series[1] as ExtendedSeries).metric = { 'service.name': 'flagd' };
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
expect(syncCursorRegistry.getActiveSeriesMetric(SYNC_KEY)).toStrictEqual({
|
||||
'service.name': 'flagd',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
/* eslint-disable */
|
||||
//@ts-nocheck
|
||||
import { memo } from 'react';
|
||||
import ForceGraph2D from 'react-force-graph-2d';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { getGraphData, getTooltip, transformLabel } from './utils';
|
||||
|
||||
function ServiceMap({ fgRef, serviceMap }: any): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { nodes, links } = getGraphData(serviceMap, isDarkMode);
|
||||
|
||||
const graphData = { nodes, links };
|
||||
|
||||
let zoomLevel = 1;
|
||||
|
||||
return (
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
cooldownTicks={100}
|
||||
graphData={graphData}
|
||||
linkLabel={getTooltip}
|
||||
linkAutoColorBy={(d) => d.target}
|
||||
linkDirectionalParticles="value"
|
||||
linkDirectionalParticleSpeed={(d) => d.value}
|
||||
nodeCanvasObject={(node, ctx) => {
|
||||
const label = transformLabel(node.id, zoomLevel);
|
||||
let { fontSize } = node;
|
||||
fontSize = (fontSize * 3) / zoomLevel;
|
||||
ctx.font = `${fontSize}px Roboto`;
|
||||
const { width } = node;
|
||||
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, width, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = isDarkMode ? '#ffffff' : '#000000';
|
||||
ctx.fillText(label, node.x, node.y);
|
||||
}}
|
||||
onLinkHover={(node) => {
|
||||
const tooltip = document.querySelector('.graph-tooltip');
|
||||
if (tooltip && node) {
|
||||
tooltip.innerHTML = getTooltip(node);
|
||||
}
|
||||
}}
|
||||
onZoom={(zoom) => {
|
||||
zoomLevel = zoom.k;
|
||||
}}
|
||||
nodePointerAreaPaint={(node, color, ctx) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, 5, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ServiceMap);
|
||||
@@ -1,6 +1,6 @@
|
||||
//@ts-nocheck
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { connect } from 'react-redux';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
@@ -16,27 +16,9 @@ import { AppState } from 'store/reducers';
|
||||
import styled from 'styled-components';
|
||||
import { GlobalTime } from 'types/actions/globalTime';
|
||||
|
||||
import Map from './Map';
|
||||
import Map from './components/Map/Map';
|
||||
|
||||
const Container = styled.div`
|
||||
.force-graph-container {
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.force-graph-container .graph-tooltip {
|
||||
background: black;
|
||||
padding: 1px;
|
||||
.keyval {
|
||||
display: flex;
|
||||
.key {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.val {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const Container = styled.div``;
|
||||
|
||||
interface ServiceMapProps extends RouteComponentProps<any> {
|
||||
serviceMap: ServiceMapStore;
|
||||
@@ -48,7 +30,8 @@ interface ServiceMapProps extends RouteComponentProps<any> {
|
||||
}
|
||||
interface graphNode {
|
||||
id: string;
|
||||
group: number;
|
||||
color: string;
|
||||
name: string;
|
||||
}
|
||||
interface graphLink {
|
||||
source: string;
|
||||
@@ -64,8 +47,6 @@ export interface graphDataType {
|
||||
}
|
||||
|
||||
function ServiceMap(props: ServiceMapProps): JSX.Element {
|
||||
const fgRef = useRef();
|
||||
|
||||
const { getDetailedServiceMapItems, globalTime, serviceMap } = props;
|
||||
|
||||
const { queries } = useResourceAttribute();
|
||||
@@ -78,10 +59,6 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
|
||||
getDetailedServiceMapItems(globalTime, queries);
|
||||
}, [globalTime, getDetailedServiceMapItems, queries]);
|
||||
|
||||
useEffect(() => {
|
||||
fgRef.current && fgRef.current.d3Force('charge').strength(-400);
|
||||
});
|
||||
|
||||
if (serviceMap.loading) {
|
||||
return <Spinner size="large" tip="Loading..." />;
|
||||
}
|
||||
@@ -108,7 +85,7 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
|
||||
}
|
||||
/>
|
||||
|
||||
<Map fgRef={fgRef} serviceMap={serviceMap} />
|
||||
<Map serviceMap={serviceMap} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { BaseEdge, Edge, EdgeProps, getBezierPath } from '@xyflow/react';
|
||||
|
||||
import { getParticleAnimation } from '../Map/Map.constants';
|
||||
|
||||
export interface FlowEdgeData extends Record<string, unknown> {
|
||||
p99: number;
|
||||
callRate: number;
|
||||
errorRate: number;
|
||||
particleColor: string;
|
||||
maxCallRate: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PARTICLE_COLOR = 'var(--accent-primary)';
|
||||
|
||||
function FlowEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
markerEnd,
|
||||
data,
|
||||
}: EdgeProps<Edge<FlowEdgeData>>): JSX.Element {
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
// Particles flow callee -> caller (child -> parent), opposite to the edge's
|
||||
// source -> target direction. Computing a reversed bezier instead of just
|
||||
// playing the same path backward keeps the curve handles correct on both
|
||||
// ends and avoids relying on `keyPoints`/`calcMode` quirks.
|
||||
const [particlePath] = getBezierPath({
|
||||
sourceX: targetX,
|
||||
sourceY: targetY,
|
||||
targetX: sourceX,
|
||||
targetY: sourceY,
|
||||
sourcePosition: targetPosition,
|
||||
targetPosition: sourcePosition,
|
||||
});
|
||||
|
||||
const callRate = data?.callRate ?? 0;
|
||||
const maxCallRate = data?.maxCallRate ?? 0;
|
||||
const { particleCount, duration } = getParticleAnimation(
|
||||
callRate,
|
||||
maxCallRate,
|
||||
);
|
||||
const fill = data?.particleColor || DEFAULT_PARTICLE_COLOR;
|
||||
|
||||
// Stagger each particle's `begin` so they're evenly distributed around the
|
||||
// loop; the result is a continuous moving stream rather than synchronized
|
||||
// dots stacking on top of each other.
|
||||
const particles = Array.from({ length: particleCount }, (_, i) => {
|
||||
const offset = (duration * i) / particleCount;
|
||||
return (
|
||||
<circle
|
||||
key={`${id}-p${i}`}
|
||||
className="flow-edge__particle"
|
||||
r={2.75}
|
||||
fill={fill}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<animateMotion
|
||||
dur={`${duration}s`}
|
||||
begin={`-${offset.toFixed(3)}s`}
|
||||
repeatCount="indefinite"
|
||||
path={particlePath}
|
||||
rotate="auto"
|
||||
/>
|
||||
</circle>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
|
||||
{particles}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowEdge;
|
||||
@@ -0,0 +1,27 @@
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
padding: 12px;
|
||||
min-width: 160px;
|
||||
font-size: 12px;
|
||||
font-family: Inter, sans-serif;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
color: var(--popover-foreground);
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary-border);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import styles from './LinkTooltip.module.scss';
|
||||
|
||||
export interface LinkTooltipData {
|
||||
p99: string | number;
|
||||
callRate: string | number;
|
||||
errorRate: string | number;
|
||||
}
|
||||
|
||||
export interface LinkTooltipProps {
|
||||
tooltip: LinkTooltipData;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const POINTER_OFFSET = 12;
|
||||
|
||||
function LinkTooltip({ tooltip, x, y }: LinkTooltipProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{ top: y + POINTER_OFFSET, left: x + POINTER_OFFSET }}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>P99 latency:</span>
|
||||
<span className={styles.value}>{tooltip.p99}ms</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Request:</span>
|
||||
<span className={styles.value}>{tooltip.callRate}/sec</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Error Rate:</span>
|
||||
<span className={styles.value}>{tooltip.errorRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkTooltip;
|
||||
@@ -0,0 +1,41 @@
|
||||
// Geometry of a service node as drawn on the map. The dagre layout uses a
|
||||
// taller bounding box (label + circle) than the circle itself, so the outer
|
||||
// height is exposed for the position centering calc.
|
||||
export const NODE_DIAMETER = 64;
|
||||
export const LABEL_HEIGHT = 18;
|
||||
export const NODE_LABEL_GAP = 6;
|
||||
export const NODE_OUTER_HEIGHT = NODE_DIAMETER + LABEL_HEIGHT + NODE_LABEL_GAP;
|
||||
|
||||
// Per-edge animated stream of dots. Speed and particle count scale with the
|
||||
// edge's call rate *relative to the busiest edge in the current graph*, on a
|
||||
// log10 ladder. The busiest edge always pegs the fastest/most-dense
|
||||
// visualisation; the slowest gets a single drifting particle. This keeps the
|
||||
// stream legible whether the busiest service handles 5 req/sec or 5k.
|
||||
export const PARTICLE_FAST_SECS = 0.6;
|
||||
export const PARTICLE_SLOW_SECS = 5;
|
||||
export const MAX_PARTICLES = 8;
|
||||
|
||||
// Compute particle count + per-loop duration for an edge's call rate, scaled
|
||||
// against the max call rate observed across the graph. Pure so it can be
|
||||
// unit-tested without rendering the edge.
|
||||
export function getParticleAnimation(
|
||||
callRate: number,
|
||||
maxCallRate: number,
|
||||
): { particleCount: number; duration: number } {
|
||||
if (callRate <= 0) {
|
||||
return { particleCount: 0, duration: PARTICLE_SLOW_SECS };
|
||||
}
|
||||
// Defensive: if a stale/zero max sneaks in, treat this edge as the max so
|
||||
// `factor` stays in [0, 1] rather than going to Infinity or NaN.
|
||||
const effectiveMax = Math.max(maxCallRate, callRate);
|
||||
const logRate = Math.log10(callRate + 1);
|
||||
const logMax = Math.log10(effectiveMax + 1);
|
||||
const factor = logMax > 0 ? logRate / logMax : 1;
|
||||
const duration =
|
||||
PARTICLE_SLOW_SECS - factor * (PARTICLE_SLOW_SECS - PARTICLE_FAST_SECS);
|
||||
const particleCount = Math.max(
|
||||
1,
|
||||
Math.min(MAX_PARTICLES, Math.ceil(factor * MAX_PARTICLES)),
|
||||
);
|
||||
return { particleCount, duration };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 124px);
|
||||
position: relative;
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
// ReactFlow defaults edge pointer-events to `visibleStroke`, which means
|
||||
// our thin dashed line only captures hover on the painted dash segments.
|
||||
// Force `stroke` on the wide invisible interaction path so the entire edge
|
||||
// length is hoverable for the tooltip.
|
||||
:global(.react-flow__edge-interaction) {
|
||||
pointer-events: stroke;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:global(.react-flow__edge) {
|
||||
pointer-events: stroke;
|
||||
}
|
||||
}
|
||||
185
frontend/src/modules/Servicemap/components/Map/Map.tsx
Normal file
185
frontend/src/modules/Servicemap/components/Map/Map.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
Edge,
|
||||
Node,
|
||||
ReactFlow,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
} from '@xyflow/react';
|
||||
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
|
||||
import { NODE_DIAMETER, NODE_OUTER_HEIGHT } from './Map.constants';
|
||||
import styles from './Map.module.scss';
|
||||
import ServiceNode, { ServiceNodeData } from '../ServiceNode/ServiceNode';
|
||||
import LinkTooltip from '../LinkTooltip/LinkTooltip';
|
||||
import {
|
||||
computeNodePositions,
|
||||
getEdgeColor,
|
||||
getGraphData,
|
||||
getLinkTooltip,
|
||||
LinkTooltip as LinkTooltipData,
|
||||
} from '../../utils';
|
||||
|
||||
const nodeTypes = { service: ServiceNode };
|
||||
const edgeTypes = { flow: FlowEdge };
|
||||
|
||||
const PARTICLE_COLOR = 'var(--l1-foreground)';
|
||||
const BG_COLOR = 'var(--l2-background)';
|
||||
|
||||
const BASE_EDGE_STYLE = {
|
||||
strokeWidth: 1.25,
|
||||
strokeDasharray: '5 4',
|
||||
};
|
||||
interface HoverState {
|
||||
tooltip: LinkTooltipData;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function ServiceMap({ serviceMap }: any): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [hovered, setHovered] = useState<HoverState | null>(null);
|
||||
|
||||
const { nodes: rawNodes, links } = useMemo(
|
||||
() => getGraphData(serviceMap, isDarkMode),
|
||||
[serviceMap, isDarkMode],
|
||||
);
|
||||
|
||||
const positions = useMemo(
|
||||
() => computeNodePositions(rawNodes, links),
|
||||
[rawNodes, links],
|
||||
);
|
||||
|
||||
const initialNodes: Node<ServiceNodeData>[] = useMemo(
|
||||
() =>
|
||||
rawNodes.map((node) => {
|
||||
const center = positions[node.id] ?? { x: 0, y: 0 };
|
||||
return {
|
||||
id: node.id,
|
||||
type: 'service',
|
||||
// `position` is the top-left of the node bounding box; centre the
|
||||
// circle on the simulated coordinate.
|
||||
position: {
|
||||
x: center.x - NODE_DIAMETER / 2,
|
||||
y: center.y - NODE_OUTER_HEIGHT / 2,
|
||||
},
|
||||
data: { label: node.id, color: node.color },
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
};
|
||||
}),
|
||||
[rawNodes, positions],
|
||||
);
|
||||
|
||||
// Particle visualisation is scaled relative to the busiest edge in the
|
||||
// current graph, so each render of the edge layer needs the per-graph max.
|
||||
const maxCallRate = useMemo(
|
||||
() => links.reduce((max, link) => Math.max(max, link.callRate ?? 0), 0),
|
||||
[links],
|
||||
);
|
||||
|
||||
const initialEdges: Edge<FlowEdgeData>[] = useMemo(
|
||||
() =>
|
||||
links.map((link, i) => ({
|
||||
id: `${link.source}->${link.target}-${i}`,
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
type: 'flow',
|
||||
data: {
|
||||
p99: link.p99,
|
||||
callRate: link.callRate,
|
||||
errorRate: link.errorRate,
|
||||
particleColor: PARTICLE_COLOR,
|
||||
maxCallRate,
|
||||
},
|
||||
// markerEnd: EDGE_MARKER,
|
||||
// Stroke is hashed off the target so edges sharing a destination read
|
||||
// as a single visual fan-in (matches the pre-rewrite behaviour).
|
||||
style: { ...BASE_EDGE_STYLE, stroke: getEdgeColor(link.target) },
|
||||
})),
|
||||
[links, maxCallRate],
|
||||
);
|
||||
|
||||
const [flowNodes, setFlowNodes, onNodesChange] =
|
||||
useNodesState<Node<ServiceNodeData>>(initialNodes);
|
||||
const [flowEdges, setFlowEdges, onEdgesChange] =
|
||||
useEdgesState<Edge<FlowEdgeData>>(initialEdges);
|
||||
|
||||
// Reset internal node/edge state when the source graph changes (filters,
|
||||
// time range, theme). User drag positions during a stable graph are kept.
|
||||
useEffect(() => {
|
||||
setFlowNodes(initialNodes);
|
||||
}, [initialNodes, setFlowNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setFlowEdges(initialEdges);
|
||||
}, [initialEdges, setFlowEdges]);
|
||||
|
||||
const handleEdgeMouseEnter = (event: React.MouseEvent, edge: Edge): void => {
|
||||
setHovered({
|
||||
tooltip: getLinkTooltip(edge.data as any),
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdgeMouseMove = (event: React.MouseEvent, edge: Edge): void => {
|
||||
setHovered((prev) =>
|
||||
prev
|
||||
? { ...prev, x: event.clientX, y: event.clientY }
|
||||
: {
|
||||
tooltip: getLinkTooltip(edge.data as any),
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleEdgeMouseLeave = (): void => {
|
||||
setHovered(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
minZoom={0.2}
|
||||
maxZoom={4}
|
||||
nodesDraggable
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode={isDarkMode ? 'dark' : 'light'}
|
||||
onEdgeMouseEnter={handleEdgeMouseEnter}
|
||||
onEdgeMouseMove={handleEdgeMouseMove}
|
||||
onEdgeMouseLeave={handleEdgeMouseLeave}
|
||||
>
|
||||
<Background
|
||||
bgColor={BG_COLOR}
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
size={1}
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
{hovered && (
|
||||
<LinkTooltip tooltip={hovered.tooltip} x={hovered.x} y={hovered.y} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ServiceMap);
|
||||
@@ -0,0 +1,39 @@
|
||||
.node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.circle {
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--l3-border);
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.handle {
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: 6px;
|
||||
max-width: 120px;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter, sans-serif;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Handle, Node, NodeProps, Position } from '@xyflow/react';
|
||||
import { Cpu } from 'lucide-react';
|
||||
|
||||
import { NODE_DIAMETER } from '../Map/Map.constants';
|
||||
import styles from './ServiceNode.module.scss';
|
||||
|
||||
// Icon takes ~35% of the circle diameter — large enough to read at typical
|
||||
// zoom, small enough to leave the colored ring visible as the health signal.
|
||||
const ICON_SIZE = Math.round(NODE_DIAMETER * 0.35);
|
||||
|
||||
export interface ServiceNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function ServiceNode({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Element {
|
||||
return (
|
||||
<div className={styles.node}>
|
||||
<div
|
||||
className={styles.circle}
|
||||
style={{
|
||||
width: NODE_DIAMETER,
|
||||
height: NODE_DIAMETER,
|
||||
background: data.color,
|
||||
}}
|
||||
>
|
||||
<Cpu size={ICON_SIZE} strokeWidth={1.5} aria-hidden />
|
||||
<Handle type="target" position={Position.Left} className={styles.handle} />
|
||||
<Handle type="source" position={Position.Right} className={styles.handle} />
|
||||
</div>
|
||||
<div className={styles.label} title={data.label}>
|
||||
{data.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceNode;
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Position } from '@xyflow/react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
|
||||
|
||||
// Stub BaseEdge / getBezierPath so assertions don't depend on the internal
|
||||
// path geometry — we only care that FlowEdge wires its inputs through and
|
||||
// renders the right number of particles for the given call rate.
|
||||
jest.mock('@xyflow/react', () => {
|
||||
const actual = jest.requireActual('@xyflow/react');
|
||||
return {
|
||||
...actual,
|
||||
BaseEdge: ({
|
||||
id,
|
||||
path,
|
||||
style,
|
||||
markerEnd,
|
||||
}: {
|
||||
id: string;
|
||||
path: string;
|
||||
style?: React.CSSProperties;
|
||||
markerEnd?: string;
|
||||
}): JSX.Element => (
|
||||
<path
|
||||
data-testid="base-edge"
|
||||
data-id={id}
|
||||
data-path={path}
|
||||
data-marker-end={markerEnd ?? ''}
|
||||
style={style}
|
||||
/>
|
||||
),
|
||||
// Encode the inputs into the returned path so tests can distinguish
|
||||
// between the forward edge path and the reversed particle path.
|
||||
getBezierPath: ({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
}: {
|
||||
sourceX: number;
|
||||
sourceY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
}): [string, number, number, number, number] => [
|
||||
`M${sourceX},${sourceY} L${targetX},${targetY}`,
|
||||
(sourceX + targetX) / 2,
|
||||
(sourceY + targetY) / 2,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const baseEdgeProps = {
|
||||
id: 'edge-1',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
targetX: 100,
|
||||
targetY: 0,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
style: { stroke: '#000' },
|
||||
markerEnd: 'url(#arrow)',
|
||||
} as const;
|
||||
|
||||
function renderEdge(data: FlowEdgeData | undefined): ReturnType<typeof render> {
|
||||
return render(<FlowEdge {...(baseEdgeProps as any)} data={data} />);
|
||||
}
|
||||
|
||||
const SAMPLE_DATA: FlowEdgeData = {
|
||||
p99: 1000000,
|
||||
callRate: 25,
|
||||
errorRate: 0,
|
||||
particleColor: 'rgb(0, 200, 0)',
|
||||
maxCallRate: 1000,
|
||||
};
|
||||
|
||||
describe('FlowEdge', () => {
|
||||
it('forwards id, path, style, and markerEnd to BaseEdge', () => {
|
||||
const { getByTestId } = renderEdge(SAMPLE_DATA);
|
||||
|
||||
const baseEdge = getByTestId('base-edge');
|
||||
expect(baseEdge).toHaveAttribute('data-id', 'edge-1');
|
||||
// BaseEdge gets the forward path (source -> target).
|
||||
expect(baseEdge).toHaveAttribute('data-path', 'M0,0 L100,0');
|
||||
expect(baseEdge).toHaveAttribute('data-marker-end', 'url(#arrow)');
|
||||
expect(baseEdge).toHaveStyle({ stroke: '#000' });
|
||||
});
|
||||
|
||||
it('renders no particles when callRate is zero', () => {
|
||||
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 0 });
|
||||
|
||||
expect(container.querySelectorAll('circle')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders no particles when data is missing', () => {
|
||||
const { container } = renderEdge(undefined);
|
||||
|
||||
expect(container.querySelectorAll('circle')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders multiple staggered particles for mid-range traffic', () => {
|
||||
// callRate=25 against maxCallRate=1000:
|
||||
// factor = log10(26) / log10(1001) ≈ 0.4716
|
||||
// particleCount = ceil(0.4716 * 8) = 4
|
||||
const { container } = renderEdge({
|
||||
...SAMPLE_DATA,
|
||||
callRate: 25,
|
||||
maxCallRate: 1000,
|
||||
});
|
||||
|
||||
const circles = container.querySelectorAll('circle');
|
||||
expect(circles).toHaveLength(4);
|
||||
|
||||
// Each particle's animateMotion `begin` should be a distinct negative
|
||||
// offset; identical offsets would stack particles on top of each other.
|
||||
const begins = Array.from(container.querySelectorAll('animateMotion')).map(
|
||||
(el) => el.getAttribute('begin'),
|
||||
);
|
||||
expect(new Set(begins).size).toBe(begins.length);
|
||||
begins.forEach((begin) => {
|
||||
expect(begin).toMatch(/^-\d+\.\d{3}s$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('saturates at MAX_PARTICLES (8) when the edge is the busiest in the graph', () => {
|
||||
// Relative scaling: whichever absolute rate is the max pegs at 8.
|
||||
const { container } = renderEdge({
|
||||
...SAMPLE_DATA,
|
||||
callRate: 50,
|
||||
maxCallRate: 50,
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll('circle')).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('uses data.particleColor as the particle fill', () => {
|
||||
const { container } = renderEdge({
|
||||
...SAMPLE_DATA,
|
||||
callRate: 5,
|
||||
particleColor: 'rgb(123, 45, 67)',
|
||||
});
|
||||
|
||||
const circle = container.querySelector('circle');
|
||||
expect(circle).toHaveAttribute('fill', 'rgb(123, 45, 67)');
|
||||
});
|
||||
|
||||
it('falls back to the default particle color when particleColor is empty', () => {
|
||||
const { container } = renderEdge({
|
||||
...SAMPLE_DATA,
|
||||
callRate: 5,
|
||||
particleColor: '',
|
||||
});
|
||||
|
||||
const circle = container.querySelector('circle');
|
||||
expect(circle).toHaveAttribute('fill', 'var(--accent-primary)');
|
||||
});
|
||||
|
||||
it('marks particles as non-interactive so they do not show a grab cursor', () => {
|
||||
// Without pointer-events:none, react-flow's edge layer cursor (grab)
|
||||
// cascades onto the SVG circles. Particles are decorative.
|
||||
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 5 });
|
||||
|
||||
const circles = container.querySelectorAll('circle');
|
||||
expect(circles.length).toBeGreaterThan(0);
|
||||
circles.forEach((circle) => {
|
||||
expect(circle).toHaveAttribute('pointer-events', 'none');
|
||||
});
|
||||
});
|
||||
|
||||
it('animates particles along the reversed path so they flow callee -> caller', () => {
|
||||
// Edge goes (0,0) -> (100,0) but particles must travel back the other
|
||||
// way to visualise the call-graph response direction.
|
||||
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 5 });
|
||||
|
||||
const motions = container.querySelectorAll('animateMotion');
|
||||
expect(motions.length).toBeGreaterThan(0);
|
||||
motions.forEach((el) => {
|
||||
expect(el).toHaveAttribute('path', 'M100,0 L0,0');
|
||||
expect(el).toHaveAttribute('repeatCount', 'indefinite');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import LinkTooltip, { LinkTooltipData } from '../LinkTooltip/LinkTooltip';
|
||||
|
||||
const baseTooltip: LinkTooltipData = {
|
||||
p99: 12.34,
|
||||
callRate: 5.6,
|
||||
errorRate: 0.1,
|
||||
};
|
||||
|
||||
describe('LinkTooltip', () => {
|
||||
it('renders p99, request, and error rate rows with their suffixes', () => {
|
||||
render(<LinkTooltip tooltip={baseTooltip} x={0} y={0} />);
|
||||
|
||||
expect(screen.getByText('P99 latency:')).toBeInTheDocument();
|
||||
expect(screen.getByText('12.34ms')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Request:')).toBeInTheDocument();
|
||||
expect(screen.getByText('5.6/sec')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Error Rate:')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.1%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders string-typed metric values verbatim', () => {
|
||||
render(
|
||||
<LinkTooltip
|
||||
tooltip={{ p99: '0', callRate: '0', errorRate: '0' }}
|
||||
x={0}
|
||||
y={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('0ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('0/sec')).toBeInTheDocument();
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('positions itself offset from the cursor coordinates', () => {
|
||||
const { container } = render(
|
||||
<LinkTooltip tooltip={baseTooltip} x={100} y={200} />,
|
||||
);
|
||||
|
||||
// POINTER_OFFSET is 12 in the component; the tooltip should sit at
|
||||
// (x + 12, y + 12) so it does not occlude the hovered edge segment.
|
||||
const tooltip = container.firstChild as HTMLElement;
|
||||
expect(tooltip).toHaveStyle({ top: '212px', left: '112px' });
|
||||
});
|
||||
|
||||
it('handles negative coordinates without breaking the offset math', () => {
|
||||
const { container } = render(
|
||||
<LinkTooltip tooltip={baseTooltip} x={-50} y={-30} />,
|
||||
);
|
||||
|
||||
const tooltip = container.firstChild as HTMLElement;
|
||||
expect(tooltip).toHaveStyle({ top: '-18px', left: '-38px' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
getParticleAnimation,
|
||||
MAX_PARTICLES,
|
||||
PARTICLE_FAST_SECS,
|
||||
PARTICLE_SLOW_SECS,
|
||||
} from '../Map/Map.constants';
|
||||
|
||||
describe('getParticleAnimation', () => {
|
||||
it('returns zero particles and the slow duration for non-positive call rates', () => {
|
||||
expect(getParticleAnimation(0, 1000)).toStrictEqual({
|
||||
particleCount: 0,
|
||||
duration: PARTICLE_SLOW_SECS,
|
||||
});
|
||||
expect(getParticleAnimation(-5, 1000)).toStrictEqual({
|
||||
particleCount: 0,
|
||||
duration: PARTICLE_SLOW_SECS,
|
||||
});
|
||||
});
|
||||
|
||||
it('produces at least one particle for any positive call rate', () => {
|
||||
expect(getParticleAnimation(0.1, 1000).particleCount).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
expect(getParticleAnimation(1, 1000).particleCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('saturates at MAX_PARTICLES and PARTICLE_FAST_SECS when callRate equals max', () => {
|
||||
// Whatever the absolute scale, the busiest edge should peg the
|
||||
// visualisation — that's the point of the relative scaling.
|
||||
const EPS = 1e-9;
|
||||
[5, 50, 500, 5_000, 1_000_000].forEach((rate) => {
|
||||
const { particleCount, duration } = getParticleAnimation(rate, rate);
|
||||
expect(particleCount).toBe(MAX_PARTICLES);
|
||||
expect(duration).toBeGreaterThanOrEqual(PARTICLE_FAST_SECS - EPS);
|
||||
expect(duration).toBeLessThanOrEqual(PARTICLE_FAST_SECS + EPS);
|
||||
});
|
||||
});
|
||||
|
||||
it('caps particleCount at MAX_PARTICLES even if max is stale or zero', () => {
|
||||
// Defensive: if max somehow lags behind callRate, factor still clamps to 1.
|
||||
expect(getParticleAnimation(1000, 0).particleCount).toBe(MAX_PARTICLES);
|
||||
expect(getParticleAnimation(1000, 100).particleCount).toBe(MAX_PARTICLES);
|
||||
});
|
||||
|
||||
it('produces different particle counts for the same callRate at different scales', () => {
|
||||
// 50 req/sec is "busy" in a 50-max graph but "trickle" in a 5k-max graph.
|
||||
const busy = getParticleAnimation(50, 50);
|
||||
const trickle = getParticleAnimation(50, 5000);
|
||||
expect(busy.particleCount).toBeGreaterThan(trickle.particleCount);
|
||||
expect(busy.duration).toBeLessThan(trickle.duration);
|
||||
});
|
||||
|
||||
it('monotonically increases particle count as rate climbs toward max', () => {
|
||||
const max = 5000;
|
||||
const rates = [0.5, 5, 50, 500, max];
|
||||
const counts = rates.map((r) => getParticleAnimation(r, max).particleCount);
|
||||
for (let i = 1; i < counts.length; i += 1) {
|
||||
expect(counts[i]).toBeGreaterThanOrEqual(counts[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('monotonically decreases per-loop duration as rate climbs toward max', () => {
|
||||
const max = 5000;
|
||||
const rates = [0.5, 5, 50, 500, max];
|
||||
const durations = rates.map((r) => getParticleAnimation(r, max).duration);
|
||||
for (let i = 1; i < durations.length; i += 1) {
|
||||
expect(durations[i]).toBeLessThanOrEqual(durations[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps duration bounded between PARTICLE_FAST_SECS and PARTICLE_SLOW_SECS', () => {
|
||||
// At saturation the formula computes to PARTICLE_FAST_SECS up to
|
||||
// floating-point error (~1e-16), so allow a small epsilon.
|
||||
const EPS = 1e-9;
|
||||
const cases: Array<[number, number]> = [
|
||||
[0, 1000],
|
||||
[0.01, 1000],
|
||||
[1, 1000],
|
||||
[10, 1000],
|
||||
[100, 1000],
|
||||
[1000, 1000],
|
||||
[1000, 0], // defensive max
|
||||
[1_000_000, 1_000_000],
|
||||
];
|
||||
cases.forEach(([rate, max]) => {
|
||||
const { duration } = getParticleAnimation(rate, max);
|
||||
expect(duration).toBeGreaterThanOrEqual(PARTICLE_FAST_SECS - EPS);
|
||||
expect(duration).toBeLessThanOrEqual(PARTICLE_SLOW_SECS + EPS);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import ServiceNode from '../ServiceNode/ServiceNode';
|
||||
import { NODE_DIAMETER } from '../Map/Map.constants';
|
||||
|
||||
// `Handle` requires a ReactFlowProvider to mount. We don't exercise its
|
||||
// connection logic from this component, so a stub keeps the test isolated to
|
||||
// ServiceNode's own rendering responsibilities.
|
||||
jest.mock('@xyflow/react', () => {
|
||||
const actual = jest.requireActual('@xyflow/react');
|
||||
return {
|
||||
...actual,
|
||||
Handle: ({
|
||||
type,
|
||||
position,
|
||||
}: {
|
||||
type: string;
|
||||
position: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid={`handle-${type}`} data-position={position} />
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const baseNodeProps = {
|
||||
id: 'frontend',
|
||||
type: 'service',
|
||||
dragging: false,
|
||||
isConnectable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
zIndex: 0,
|
||||
selectable: false,
|
||||
deletable: false,
|
||||
draggable: true,
|
||||
selected: false,
|
||||
} as const;
|
||||
|
||||
function renderNode(data: {
|
||||
label: string;
|
||||
color: string;
|
||||
}): ReturnType<typeof render> {
|
||||
return render(<ServiceNode {...(baseNodeProps as any)} data={data} />);
|
||||
}
|
||||
|
||||
describe('ServiceNode', () => {
|
||||
it('renders the label text from data', () => {
|
||||
renderNode({ label: 'checkout-service', color: 'red' });
|
||||
|
||||
expect(screen.getByText('checkout-service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the label as a title attribute for full-name hover-disclosure', () => {
|
||||
// The visible label is truncated for layout, so the full service name is
|
||||
// surfaced via title — assert the attribute round-trips data.label.
|
||||
renderNode({
|
||||
label: 'a-very-long-service-name-that-truncates',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
const label = screen.getByText('a-very-long-service-name-that-truncates');
|
||||
expect(label).toHaveAttribute(
|
||||
'title',
|
||||
'a-very-long-service-name-that-truncates',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies data.color as the circle background and uses the configured diameter', () => {
|
||||
// All nodes render at NODE_DIAMETER — there is no per-node sizing.
|
||||
const { container } = renderNode({
|
||||
label: 'frontend',
|
||||
color: 'rgb(255, 0, 0)',
|
||||
});
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
const circle = wrapper.firstChild as HTMLElement;
|
||||
expect(circle).toHaveStyle({
|
||||
background: 'rgb(255, 0, 0)',
|
||||
width: `${NODE_DIAMETER}px`,
|
||||
height: `${NODE_DIAMETER}px`,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a target handle on the left and a source handle on the right', () => {
|
||||
renderNode({ label: 'frontend', color: 'red' });
|
||||
|
||||
const target = screen.getByTestId('handle-target');
|
||||
const source = screen.getByTestId('handle-source');
|
||||
|
||||
expect(target).toHaveAttribute('data-position', 'left');
|
||||
expect(source).toHaveAttribute('data-position', 'right');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
//@ts-nocheck
|
||||
|
||||
import dagre from '@dagrejs/dagre';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
cloneDeep,
|
||||
find,
|
||||
@@ -12,27 +15,7 @@ import {
|
||||
|
||||
import { graphDataType } from './ServiceMap';
|
||||
|
||||
const MIN_WIDTH = 10;
|
||||
const MAX_WIDTH = 20;
|
||||
const DEFAULT_FONT_SIZE = 6;
|
||||
|
||||
export const getDimensions = (
|
||||
num: number,
|
||||
highest: number,
|
||||
): {
|
||||
fontSize: number;
|
||||
width: number;
|
||||
} => {
|
||||
const percentage = (num / highest) * 100;
|
||||
const width = (percentage * (MAX_WIDTH - MIN_WIDTH)) / 100 + MIN_WIDTH;
|
||||
const fontSize = DEFAULT_FONT_SIZE;
|
||||
return {
|
||||
fontSize,
|
||||
width,
|
||||
};
|
||||
};
|
||||
|
||||
export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
export const getGraphData = (serviceMap, _isDarkMode): graphDataType => {
|
||||
const { items } = serviceMap;
|
||||
const services = Object.values(groupBy(items, 'child')).map((e) => {
|
||||
return {
|
||||
@@ -42,7 +25,6 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
};
|
||||
});
|
||||
const highestCallCount = maxBy(items, (e) => e?.callCount)?.callCount;
|
||||
const highestCallRate = maxBy(services, (e) => e?.callRate)?.callRate;
|
||||
|
||||
const divNum = Number(
|
||||
String(1).padEnd(highestCallCount.toString().length, '0'),
|
||||
@@ -62,31 +44,19 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
const uniqParent = uniqBy(cloneDeep(items), 'parent').map((e) => e.parent);
|
||||
const uniqChild = uniqBy(cloneDeep(items), 'child').map((e) => e.child);
|
||||
const uniqNodes = uniq([...uniqParent, ...uniqChild]);
|
||||
const nodes = uniqNodes.map((node, i) => {
|
||||
// Semantic tokens auto-flip with theme; passed as CSS variable strings so
|
||||
// the consuming component can apply them directly via `style.background`.
|
||||
const HEALTHY_COLOR = 'var(--l3-background)';
|
||||
const ERROR_COLOR = 'var(--danger-background)';
|
||||
const nodes = uniqNodes.map((node) => {
|
||||
const service = find(services, (service) => service.serviceName === node);
|
||||
let color = isDarkMode ? '#7CA568' : '#D5F2BB';
|
||||
if (!service) {
|
||||
return {
|
||||
id: node,
|
||||
group: i + 1,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
width: MIN_WIDTH,
|
||||
color,
|
||||
nodeVal: MIN_WIDTH,
|
||||
name: node,
|
||||
};
|
||||
let color = HEALTHY_COLOR;
|
||||
if (service && service.errorRate > 0) {
|
||||
color = ERROR_COLOR;
|
||||
}
|
||||
if (service.errorRate > 0) {
|
||||
color = isDarkMode ? '#DB836E' : '#F98989';
|
||||
}
|
||||
const { fontSize, width } = getDimensions(service.callRate, highestCallRate);
|
||||
return {
|
||||
id: node,
|
||||
group: i + 1,
|
||||
fontSize,
|
||||
width,
|
||||
color,
|
||||
nodeVal: width,
|
||||
name: node,
|
||||
};
|
||||
});
|
||||
@@ -96,20 +66,6 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getZoomPx = (): number => {
|
||||
const { width } = window.screen;
|
||||
if (width < 1400) {
|
||||
return 190;
|
||||
}
|
||||
if (width > 1400 && width < 1700) {
|
||||
return 380;
|
||||
}
|
||||
if (width > 1700) {
|
||||
return 470;
|
||||
}
|
||||
return 190;
|
||||
};
|
||||
|
||||
const getRound2DigitsAfterDecimal = (num: number): number => {
|
||||
if (num === 0) {
|
||||
return 0;
|
||||
@@ -117,27 +73,28 @@ const getRound2DigitsAfterDecimal = (num: number): number => {
|
||||
return num.toFixed(20).match(/^-?\d*\.?0*\d{0,2}/)[0];
|
||||
};
|
||||
|
||||
export const getTooltip = (link: {
|
||||
export interface LinkTooltip {
|
||||
p99: string | number;
|
||||
callRate: string | number;
|
||||
errorRate: string | number;
|
||||
}
|
||||
|
||||
export const getLinkTooltip = (link: {
|
||||
p99: number;
|
||||
errorRate: number;
|
||||
callRate: number;
|
||||
id: string;
|
||||
}): string => {
|
||||
return `<div style="color:#333333;padding:12px;background: white;border-radius: 2px;">
|
||||
<div class="keyval">
|
||||
<div class="key">P99 latency:</div>
|
||||
<div class="val">${getRound2DigitsAfterDecimal(link.p99 / 1000000)}ms</div>
|
||||
</div>
|
||||
<div class="keyval">
|
||||
<div class="key">Request:</div>
|
||||
<div class="val">${getRound2DigitsAfterDecimal(link.callRate)}/sec</div>
|
||||
</div>
|
||||
<div class="keyval">
|
||||
<div class="key">Error Rate:</div>
|
||||
<div class="val">${getRound2DigitsAfterDecimal(link.errorRate)}%</div>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
}): LinkTooltip => ({
|
||||
p99: getRound2DigitsAfterDecimal(link.p99 / 1000000),
|
||||
callRate: getRound2DigitsAfterDecimal(link.callRate),
|
||||
errorRate: getRound2DigitsAfterDecimal(link.errorRate),
|
||||
});
|
||||
|
||||
// Edges share a color with every other edge pointing at the same destination
|
||||
// service (mirrors the original `linkAutoColorBy={(d) => d.target}` behaviour).
|
||||
// Hashing onto the existing chart palette keeps it visually consistent with
|
||||
// the rest of the app and stable across renders for a given target id.
|
||||
export const getEdgeColor = (targetId: string): string =>
|
||||
generateColor(targetId, themeColors.chartcolors);
|
||||
|
||||
export const transformLabel = (label: string, zoomLevel: number): string => {
|
||||
//? 13 is the minimum label length. Scaling factor of 0.9 which is slightly less than 1
|
||||
@@ -150,3 +107,51 @@ export const transformLabel = (label: string, zoomLevel: number): string => {
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
// Layered DAG layout via dagre. For service maps the data flows
|
||||
// caller -> callee, so a left-to-right rank direction reads naturally and
|
||||
// minimises edge crossings vs. a force-directed simulation.
|
||||
//
|
||||
// `nodeBoxWidth` reserves space for the label rendered below each circle —
|
||||
// the visible label can be up to ~120px wide, so dagre needs to know that
|
||||
// horizontally adjacent ranks must keep that distance.
|
||||
export const computeNodePositions = (
|
||||
nodes: { id: string }[],
|
||||
links: { source: string; target: string }[],
|
||||
nodeBoxWidth = 130,
|
||||
nodeBoxHeight = 100,
|
||||
): Record<string, { x: number; y: number }> => {
|
||||
const result: Record<string, { x: number; y: number }> = {};
|
||||
if (nodes.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const g = new dagre.graphlib.Graph({ multigraph: true, compound: false });
|
||||
g.setGraph({
|
||||
rankdir: 'LR',
|
||||
nodesep: 40,
|
||||
ranksep: 90,
|
||||
marginx: 40,
|
||||
marginy: 40,
|
||||
});
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
nodes.forEach((node) => {
|
||||
g.setNode(node.id, { width: nodeBoxWidth, height: nodeBoxHeight });
|
||||
});
|
||||
links.forEach((link, i) => {
|
||||
// `name` makes parallel edges (same source+target, different metrics)
|
||||
// safe under multigraph mode.
|
||||
g.setEdge(link.source, link.target, {}, `${link.source}-${link.target}-${i}`);
|
||||
});
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const laidOut = g.node(node.id);
|
||||
if (laidOut) {
|
||||
result[node.id] = { x: laidOut.x, y: laidOut.y };
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -2953,6 +2953,18 @@
|
||||
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz#b6c75a56a1947cc916ea058772d666a2c8932f31"
|
||||
integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==
|
||||
|
||||
"@dagrejs/dagre@3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@dagrejs/dagre/-/dagre-3.0.0.tgz#543f20188f7494db0f45d634f7b3760747f87f23"
|
||||
integrity sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==
|
||||
dependencies:
|
||||
"@dagrejs/graphlib" "4.0.1"
|
||||
|
||||
"@dagrejs/graphlib@4.0.1":
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-4.0.1.tgz#a9cf907cc5ddf9140a64360ad487766f17d1ee36"
|
||||
integrity sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==
|
||||
|
||||
"@date-fns/tz@^1.4.1":
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.4.1.tgz#2d905f282304630e07bef6d02d2e7dbf3f0cc4e4"
|
||||
@@ -6002,11 +6014,6 @@
|
||||
resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz"
|
||||
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==
|
||||
|
||||
"@tweenjs/tween.js@18 - 25":
|
||||
version "25.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz#7266baebcc3affe62a3a54318a3ea82d904cd0b9"
|
||||
integrity sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==
|
||||
|
||||
"@tybys/wasm-util@^0.10.0", "@tybys/wasm-util@^0.10.1":
|
||||
version "0.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
|
||||
@@ -6124,6 +6131,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz#006b7bd838baec1511270cb900bf4fc377bbbf41"
|
||||
integrity sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==
|
||||
|
||||
"@types/d3-drag@^3.0.7":
|
||||
version "3.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02"
|
||||
integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==
|
||||
dependencies:
|
||||
"@types/d3-selection" "*"
|
||||
|
||||
"@types/d3-format@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.1.tgz#194f1317a499edd7e58766f96735bdc0216bb89d"
|
||||
@@ -6141,6 +6155,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-1.1.11.tgz#c3bd70d025621f73cb3319e97e08ae4c9051c791"
|
||||
integrity sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA==
|
||||
|
||||
"@types/d3-interpolate@*", "@types/d3-interpolate@^3.0.4":
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
|
||||
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
|
||||
dependencies:
|
||||
"@types/d3-color" "*"
|
||||
|
||||
"@types/d3-interpolate@3.0.1", "@types/d3-interpolate@^3.0.0":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc"
|
||||
@@ -6160,6 +6181,11 @@
|
||||
dependencies:
|
||||
"@types/d3-time" "*"
|
||||
|
||||
"@types/d3-selection@*", "@types/d3-selection@^3.0.10":
|
||||
version "3.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3"
|
||||
integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==
|
||||
|
||||
"@types/d3-shape@^1.3.1":
|
||||
version "1.3.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.12.tgz#8f2f9f7a12e631ce6700d6d55b84795ce2c8b259"
|
||||
@@ -6182,6 +6208,21 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819"
|
||||
integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==
|
||||
|
||||
"@types/d3-transition@^3.0.8":
|
||||
version "3.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706"
|
||||
integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==
|
||||
dependencies:
|
||||
"@types/d3-selection" "*"
|
||||
|
||||
"@types/d3-zoom@^3.0.8":
|
||||
version "3.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b"
|
||||
integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==
|
||||
dependencies:
|
||||
"@types/d3-interpolate" "*"
|
||||
"@types/d3-selection" "*"
|
||||
|
||||
"@types/debug@^4.0.0":
|
||||
version "4.1.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.8.tgz#cef723a5d0a90990313faec2d1e22aee5eecb317"
|
||||
@@ -7072,6 +7113,30 @@
|
||||
resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz"
|
||||
integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==
|
||||
|
||||
"@xyflow/react@12.10.2":
|
||||
version "12.10.2"
|
||||
resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.10.2.tgz#40f6d71944f674f0ffbb83c660f9473018adbe61"
|
||||
integrity sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==
|
||||
dependencies:
|
||||
"@xyflow/system" "0.0.76"
|
||||
classcat "^5.0.3"
|
||||
zustand "^4.4.0"
|
||||
|
||||
"@xyflow/system@0.0.76":
|
||||
version "0.0.76"
|
||||
resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.76.tgz#57da5e4d230cdbec56548a6d5eec115f22858259"
|
||||
integrity sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==
|
||||
dependencies:
|
||||
"@types/d3-drag" "^3.0.7"
|
||||
"@types/d3-interpolate" "^3.0.4"
|
||||
"@types/d3-selection" "^3.0.10"
|
||||
"@types/d3-transition" "^3.0.8"
|
||||
"@types/d3-zoom" "^3.0.8"
|
||||
d3-drag "^3.0.0"
|
||||
d3-interpolate "^3.0.1"
|
||||
d3-selection "^3.0.0"
|
||||
d3-zoom "^3.0.0"
|
||||
|
||||
"@zxing/text-encoding@0.9.0":
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b"
|
||||
@@ -7097,11 +7162,6 @@ abort-controller@^3.0.0:
|
||||
dependencies:
|
||||
event-target-shim "^5.0.0"
|
||||
|
||||
accessor-fn@1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.4.1.tgz"
|
||||
integrity sha512-P7yNKfmpuWLUwiRVk9RkRIPGjngemjZ7yANc0DL7otgDqEIWkEByMhShzfgQ5ZwCPEUmba4v1kOqCdGhpzY3ew==
|
||||
|
||||
acorn-globals@^7.0.0:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3"
|
||||
@@ -7999,11 +8059,6 @@ better-xlsx@^0.7.5:
|
||||
jszip "^3.2.2"
|
||||
kind-of "^6.0.3"
|
||||
|
||||
"bezier-js@3 - 6":
|
||||
version "6.1.3"
|
||||
resolved "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.3.tgz"
|
||||
integrity sha512-VPFvkyO98oCJ1Tsi+bFBrKEWLdefAj4DJVaWp3xTEsdCbunC7Pt/nTeIgu/UdskBNcmHv8TOfsgdMZb1GsICmg==
|
||||
|
||||
big-integer@^1.6.16:
|
||||
version "1.6.51"
|
||||
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
|
||||
@@ -8273,13 +8328,6 @@ caniuse-lite@^1.0.30001759:
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz#0279c498e862efb067938bba0a0aabafe8d0b730"
|
||||
integrity sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==
|
||||
|
||||
canvas-color-tracker@^1.3:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz#b924cf94b33441b82692938fca5b936be971a46d"
|
||||
integrity sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==
|
||||
dependencies:
|
||||
tinycolor2 "^1.6.0"
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
|
||||
@@ -8421,6 +8469,11 @@ class-variance-authority@^0.7.0:
|
||||
dependencies:
|
||||
clsx "^2.1.1"
|
||||
|
||||
classcat@^5.0.3:
|
||||
version "5.0.5"
|
||||
resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77"
|
||||
integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==
|
||||
|
||||
classnames@2.3.2, classnames@2.x, classnames@^2.2.1, classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz"
|
||||
@@ -9055,7 +9108,7 @@ csstype@^3.1.2:
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||
|
||||
"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3":
|
||||
"d3-array@2 - 3", "d3-array@2.10.0 - 3":
|
||||
version "3.2.3"
|
||||
resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.3.tgz"
|
||||
integrity sha512-JRHwbQQ84XuAESWhvIPaUV4/1UYTBOLiOPGWqgFDHZS1D5QN9c57FbH3QpEnQMYiOXNzKUQyGTZf+EVO7RT5TQ==
|
||||
@@ -9081,11 +9134,6 @@ d3-array@^1.2.0:
|
||||
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
|
||||
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
|
||||
|
||||
d3-binarytree@1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz"
|
||||
integrity sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==
|
||||
|
||||
"d3-color@1 - 3", d3-color@3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz"
|
||||
@@ -9103,7 +9151,7 @@ d3-delaunay@6.0.2:
|
||||
resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz"
|
||||
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
|
||||
|
||||
"d3-drag@2 - 3":
|
||||
"d3-drag@2 - 3", d3-drag@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz"
|
||||
integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
|
||||
@@ -9116,17 +9164,6 @@ d3-delaunay@6.0.2:
|
||||
resolved "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz"
|
||||
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
|
||||
|
||||
"d3-force-3d@2 - 3":
|
||||
version "3.0.5"
|
||||
resolved "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.5.tgz"
|
||||
integrity sha512-tdwhAhoTYZY/a6eo9nR7HP3xSW/C6XvJTbeRpR92nlPzH6OiE+4MliN9feuSFd0tPtEUo+191qOhCTWx3NYifg==
|
||||
dependencies:
|
||||
d3-binarytree "1"
|
||||
d3-dispatch "1 - 3"
|
||||
d3-octree "1"
|
||||
d3-quadtree "1 - 3"
|
||||
d3-timer "1 - 3"
|
||||
|
||||
"d3-format@1 - 3", d3-format@3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz"
|
||||
@@ -9149,18 +9186,13 @@ d3-hierarchy@^1.1.4:
|
||||
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
|
||||
integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
|
||||
|
||||
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3.0.1:
|
||||
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3.0.1, d3-interpolate@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
|
||||
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
|
||||
dependencies:
|
||||
d3-color "1 - 3"
|
||||
|
||||
d3-octree@1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/d3-octree/-/d3-octree-1.0.2.tgz"
|
||||
integrity sha512-Qxg4oirJrNXauiuC94uKMbgxwnhdda9xRLl9ihq45srlJ4Ga3CSgqGcAL8iW7N5CIv4Oz8x3E734ulxyvHPvwA==
|
||||
|
||||
d3-path@1, d3-path@^1.0.5:
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
|
||||
@@ -9171,20 +9203,7 @@ d3-polygon@^1.0.3:
|
||||
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
|
||||
integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
|
||||
|
||||
"d3-quadtree@1 - 3":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz"
|
||||
integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==
|
||||
|
||||
"d3-scale-chromatic@1 - 3":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz"
|
||||
integrity sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==
|
||||
dependencies:
|
||||
d3-color "1 - 3"
|
||||
d3-interpolate "1 - 3"
|
||||
|
||||
"d3-scale@1 - 4", d3-scale@4.0.2:
|
||||
d3-scale@4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz"
|
||||
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
|
||||
@@ -9195,9 +9214,9 @@ d3-polygon@^1.0.3:
|
||||
d3-time "2.1.1 - 3"
|
||||
d3-time-format "2 - 4"
|
||||
|
||||
"d3-selection@2 - 3", d3-selection@3:
|
||||
"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz"
|
||||
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
|
||||
integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
|
||||
|
||||
d3-shape@^1.0.6, d3-shape@^1.2.0:
|
||||
@@ -9237,9 +9256,9 @@ d3-shape@^1.0.6, d3-shape@^1.2.0:
|
||||
d3-interpolate "1 - 3"
|
||||
d3-timer "1 - 3"
|
||||
|
||||
"d3-zoom@2 - 3":
|
||||
d3-zoom@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz"
|
||||
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
|
||||
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
|
||||
dependencies:
|
||||
d3-dispatch "1 - 3"
|
||||
@@ -10460,15 +10479,6 @@ flatted@^3.4.2:
|
||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
|
||||
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
|
||||
|
||||
float-tooltip@^1.7:
|
||||
version "1.7.5"
|
||||
resolved "https://registry.yarnpkg.com/float-tooltip/-/float-tooltip-1.7.5.tgz#7083bf78f0de5a97f9c2d6aa8e90d2139f34047f"
|
||||
integrity sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==
|
||||
dependencies:
|
||||
d3-selection "2 - 3"
|
||||
kapsule "^1.16"
|
||||
preact "10"
|
||||
|
||||
flubber@^0.4.2:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/flubber/-/flubber-0.4.2.tgz#14452d4a838cc3b9f2fb6175da94e35acd55fbaa"
|
||||
@@ -10505,27 +10515,6 @@ for-each@^0.3.5:
|
||||
dependencies:
|
||||
is-callable "^1.2.7"
|
||||
|
||||
force-graph@^1.51:
|
||||
version "1.51.1"
|
||||
resolved "https://registry.yarnpkg.com/force-graph/-/force-graph-1.51.1.tgz#c967249bf6ad2cb4a3ba89ed4c6d79895bd70fe1"
|
||||
integrity sha512-uEEX8iRzgq1IKRISOw6RrB2RLMhcI25xznQYrCTVvxZHZZ+A2jH6qIolYuwavVxAMi64pFp2yZm4KFVdD993cg==
|
||||
dependencies:
|
||||
"@tweenjs/tween.js" "18 - 25"
|
||||
accessor-fn "1"
|
||||
bezier-js "3 - 6"
|
||||
canvas-color-tracker "^1.3"
|
||||
d3-array "1 - 3"
|
||||
d3-drag "2 - 3"
|
||||
d3-force-3d "2 - 3"
|
||||
d3-scale "1 - 4"
|
||||
d3-scale-chromatic "1 - 3"
|
||||
d3-selection "2 - 3"
|
||||
d3-zoom "2 - 3"
|
||||
float-tooltip "^1.7"
|
||||
index-array-by "1"
|
||||
kapsule "^1.16"
|
||||
lodash-es "4"
|
||||
|
||||
foreground-child@^3.1.0:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
|
||||
@@ -11673,11 +11662,6 @@ indent-string@^4.0.0:
|
||||
resolved "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz"
|
||||
integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
|
||||
|
||||
index-array-by@1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.1.tgz"
|
||||
integrity sha512-Zu6THdrxQdyTuT2uA5FjUoBEsFHPzHcPIj18FszN6yXKHxSfGcR4TPLabfuT//E25q1Igyx9xta2WMvD/x9P/g==
|
||||
|
||||
inflected@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/inflected/-/inflected-2.1.0.tgz#2816ac17a570bbbc8303ca05bca8bf9b3f959687"
|
||||
@@ -12383,11 +12367,6 @@ jake@^10.8.5:
|
||||
filelist "^1.0.4"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
jerrypick@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.1.tgz"
|
||||
integrity sha512-XTtedPYEyVp4t6hJrXuRKr/jHj8SC4z+4K0b396PMkov6muL+i8IIamJIvZWe3jUspgIJak0P+BaWKawMYNBLg==
|
||||
|
||||
jest-changed-files@30.2.0:
|
||||
version "30.2.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.2.0.tgz#602266e478ed554e1e1469944faa7efd37cee61c"
|
||||
@@ -13103,13 +13082,6 @@ junk@^3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
|
||||
integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==
|
||||
|
||||
kapsule@^1.16:
|
||||
version "1.16.3"
|
||||
resolved "https://registry.yarnpkg.com/kapsule/-/kapsule-1.16.3.tgz#5684ed89838b6658b30d0f2cc056dffc3ba68c30"
|
||||
integrity sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==
|
||||
dependencies:
|
||||
lodash-es "4"
|
||||
|
||||
keyv@^4.0.0:
|
||||
version "4.5.4"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
|
||||
@@ -13334,7 +13306,7 @@ locate-path@^7.1.0:
|
||||
dependencies:
|
||||
p-locate "^6.0.0"
|
||||
|
||||
lodash-es@4, lodash-es@^4.17.21:
|
||||
lodash-es@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
|
||||
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||
@@ -15645,11 +15617,6 @@ powershell-utils@^0.1.0:
|
||||
resolved "https://registry.yarnpkg.com/powershell-utils/-/powershell-utils-0.1.0.tgz#5a42c9a824fb4f2f251ccb41aaae73314f5d6ac2"
|
||||
integrity sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==
|
||||
|
||||
preact@10:
|
||||
version "10.28.4"
|
||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.4.tgz#8ffab01c5c0590535bdaecdd548801f44c6e483a"
|
||||
integrity sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==
|
||||
|
||||
preact@^10.19.3:
|
||||
version "10.22.0"
|
||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.22.0.tgz#a50f38006ae438d255e2631cbdaf7488e6dd4e16"
|
||||
@@ -15711,7 +15678,7 @@ progress@^2.0.3:
|
||||
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
|
||||
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
|
||||
|
||||
prop-types@15, prop-types@15.8.1, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
prop-types@15.8.1, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||
@@ -16335,15 +16302,6 @@ react-fast-compare@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
|
||||
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
|
||||
|
||||
react-force-graph-2d@^1.29.1:
|
||||
version "1.29.1"
|
||||
resolved "https://registry.yarnpkg.com/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz#a0784d4387b12b28e2b552058ec09d092b4e8cda"
|
||||
integrity sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==
|
||||
dependencies:
|
||||
force-graph "^1.51"
|
||||
prop-types "15"
|
||||
react-kapsule "^2.5"
|
||||
|
||||
react-full-screen@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-full-screen/-/react-full-screen-1.1.1.tgz#b707d56891015a71c503a65dbab3086d75be97d7"
|
||||
@@ -16413,13 +16371,6 @@ react-is@^18.3.1:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
|
||||
react-kapsule@^2.5:
|
||||
version "2.5.7"
|
||||
resolved "https://registry.yarnpkg.com/react-kapsule/-/react-kapsule-2.5.7.tgz#dcd957ae8e897ff48055fc8ff48ed04ebe3c5bd2"
|
||||
integrity sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==
|
||||
dependencies:
|
||||
jerrypick "^1.1.1"
|
||||
|
||||
react-lottie@1.2.10:
|
||||
version "1.2.10"
|
||||
resolved "https://registry.yarnpkg.com/react-lottie/-/react-lottie-1.2.10.tgz#399f78a448a7833b2380d74fc489ecf15f8d18c7"
|
||||
@@ -18489,7 +18440,7 @@ tiny-warning@^1.0.0:
|
||||
resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz"
|
||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
||||
|
||||
tinycolor2@1.6.0, tinycolor2@^1.6.0:
|
||||
tinycolor2@1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
|
||||
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
|
||||
@@ -19194,7 +19145,7 @@ use-sidecar@^1.1.3:
|
||||
detect-node-es "^1.1.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
use-sync-external-store@1.6.0, use-sync-external-store@^1.2.2:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
|
||||
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
|
||||
@@ -19837,6 +19788,13 @@ zustand@5.0.11:
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.11.tgz#99f912e590de1ca9ce6c6d1cab6cdb1f034ab494"
|
||||
integrity sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==
|
||||
|
||||
zustand@^4.4.0:
|
||||
version "4.5.7"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55"
|
||||
integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
|
||||
dependencies:
|
||||
use-sync-external-store "^1.2.2"
|
||||
|
||||
zwitch@^2.0.0, zwitch@^2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
|
||||
|
||||
Reference in New Issue
Block a user