mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-02 15:10:34 +01:00
Compare commits
13 Commits
feat/v2-da
...
issue_5015
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f4b4b0fc2 | ||
|
|
2cf61813ae | ||
|
|
887dc9de16 | ||
|
|
d0ab1b6301 | ||
|
|
1bfe614b92 | ||
|
|
592cc02df4 | ||
|
|
bf07f74185 | ||
|
|
a9bb25e085 | ||
|
|
99267e5e91 | ||
|
|
08320f5173 | ||
|
|
8562049bfe | ||
|
|
e3a22cd7cf | ||
|
|
74c875ec79 |
6
.github/workflows/e2eci.yaml
vendored
6
.github/workflows/e2eci.yaml
vendored
@@ -45,9 +45,15 @@ jobs:
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
SIGNOZ_BUILDX_GHA_SCOPE: signoz-e2e
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: setup-buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: expose-gha-runtime
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
6
.github/workflows/integrationci.yaml
vendored
6
.github/workflows/integrationci.yaml
vendored
@@ -69,9 +69,15 @@ jobs:
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-integrate')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SIGNOZ_BUILDX_GHA_SCOPE: signoz-integration
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: setup-buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: expose-gha-runtime
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
16
Makefile
16
Makefile
@@ -34,6 +34,7 @@ DOCKER_BUILD_ARCHS_ENTERPRISE = $(addprefix docker-build-enterprise-,$(ARCHS))
|
||||
DOCKERFILE_ENTERPRISE = $(SRC)/cmd/enterprise/Dockerfile
|
||||
DOCKER_REGISTRY_ENTERPRISE ?= docker.io/signoz/signoz
|
||||
JS_BUILD_CONTEXT = $(SRC)/frontend
|
||||
DOCKER_BUILDX_PRUNE_FLAGS ?= --force
|
||||
|
||||
##############################################################
|
||||
# directories
|
||||
@@ -229,6 +230,21 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
|
||||
@find tests -type f -name "*.pyo" -delete 2>/dev/null || true
|
||||
@echo ">> python cache cleaned"
|
||||
|
||||
.PHONY: py-docker-clean
|
||||
py-docker-clean: ## Remove Docker image and build caches used by python integration tests
|
||||
@echo ">> removing SigNoz integration test image"
|
||||
@docker image rm -f signoz:integration 2>/dev/null || true
|
||||
@echo ">> removing local integration buildx cache directories"
|
||||
@rm -rf /tmp/signoz-integration-buildx-cache /tmp/signoz-integration-buildx-cache-next /tmp/signoz-e2e-buildx-cache /tmp/signoz-e2e-buildx-cache-next
|
||||
@echo ">> pruning docker buildx cache with flags: $(DOCKER_BUILDX_PRUNE_FLAGS)"
|
||||
@docker buildx prune $(DOCKER_BUILDX_PRUNE_FLAGS)
|
||||
|
||||
.PHONY: py-test-clean
|
||||
py-test-clean: ## Tear down python test stack and remove python/Docker test caches
|
||||
@$(MAKE) py-test-teardown || true
|
||||
@$(MAKE) py-clean
|
||||
@$(MAKE) py-docker-clean
|
||||
|
||||
|
||||
##############################################################
|
||||
# generate commands
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# syntax=docker/dockerfile:1.13
|
||||
|
||||
FROM golang:1.25-bookworm
|
||||
|
||||
ARG OS="linux"
|
||||
@@ -21,7 +23,8 @@ RUN set -eux; \
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
@@ -29,7 +32,9 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates/email /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# syntax=docker/dockerfile:1.13
|
||||
|
||||
FROM node:22-bookworm AS build
|
||||
|
||||
WORKDIR /opt/
|
||||
@@ -30,7 +32,8 @@ RUN set -eux; \
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
@@ -38,7 +41,9 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates/email /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
COPY --from=build /opt/build ./web/
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.124.0
|
||||
image: signoz/signoz:v0.125.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.124.0
|
||||
image: signoz/signoz:v0.125.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.124.0}
|
||||
image: signoz/signoz:${VERSION:-v0.125.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.124.0}
|
||||
image: signoz/signoz:${VERSION:-v0.125.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
.badgesContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.badgeContainer {
|
||||
color: var(--bg-sienna-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.52px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
padding: 2px 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
> div {
|
||||
margin: 0;
|
||||
}
|
||||
padding-left: 0px !important;
|
||||
padding-right: 0px !important;
|
||||
}
|
||||
|
||||
.tagsInput {
|
||||
display: flex;
|
||||
border: none;
|
||||
padding: 0px;
|
||||
width: 183px;
|
||||
height: 24px;
|
||||
padding: 3px 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editInput {
|
||||
.ant-form-item {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { Col, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import Input from 'components/Input';
|
||||
|
||||
import styles from './AddBadges.module.scss';
|
||||
|
||||
function AddTags({ tags, setTags }: AddTagsProps): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [editInputIndex, setEditInputIndex] = useState(-1);
|
||||
const [editInputValue, setEditInputValue] = useState('');
|
||||
|
||||
const handleInputConfirm = (): void => {
|
||||
if (inputValue) {
|
||||
setTags([...tags, inputValue]);
|
||||
}
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const handleEditInputConfirm = (): void => {
|
||||
const newTags = [...tags];
|
||||
newTags[editInputIndex] = editInputValue;
|
||||
setTags(newTags);
|
||||
setEditInputIndex(-1);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const handleClose = (removedTag: string): void => {
|
||||
const newTags = tags.filter((tag) => tag !== removedTag);
|
||||
setTags(newTags);
|
||||
};
|
||||
|
||||
const onChangeHandler = (
|
||||
value: string,
|
||||
func: Dispatch<SetStateAction<string>>,
|
||||
): void => {
|
||||
func(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.badgesContainer}>
|
||||
{tags.map((tag, index) => {
|
||||
if (editInputIndex === index) {
|
||||
return (
|
||||
<Col key={tag} lg={4} className={styles.editInput}>
|
||||
<Input
|
||||
size="small"
|
||||
value={editInputValue}
|
||||
onChangeHandler={(event): void =>
|
||||
onChangeHandler(event.target.value, setEditInputValue)
|
||||
}
|
||||
onBlurHandler={handleEditInputConfirm}
|
||||
onPressEnterHandler={handleEditInputConfirm}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
const isLongTag = tag.length > 20;
|
||||
|
||||
const tagElem = (
|
||||
<Badge
|
||||
key={tag}
|
||||
color="vanilla"
|
||||
className={styles.badgeContainer}
|
||||
closable
|
||||
onClose={(e): void => {
|
||||
e.preventDefault();
|
||||
handleClose(tag);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onDoubleClick={(e): void => {
|
||||
setEditInputIndex(index);
|
||||
setEditInputValue(tag);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return isLongTag ? (
|
||||
<Tooltip title={tag} key={tag}>
|
||||
{tagElem}
|
||||
</Tooltip>
|
||||
) : (
|
||||
tagElem
|
||||
);
|
||||
})}
|
||||
|
||||
<Col className={styles.inputContainer}>
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
rootClassName={styles.tagsInput}
|
||||
placeholder="Start typing your tag name"
|
||||
onChangeHandler={(event): void =>
|
||||
onChangeHandler(event.target.value, setInputValue)
|
||||
}
|
||||
onBlurHandler={handleInputConfirm}
|
||||
onPressEnterHandler={handleInputConfirm}
|
||||
/>
|
||||
</Col>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddTagsProps {
|
||||
tags: string[];
|
||||
setTags: Dispatch<SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
export default AddTags;
|
||||
@@ -1,11 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
.settings-container-root {
|
||||
.ant-drawer-wrapper-body {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-drawer-header {
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 14px 14px 14px 11px;
|
||||
|
||||
.ant-drawer-header-title {
|
||||
gap: 16px;
|
||||
|
||||
.ant-drawer-title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 16px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { memo, PropsWithChildren, ReactElement } from 'react';
|
||||
import { Drawer } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import './SettingsDrawer.styles.scss';
|
||||
|
||||
type SettingsDrawerProps = PropsWithChildren<{
|
||||
drawerTitle: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}>;
|
||||
|
||||
function SettingsDrawer({
|
||||
children,
|
||||
drawerTitle,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: SettingsDrawerProps): JSX.Element {
|
||||
return (
|
||||
<Drawer
|
||||
title={drawerTitle}
|
||||
placement="right"
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
rootClassName="settings-container-root"
|
||||
>
|
||||
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
|
||||
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SettingsDrawer);
|
||||
@@ -1,395 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Check,
|
||||
ClipboardCopy,
|
||||
Ellipsis,
|
||||
FileJson,
|
||||
Fullscreen,
|
||||
Globe,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Card, Input, Modal, Popover, Tag, Tooltip } from 'antd';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
lockDashboardV2,
|
||||
patchDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { Base64Icons } from '../../DashboardContainer/DashboardSettings/General/utils';
|
||||
import DashboardSettingsV2 from '../DashboardSettings';
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import SettingsDrawer from './SettingsDrawer';
|
||||
|
||||
import '../../DashboardContainer/DashboardDescription/Description.styles.scss';
|
||||
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface DashboardDescriptionV2Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
handle: FullScreenHandle;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DashboardDescriptionV2(props: DashboardDescriptionV2Props): JSX.Element {
|
||||
const { dashboard, handle, onRefetch } = props;
|
||||
|
||||
const id = dashboard?.id ?? '';
|
||||
const isDashboardLocked = !!dashboard?.locked;
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const title = dashboard?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.image || Base64Icons[0];
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
(dashboard?.tags ?? []).map((t) =>
|
||||
t.key === t.value ? t.key : `${t.key}:${t.value}`,
|
||||
),
|
||||
[dashboard?.tags],
|
||||
);
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard?.createdBy && dashboard.createdBy === user.email;
|
||||
const addPanelPermission = !isDashboardLocked;
|
||||
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
|
||||
const isPublicDashboard = false;
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
|
||||
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard) {setUpdatedTitle(title);}
|
||||
}, [dashboard, title]);
|
||||
|
||||
const handleLockDashboardToggle = async (): Promise<void> => {
|
||||
if (!id) {return;}
|
||||
setIsDashbordSettingsOpen(false);
|
||||
try {
|
||||
if (isDashboardLocked) {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
} else {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
}
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
};
|
||||
|
||||
const onNameChangeHandler = async (): Promise<void> => {
|
||||
const trimmed = updatedTitle.trim();
|
||||
if (!id || !trimmed || trimmed === title) {
|
||||
setIsRenameDashboardOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRenameLoading(true);
|
||||
const patch: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/display/name',
|
||||
value: trimmed,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id }, patch);
|
||||
toast.success('Dashboard renamed successfully');
|
||||
setIsRenameDashboardOpen(false);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsRenameDashboardOpen(true);
|
||||
} finally {
|
||||
setIsRenameLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEmptyWidgetHandler = (): void => {
|
||||
logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
toast.info('V2 panel editor coming next');
|
||||
};
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
toast.error(t('something_went_wrong', { ns: 'common' }));
|
||||
}
|
||||
if (state.value) {
|
||||
toast.success(t('success', { ns: 'common' }));
|
||||
}
|
||||
}, [state.error, state.value, t]);
|
||||
|
||||
const dashboardDataJSON = (): string =>
|
||||
JSON.stringify(dashboard ?? {}, null, 2);
|
||||
|
||||
const exportJSON = (): void => {
|
||||
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${title || 'dashboard'}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const onConfigureClick = (): void => {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const onSettingsDrawerClose = (): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="dashboard-description-container">
|
||||
<DashboardHeader title={title} image={image} />
|
||||
<section className="dashboard-details">
|
||||
<div className="left-section">
|
||||
<img src={image} alt="dashboard-img" className="dashboard-img" />
|
||||
<Tooltip title={title.length > 30 ? title : ''}>
|
||||
<Typography.Text
|
||||
className="dashboard-title"
|
||||
data-testid="dashboard-title"
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
{isPublicDashboard && (
|
||||
<Tooltip title="This dashboard is publicly accessible">
|
||||
<Globe size={14} className="public-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<Tooltip title="This dashboard is locked">
|
||||
<LockKeyhole size={14} className="lock-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="right-section">
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<Popover
|
||||
open={isDashboardSettingsOpen}
|
||||
arrow={false}
|
||||
onOpenChange={(visible): void => setIsDashbordSettingsOpen(visible)}
|
||||
rootClassName="dashboard-settings"
|
||||
content={
|
||||
<div className="menu-content">
|
||||
<section className="section-1">
|
||||
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
|
||||
<Tooltip
|
||||
title={
|
||||
dashboard?.createdBy === 'integration' &&
|
||||
'Dashboards created by integrations cannot be unlocked'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LockKeyhole size={14} />}
|
||||
disabled={dashboard?.createdBy === 'integration'}
|
||||
onClick={handleLockDashboardToggle}
|
||||
data-testid="lock-unlock-dashboard"
|
||||
>
|
||||
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PenLine size={14} />}
|
||||
onClick={(): void => {
|
||||
setIsRenameDashboardOpen(true);
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Fullscreen size={14} />}
|
||||
onClick={handle.enter}
|
||||
>
|
||||
Full screen
|
||||
</Button>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FileJson size={14} />}
|
||||
onClick={(): void => {
|
||||
exportJSON();
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ClipboardCopy size={14} />}
|
||||
onClick={(): void => {
|
||||
setCopy(dashboardDataJSON());
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Copy as JSON
|
||||
</Button>
|
||||
</section>
|
||||
<section className="delete-dashboard">
|
||||
<DeleteButton
|
||||
createdBy={dashboard?.createdBy || ''}
|
||||
name={title}
|
||||
id={id}
|
||||
isLocked={isDashboardLocked}
|
||||
routeToListPage
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button
|
||||
icon={<Ellipsis size={14} />}
|
||||
type="text"
|
||||
className="icons"
|
||||
data-testid="options"
|
||||
/>
|
||||
</Popover>
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
className="configure-button"
|
||||
icon={<ConfigureIcon />}
|
||||
data-testid="show-drawer"
|
||||
onClick={onConfigureClick}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={onSettingsDrawerClose}
|
||||
>
|
||||
<DashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && addPanelPermission && (
|
||||
<Button
|
||||
className="add-panel-btn"
|
||||
onClick={onEmptyWidgetHandler}
|
||||
icon={<Plus size="md" />}
|
||||
type="primary"
|
||||
data-testid="add-panel-header"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{tags.length > 0 && (
|
||||
<div className="dashboard-tags">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} className="tag">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className="dashboard-description-section">{description}</section>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={isRenameDashboardOpen}
|
||||
title="Rename Dashboard"
|
||||
onOk={onNameChangeHandler}
|
||||
onCancel={(): void => {
|
||||
setIsRenameDashboardOpen(false);
|
||||
}}
|
||||
rootClassName="rename-dashboard"
|
||||
footer={
|
||||
<div className="dashboard-rename">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
className="rename-btn"
|
||||
onClick={onNameChangeHandler}
|
||||
disabled={isRenameLoading}
|
||||
>
|
||||
Rename Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X size={14} />}
|
||||
className="cancel-btn"
|
||||
onClick={(): void => setIsRenameDashboardOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="dashboard-content">
|
||||
<Typography.Text className="name-text">Enter a new name</Typography.Text>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className="dashboard-name-input"
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardDescriptionV2;
|
||||
@@ -1,227 +0,0 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfoIcon {
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDescription {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDocLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-background);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
|
||||
import { Col, Input, Radio, Select, Space, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
TagtypesPostableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
import { Button } from './styles';
|
||||
import { Base64Icons } from './utils';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import type { V2Dashboard } from '../../utils';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// Convert V2 tags ({key, value}[]) into "key:value" strings for the V1
|
||||
// AddTags component (which expects string[]), and back on save.
|
||||
//
|
||||
// V2 tags require both `key` and `value` to be non-empty server-side
|
||||
// (returns `tag_invalid_value` otherwise). To preserve the V1 single-word
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
|
||||
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
|
||||
}
|
||||
|
||||
function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
|
||||
return tagStrings
|
||||
.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
const idx = trimmed.indexOf(':');
|
||||
if (idx === -1) {
|
||||
return { key: trimmed, value: trimmed };
|
||||
}
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
return { key, value: value || key };
|
||||
})
|
||||
.filter((t) => t.key.length > 0);
|
||||
}
|
||||
|
||||
function GeneralDashboardSettingsV2({
|
||||
dashboard,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const id = dashboard?.id ?? '';
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(id);
|
||||
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
|
||||
useSyncTooltipFilterMode(id);
|
||||
|
||||
const title = dashboard?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.image || Base64Icons[0];
|
||||
const tagsAsStrings = useMemo(
|
||||
() => tagsToStrings(dashboard?.tags ?? []),
|
||||
[dashboard?.tags],
|
||||
);
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
|
||||
const [updatedDescription, setUpdatedDescription] =
|
||||
useState<string>(description);
|
||||
const [updatedImage, setUpdatedImage] = useState<string>(image);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] =
|
||||
useState<number>(0);
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Sync state when dashboard refetches after a save
|
||||
useEffect(() => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedDescription(description);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard?.updatedAt]);
|
||||
|
||||
const buildPatch = (): DashboardtypesJSONPatchOperationDTO[] => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
const replace = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
): DashboardtypesJSONPatchOperationDTO => ({
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path,
|
||||
value,
|
||||
});
|
||||
|
||||
if (updatedTitle !== title) {
|
||||
ops.push(replace('/spec/display/name', updatedTitle));
|
||||
}
|
||||
if (updatedDescription !== description) {
|
||||
ops.push(replace('/spec/display/description', updatedDescription));
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/image', updatedImage));
|
||||
}
|
||||
if (!isEqual(updatedTags, tagsAsStrings)) {
|
||||
ops.push(replace('/tags', stringsToTags(updatedTags)));
|
||||
}
|
||||
return ops;
|
||||
};
|
||||
|
||||
const onSaveHandler = async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const ops = buildPatch();
|
||||
if (ops.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id }, ops);
|
||||
toast.success('Dashboard updated');
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let n = 0;
|
||||
const initialValues = [title, description, tagsAsStrings, image];
|
||||
const updatedValues = [
|
||||
updatedTitle,
|
||||
updatedDescription,
|
||||
updatedTags,
|
||||
updatedImage,
|
||||
];
|
||||
initialValues.forEach((val, index) => {
|
||||
if (!isEqual(val, updatedValues[index])) {
|
||||
n += 1;
|
||||
}
|
||||
});
|
||||
setNumberOfUnsavedChanges(n);
|
||||
}, [
|
||||
description,
|
||||
image,
|
||||
tagsAsStrings,
|
||||
title,
|
||||
updatedDescription,
|
||||
updatedImage,
|
||||
updatedTags,
|
||||
updatedTitle,
|
||||
]);
|
||||
|
||||
const discardHandler = (): void => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
setUpdatedDescription(description);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<Col className={styles.overviewSettings}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '21px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
value={updatedImage}
|
||||
onChange={(value: string): void => setUpdatedImage(value)}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={updatedDescription}
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => setUpdatedDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
|
||||
<div className={styles.crossPanelSyncSectionHeader}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<div className={styles.crossPanelSyncTooltipContent}>
|
||||
<strong className={styles.crossPanelSyncTooltipTitle}>
|
||||
Cross-Panel Sync
|
||||
</strong>
|
||||
<span className={styles.crossPanelSyncTooltipDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</span>
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.crossPanelSyncTooltipDocLink}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Sync Mode
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Synced Tooltip Series
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Show only series that intersect on group-by, or every series with the
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(e): void => {
|
||||
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: e.target.value,
|
||||
});
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<div className={styles.overviewSettingsFooter}>
|
||||
<div className={styles.unsaved}>
|
||||
<div className={styles.unsavedDot} />
|
||||
<Typography.Text className={styles.unsavedChanges}>
|
||||
{numberOfUnsavedChanges} unsaved change
|
||||
{numberOfUnsavedChanges > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.footerActionBtns}>
|
||||
<Button
|
||||
disabled={isSaving}
|
||||
icon={<X size={14} />}
|
||||
onClick={discardHandler}
|
||||
type="text"
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
style={{ margin: '16px 0' }}
|
||||
disabled={isSaving}
|
||||
loading={isSaving}
|
||||
icon={<Check size={14} />}
|
||||
data-testid="save-dashboard-config"
|
||||
onClick={onSaveHandler}
|
||||
type="primary"
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneralDashboardSettingsV2;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Button as ButtonComponent, Drawer } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
export const Button = styled(ButtonComponent)`
|
||||
&&& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DrawerContainer = styled(Drawer)`
|
||||
.ant-drawer-header {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
`;
|
||||
File diff suppressed because one or more lines are too long
@@ -1,67 +0,0 @@
|
||||
import { Button, Empty, Tabs } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
|
||||
import '../../DashboardContainer/DashboardSettings/DashboardSettingsContent.styles.scss';
|
||||
|
||||
import GeneralDashboardSettingsV2 from './General';
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function Placeholder({ message }: { message: string }): JSX.Element {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<Typography.Text>{message}</Typography.Text>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Table size={14} />}>
|
||||
General
|
||||
</Button>
|
||||
),
|
||||
key: 'general',
|
||||
children: (
|
||||
<GeneralDashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Braces size={14} />}>
|
||||
Variables
|
||||
</Button>
|
||||
),
|
||||
key: 'variables',
|
||||
children: <Placeholder message="V2 dashboard variables coming next." />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Globe size={14} />}>
|
||||
Publish
|
||||
</Button>
|
||||
),
|
||||
key: 'public-dashboard',
|
||||
children: (
|
||||
<Placeholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return <Tabs items={items} />;
|
||||
}
|
||||
|
||||
export default DashboardSettingsV2;
|
||||
@@ -1,17 +0,0 @@
|
||||
.addButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--bg-slate-400, #1d212d);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSectionV2 } from '../utils';
|
||||
import { useAddSection } from './hooks/useAddSection';
|
||||
import { useFirstSectionMigration } from './hooks/useFirstSectionMigration';
|
||||
import FirstSectionMigrationModal from './FirstSectionMigrationModal';
|
||||
import styles from './AddSectionControl.module.scss';
|
||||
|
||||
const DEFAULT_SECTION_TITLE = 'New section';
|
||||
|
||||
interface Props {
|
||||
sections: DashboardSectionV2[];
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
isSectioned: boolean;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function AddSectionControl({
|
||||
sections,
|
||||
layouts,
|
||||
dashboardId,
|
||||
isSectioned,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const [isMigrationOpen, setIsMigrationOpen] = useState(false);
|
||||
const { addSection } = useAddSection({ layouts, dashboardId, onRefetch });
|
||||
const { migrate, isSaving } = useFirstSectionMigration({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
|
||||
// Free-flowing dashboard with existing panels → must migrate before sections
|
||||
// can coexist (every panel must belong to a section once any exists).
|
||||
const needsMigration =
|
||||
!isSectioned && sections.some((s) => s.items.length > 0);
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (needsMigration) {
|
||||
setIsMigrationOpen(true);
|
||||
return;
|
||||
}
|
||||
void addSection(DEFAULT_SECTION_TITLE);
|
||||
};
|
||||
|
||||
const handleConfirmMigration = async (): Promise<void> => {
|
||||
await migrate(DEFAULT_SECTION_TITLE);
|
||||
setIsMigrationOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addButton}
|
||||
onClick={handleClick}
|
||||
data-testid="add-section"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add section
|
||||
</button>
|
||||
<FirstSectionMigrationModal
|
||||
open={isMigrationOpen}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsMigrationOpen(false)}
|
||||
onConfirm={handleConfirmMigration}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddSectionControl;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when the user adds the first section to a free-flowing dashboard that
|
||||
* already has panels. Confirms grouping the existing panels into a section
|
||||
* before proceeding.
|
||||
*/
|
||||
function FirstSectionMigrationModal({
|
||||
open,
|
||||
isSaving,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Group panels into sections?"
|
||||
onCancel={onClose}
|
||||
onOk={onConfirm}
|
||||
okText="Continue"
|
||||
okButtonProps={{ disabled: isSaving, 'data-testid': 'confirm-migration' }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Typography.Text>
|
||||
This dashboard's panels are currently free-flowing. Adding a section
|
||||
will move the existing panels into their own section, and a new empty
|
||||
section will be added below. You can rename sections afterwards.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default FirstSectionMigrationModal;
|
||||
@@ -1,4 +0,0 @@
|
||||
.emptyState {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DashboardSectionV2 } from '../utils';
|
||||
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
|
||||
import type { DeletePanelArgs } from './hooks/useDeletePanel';
|
||||
import styles from './PanelActionsMenu.module.scss';
|
||||
|
||||
interface Props {
|
||||
panelId: string;
|
||||
currentLayoutIndex: number;
|
||||
sections: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function PanelActionsMenu({
|
||||
panelId,
|
||||
currentLayoutIndex,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = [];
|
||||
|
||||
if (onMovePanel) {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
disabled: true,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
onMovePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (onDeletePanel) {
|
||||
if (result.length > 0) {
|
||||
result.push({ type: 'divider' });
|
||||
}
|
||||
result.push({
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete panel',
|
||||
onClick: (): void =>
|
||||
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label="Panel actions"
|
||||
data-testid={`panel-actions-${panelId}`}
|
||||
// Stop pointer/mouse down from reaching the RGL drag handle this
|
||||
// button lives inside, so opening the menu never starts a panel drag.
|
||||
onPointerDown={(e): void => e.stopPropagation()}
|
||||
onMouseDown={(e): void => e.stopPropagation()}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelActionsMenu;
|
||||
@@ -1,22 +0,0 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.typeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Modal } from 'antd';
|
||||
import {
|
||||
BarChart,
|
||||
ChartLine,
|
||||
ChartPie,
|
||||
Hash,
|
||||
List,
|
||||
Table,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import styles from './PanelTypeSelectionModalV2.module.scss';
|
||||
|
||||
interface PanelType {
|
||||
pluginKind: string;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
}
|
||||
|
||||
const PANEL_TYPES: PanelType[] = [
|
||||
{
|
||||
pluginKind: 'signoz/TimeSeriesPanel',
|
||||
label: 'Time Series',
|
||||
icon: <ChartLine size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
|
||||
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
|
||||
{
|
||||
pluginKind: 'signoz/BarChartPanel',
|
||||
label: 'Bar Chart',
|
||||
icon: <BarChart size={16} />,
|
||||
},
|
||||
{
|
||||
pluginKind: 'signoz/PieChartPanel',
|
||||
label: 'Pie Chart',
|
||||
icon: <ChartPie size={16} />,
|
||||
},
|
||||
{
|
||||
pluginKind: 'signoz/HistogramPanel',
|
||||
label: 'Histogram',
|
||||
icon: <BarChart size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (pluginKind: string) => void;
|
||||
}
|
||||
|
||||
function PanelTypeSelectionModalV2({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Select a panel type"
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.pluginKind}
|
||||
type="button"
|
||||
className={styles.typeButton}
|
||||
data-testid={`panel-type-${type.pluginKind}`}
|
||||
onClick={(): void => onSelect(type.pluginKind)}
|
||||
>
|
||||
{type.icon}
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeSelectionModalV2;
|
||||
@@ -1,52 +0,0 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--bg-slate-400, #1d212d);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bodyKind {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { EllipsisVertical } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSectionV2 } from '../utils';
|
||||
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
|
||||
import type { DeletePanelArgs } from './hooks/useDeletePanel';
|
||||
import PanelActionsMenu from './PanelActionsMenu';
|
||||
import styles from './PanelV2.module.scss';
|
||||
|
||||
interface Props {
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
/**
|
||||
* Placeholder: true once this panel's section enters the viewport. The panel
|
||||
* query-loading implementation (later PR) will consume this to lazily fetch
|
||||
* data. Currently unused on purpose.
|
||||
*/
|
||||
isVisible?: boolean;
|
||||
/** Section actions — present only in editable sectioned mode. */
|
||||
currentLayoutIndex?: number;
|
||||
sections?: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function PanelV2({
|
||||
panel,
|
||||
panelId,
|
||||
isVisible,
|
||||
currentLayoutIndex,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
|
||||
const description = panel?.spec?.display?.description;
|
||||
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel?.spec?.queries?.length ?? 0;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!description) {
|
||||
return name;
|
||||
}
|
||||
return (
|
||||
<Tooltip title={description}>
|
||||
<span>{name}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [name, description]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
>
|
||||
<div className={`${styles.header} panel-drag-handle`}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>
|
||||
{headerTitle}
|
||||
</Typography.Text>
|
||||
<Badge className={styles.badge}>{kind}</Badge>
|
||||
</div>
|
||||
{currentLayoutIndex !== undefined && (onMovePanel || onDeletePanel) ? (
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
currentLayoutIndex={currentLayoutIndex}
|
||||
sections={sections ?? []}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
) : (
|
||||
<EllipsisVertical size={14} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div>
|
||||
<div className={styles.bodyKind}>{kind} panel</div>
|
||||
<div>
|
||||
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
|
||||
coming next
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelV2;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initialValue: string;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (title: string) => void;
|
||||
}
|
||||
|
||||
function RenameSectionModal({
|
||||
open,
|
||||
initialValue,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props): JSX.Element {
|
||||
const [value, setValue] = useState<string>(initialValue);
|
||||
|
||||
// Reseed the field each time the modal opens.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(initialValue);
|
||||
}
|
||||
}, [open, initialValue]);
|
||||
|
||||
const submit = (): void => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
onSubmit(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Rename section"
|
||||
onCancel={onClose}
|
||||
onOk={submit}
|
||||
okText="Rename"
|
||||
okButtonProps={{ disabled: isSaving || !value.trim() }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
testId="rename-section-input"
|
||||
autoFocus
|
||||
value={value}
|
||||
maxLength={120}
|
||||
placeholder="Section name"
|
||||
onChange={(e): void => setValue(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameSectionModal;
|
||||
@@ -1,9 +0,0 @@
|
||||
.section {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import type { DashboardSectionV2 } from '../utils';
|
||||
import { useRenameSection } from './hooks/useRenameSection';
|
||||
import { useToggleSectionCollapse } from './hooks/useToggleSectionCollapse';
|
||||
import { useDeleteSection } from './hooks/useDeleteSection';
|
||||
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
|
||||
import type { AddPanelArgs } from './hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from './hooks/useDeletePanel';
|
||||
import PanelTypeSelectionModalV2 from './PanelTypeSelectionModalV2';
|
||||
import RenameSectionModal from './RenameSectionModal';
|
||||
import SectionGrid from './SectionGrid';
|
||||
import SectionHeader, { type SectionDragHandle } from './SectionHeader';
|
||||
import styles from './Section.module.scss';
|
||||
|
||||
interface Props {
|
||||
section: DashboardSectionV2;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
/** All sections + move handler, for the per-panel "Move to section" action. */
|
||||
sections?: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
/** Adds a panel to this section; present only in editable sectioned mode. */
|
||||
onAddPanel?: (args: AddPanelArgs) => void;
|
||||
/** Deletes a panel from this section. */
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow sections. */
|
||||
dragHandle?: SectionDragHandle;
|
||||
}
|
||||
|
||||
function Section({
|
||||
section,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onAddPanel,
|
||||
onDeletePanel,
|
||||
dragHandle,
|
||||
}: Props): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
// true once the section scrolls into (or near) the viewport.
|
||||
const isVisible = useIntersectionObserver(containerRef, {
|
||||
rootMargin: '200px',
|
||||
});
|
||||
|
||||
const { open, toggle } = useToggleSectionCollapse({
|
||||
layoutIndex: section.layoutIndex,
|
||||
initialOpen: section.open,
|
||||
dashboardId,
|
||||
});
|
||||
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const { rename, isSaving } = useRenameSection({
|
||||
layoutIndex: section.layoutIndex,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
|
||||
const handleRenameSubmit = async (title: string): Promise<void> => {
|
||||
const ok = await rename(title);
|
||||
if (ok) {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [isAddingPanel, setIsAddingPanel] = useState(false);
|
||||
const handleSelectPanelType = (pluginKind: string): void => {
|
||||
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
|
||||
setIsAddingPanel(false);
|
||||
};
|
||||
|
||||
const { deleteSection } = useDeleteSection({
|
||||
section,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
const confirmDeleteSection = (): void => {
|
||||
Modal.confirm({
|
||||
title: `Delete section "${section.title ?? ''}"?`,
|
||||
content: 'Panels in this section will be removed.',
|
||||
okText: 'Delete',
|
||||
okButtonProps: { danger: true },
|
||||
centered: true,
|
||||
onOk: () => deleteSection(),
|
||||
});
|
||||
};
|
||||
|
||||
const grid = (
|
||||
<SectionGrid
|
||||
items={section.items}
|
||||
layoutIndex={section.layoutIndex}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
isVisible={isVisible}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!section.title) {
|
||||
// Untitled section — just the grid (no header chrome), but still observed
|
||||
// for the viewport signal.
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
{grid}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.section}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
<SectionHeader
|
||||
sectionId={section.id}
|
||||
title={section.title}
|
||||
open={open}
|
||||
onToggle={toggle}
|
||||
repeatVariable={section.repeatVariable}
|
||||
dragHandle={dragHandle}
|
||||
onRename={isEditable ? (): void => setIsRenaming(true) : undefined}
|
||||
onAddPanel={
|
||||
isEditable && onAddPanel ? (): void => setIsAddingPanel(true) : undefined
|
||||
}
|
||||
onDeleteSection={isEditable ? confirmDeleteSection : undefined}
|
||||
/>
|
||||
{open ? grid : null}
|
||||
<RenameSectionModal
|
||||
open={isRenaming}
|
||||
initialValue={section.title}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsRenaming(false)}
|
||||
onSubmit={handleRenameSubmit}
|
||||
/>
|
||||
<PanelTypeSelectionModalV2
|
||||
open={isAddingPanel}
|
||||
onClose={(): void => setIsAddingPanel(false)}
|
||||
onSelect={handleSelectPanelType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Section;
|
||||
@@ -1,16 +0,0 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import styles from './SectionActionsMenu.module.scss';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
onAddPanel?: () => void;
|
||||
onRename?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
}
|
||||
|
||||
function SectionActionsMenu({
|
||||
sectionId,
|
||||
onAddPanel,
|
||||
onRename,
|
||||
onDeleteSection,
|
||||
}: Props): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = [];
|
||||
if (onAddPanel) {
|
||||
result.push({
|
||||
key: 'add-panel',
|
||||
icon: <Plus size={14} />,
|
||||
label: 'Add panel',
|
||||
onClick: onAddPanel,
|
||||
});
|
||||
}
|
||||
if (onRename) {
|
||||
result.push({
|
||||
key: 'rename',
|
||||
icon: <PenLine size={14} />,
|
||||
label: 'Rename section',
|
||||
onClick: onRename,
|
||||
});
|
||||
}
|
||||
if (onDeleteSection) {
|
||||
result.push(
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete-section',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete section',
|
||||
onClick: onDeleteSection,
|
||||
},
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [onAddPanel, onRename, onDeleteSection]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label="Section actions"
|
||||
data-testid={`dashboard-section-actions-${sectionId}`}
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionActionsMenu;
|
||||
@@ -1,7 +0,0 @@
|
||||
.preview {
|
||||
border: 1px solid var(--bg-robin-500);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 40%);
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { DashboardSectionV2 } from '../utils';
|
||||
import SectionHeader from './SectionHeader';
|
||||
import styles from './SectionDragPreview.module.scss';
|
||||
|
||||
interface Props {
|
||||
section: DashboardSectionV2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight preview rendered inside the DragOverlay while a section is being
|
||||
* dragged. Deliberately header-only (no react-grid-layout) so the overlay is
|
||||
* cheap and never triggers RGL width re-measurement.
|
||||
*/
|
||||
function SectionDragPreview({ section }: Props): JSX.Element {
|
||||
const panelCount = section.items.length;
|
||||
const title = `${section.title ?? ''} · ${panelCount} ${
|
||||
panelCount === 1 ? 'panel' : 'panels'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<SectionHeader
|
||||
sectionId={`${section.id}-preview`}
|
||||
title={title}
|
||||
open={false}
|
||||
onToggle={(): void => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionDragPreview;
|
||||
@@ -1,12 +0,0 @@
|
||||
.grid {
|
||||
// Override react-grid-layout's default red drag/resize placeholder with the
|
||||
// SigNoz brand blue.
|
||||
:global(.react-grid-item.react-grid-placeholder) {
|
||||
background: var(--bg-robin-500);
|
||||
opacity: 0.2;
|
||||
border-radius: 4px;
|
||||
transition-duration: 100ms;
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import GridLayout, {
|
||||
type ItemCallback,
|
||||
WidthProvider,
|
||||
type Layout,
|
||||
} from 'react-grid-layout';
|
||||
|
||||
import type { DashboardSectionV2 } from '../utils';
|
||||
import { usePersistLayout } from './hooks/usePersistLayout';
|
||||
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
|
||||
import type { DeletePanelArgs } from './hooks/useDeletePanel';
|
||||
import PanelV2 from './PanelV2';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
|
||||
interface Props {
|
||||
items: DashboardSectionV2['items'];
|
||||
layoutIndex: number;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
/** Forwarded to panels — true when the parent section is in the viewport. */
|
||||
isVisible?: boolean;
|
||||
/** All sections + move handler — present only in editable sectioned mode (panel "Move to section"). */
|
||||
sections?: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SectionGrid({
|
||||
items,
|
||||
layoutIndex,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
isVisible,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})),
|
||||
[items],
|
||||
);
|
||||
|
||||
const { handleLayoutChange } = usePersistLayout({
|
||||
layoutIndex,
|
||||
items,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
|
||||
// On drop, if the pointer is released over a different section, move the
|
||||
// panel there instead of repositioning it within this section. RGL clamps
|
||||
// the dragged item visually to this grid, but the pointer is free, so we
|
||||
// hit-test the release point against section containers.
|
||||
const handleDragStop = useCallback<ItemCallback>(
|
||||
// eslint-disable-next-line max-params -- signature fixed by react-grid-layout's ItemCallback
|
||||
(layout, oldItem, _newItem, _placeholder, event) => {
|
||||
// Deterministically hit-test the release point against section
|
||||
// containers (rect-based, so the dragged item on top doesn't interfere).
|
||||
const targetEl = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-section-layout-index]'),
|
||||
).find((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= r.left &&
|
||||
event.clientX <= r.right &&
|
||||
event.clientY >= r.top &&
|
||||
event.clientY <= r.bottom
|
||||
);
|
||||
});
|
||||
const attr = targetEl?.getAttribute('data-section-layout-index');
|
||||
const targetIndex = attr != null ? Number(attr) : null;
|
||||
|
||||
if (onMovePanel && targetIndex != null && targetIndex !== layoutIndex) {
|
||||
onMovePanel({
|
||||
panelId: oldItem.i,
|
||||
fromLayoutIndex: layoutIndex,
|
||||
toLayoutIndex: targetIndex,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleLayoutChange(layout);
|
||||
},
|
||||
[onMovePanel, layoutIndex, handleLayoutChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
className={styles.grid}
|
||||
cols={12}
|
||||
rowHeight={45}
|
||||
autoSize
|
||||
useCSSTransforms
|
||||
layout={rglLayout}
|
||||
draggableHandle=".panel-drag-handle"
|
||||
isDraggable={isEditable}
|
||||
isResizable={isEditable}
|
||||
onDragStop={handleDragStop}
|
||||
onResizeStop={handleLayoutChange}
|
||||
margin={[8, 8]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
<PanelV2
|
||||
panel={item.panel}
|
||||
panelId={item.id}
|
||||
isVisible={isVisible}
|
||||
currentLayoutIndex={layoutIndex}
|
||||
sections={isEditable ? sections : undefined}
|
||||
onMovePanel={isEditable ? onMovePanel : undefined}
|
||||
onDeletePanel={isEditable ? onDeletePanel : undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionGrid;
|
||||
@@ -1,52 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
|
||||
&.headerOpen {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.repeatBadge {
|
||||
margin-left: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { DraggableAttributes } from '@dnd-kit/core';
|
||||
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||
import { ChevronDown, ChevronRight, GripVertical } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import SectionActionsMenu from './SectionActionsMenu';
|
||||
import styles from './SectionHeader.module.scss';
|
||||
|
||||
export interface SectionDragHandle {
|
||||
attributes: DraggableAttributes;
|
||||
listeners: SyntheticListenerMap | undefined;
|
||||
setActivatorNodeRef: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
repeatVariable?: string;
|
||||
dragHandle?: SectionDragHandle;
|
||||
onRename?: () => void;
|
||||
onAddPanel?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
sectionId,
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
repeatVariable,
|
||||
dragHandle,
|
||||
onRename,
|
||||
onAddPanel,
|
||||
onDeleteSection,
|
||||
}: Props): JSX.Element {
|
||||
const hasActions = !!(onAddPanel || onRename || onDeleteSection);
|
||||
return (
|
||||
<div className={`${styles.header} ${open ? styles.headerOpen : ''}`}>
|
||||
{dragHandle ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dragHandle}
|
||||
ref={dragHandle.setActivatorNodeRef}
|
||||
aria-label="Drag to reorder section"
|
||||
data-testid={`dashboard-section-drag-${sectionId}`}
|
||||
{...dragHandle.attributes}
|
||||
{...dragHandle.listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
onClick={onToggle}
|
||||
data-testid={`dashboard-section-toggle-${sectionId}`}
|
||||
>
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
{repeatVariable ? (
|
||||
<Typography.Text className={styles.repeatBadge}>
|
||||
(repeats per ${repeatVariable})
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</button>
|
||||
{hasActions ? (
|
||||
<SectionActionsMenu
|
||||
sectionId={sectionId}
|
||||
onAddPanel={onAddPanel}
|
||||
onRename={onRename}
|
||||
onDeleteSection={onDeleteSection}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionHeader;
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { closestCenter, DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSectionV2 } from '../utils';
|
||||
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
|
||||
import { useMovePanelToSection } from './hooks/useMovePanelToSection';
|
||||
import { useAddPanelToSection } from './hooks/useAddPanelToSection';
|
||||
import { useDeletePanel } from './hooks/useDeletePanel';
|
||||
import Section from './Section';
|
||||
import SectionDragPreview from './SectionDragPreview';
|
||||
import SortableSection from './SortableSection';
|
||||
|
||||
interface Props {
|
||||
sections: DashboardSectionV2[];
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function SectionList({
|
||||
sections,
|
||||
layouts,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const {
|
||||
sensors,
|
||||
orderedSections,
|
||||
activeSection,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragCancel,
|
||||
} = useSectionDragReorder({ sections, layouts, dashboardId, onRefetch });
|
||||
|
||||
const onMovePanel = useMovePanelToSection({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
const onAddPanel = useAddPanelToSection({ sections, dashboardId, onRefetch });
|
||||
const onDeletePanel = useDeletePanel({ sections, dashboardId, onRefetch });
|
||||
|
||||
// Only titled sections participate in reordering; untitled (free-flow)
|
||||
// blocks render in place without a drag handle.
|
||||
const sortableIds = useMemo(
|
||||
() => orderedSections.filter((s) => s.title).map((s) => s.id),
|
||||
[orderedSections],
|
||||
);
|
||||
|
||||
if (!isEditable) {
|
||||
return (
|
||||
<>
|
||||
{sections.map((section) => (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
>
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{orderedSections.map((section) =>
|
||||
section.title ? (
|
||||
<SortableSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onAddPanel={onAddPanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
) : (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onAddPanel={onAddPanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</SortableContext>
|
||||
{/* dropAnimation disabled: optimistic reorder already places the section,
|
||||
so animating the overlay back would cause a visible snap/shake. */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeSection ? <SectionDragPreview section={activeSection} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionList;
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import type { DashboardSectionV2 } from '../utils';
|
||||
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
|
||||
import type { AddPanelArgs } from './hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from './hooks/useDeletePanel';
|
||||
import Section from './Section';
|
||||
|
||||
interface Props {
|
||||
section: DashboardSectionV2;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
sections: DashboardSectionV2[];
|
||||
onMovePanel: (args: MovePanelArgs) => void;
|
||||
onAddPanel: (args: AddPanelArgs) => void;
|
||||
onDeletePanel: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SortableSection({
|
||||
section,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onAddPanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: section.id });
|
||||
|
||||
// dnd-kit drives the drag transform per-frame, so this must be an inline
|
||||
// style — there is no static-stylesheet equivalent for a live transform.
|
||||
// While dragging, the original is hidden (the DragOverlay renders the moving
|
||||
// preview); keeping it in place preserves the gap and lets siblings animate.
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<Section
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onAddPanel={onAddPanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortableSection;
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
addPanelToSectionOps,
|
||||
createDefaultPanel,
|
||||
panelRef,
|
||||
} from '../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export interface AddPanelArgs {
|
||||
layoutIndex: number;
|
||||
pluginKind: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new panel and places its item ref at the bottom of the target
|
||||
* section, as one atomic patch. Structure-only: the panel is a valid minimal
|
||||
* placeholder (its query is filled in once the panel editor lands).
|
||||
*/
|
||||
export function useAddPanelToSection({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): (args: AddPanelArgs) => Promise<void> {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const target = sections.find((s) => s.layoutIndex === layoutIndex);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelId = uuid();
|
||||
const nextY = target.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
|
||||
try {
|
||||
await patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
addPanelToSectionOps({
|
||||
panelId,
|
||||
panel: createDefaultPanel(pluginKind),
|
||||
layoutIndex,
|
||||
item: {
|
||||
x: 0,
|
||||
y: nextY,
|
||||
width: 6,
|
||||
height: 6,
|
||||
content: { $ref: panelRef(panelId) },
|
||||
},
|
||||
}),
|
||||
);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { addSectionOp, newGridLayout, reorderLayoutsOp } from '../../patchOps';
|
||||
|
||||
interface Params {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
addSection: (title: string) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an empty titled section. When the dashboard has no layouts yet, the
|
||||
* layouts array is created via a `replace` (an `add` to a missing/empty array
|
||||
* pointer is unreliable); otherwise a new Grid is appended.
|
||||
*/
|
||||
export function useAddSection({
|
||||
layouts,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const addSection = useCallback(
|
||||
async (title: string): Promise<void> => {
|
||||
const trimmed = title.trim();
|
||||
if (!dashboardId || !trimmed) {
|
||||
return;
|
||||
}
|
||||
const op =
|
||||
!layouts || layouts.length === 0
|
||||
? reorderLayoutsOp([newGridLayout(trimmed)])
|
||||
: addSectionOp(trimmed);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [op]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[layouts, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { addSection, isSaving };
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { removePanelOp, replaceSectionItemsOp } from '../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export interface DeletePanelArgs {
|
||||
panelId: string;
|
||||
layoutIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a panel: drops its item ref from the section's items and deletes the
|
||||
* panel from `spec.panels`, as one atomic patch.
|
||||
*/
|
||||
export function useDeletePanel({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): (args: DeletePanelArgs) => Promise<void> {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
async ({ panelId, layoutIndex }: DeletePanelArgs): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const section = sections.find((s) => s.layoutIndex === layoutIndex);
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextItems = section.items.filter((i) => i.id !== panelId);
|
||||
try {
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
replaceSectionItemsOp(layoutIndex, nextItems),
|
||||
removePanelOp(panelId),
|
||||
]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { removePanelOp, removeSectionOp } from '../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../utils';
|
||||
|
||||
interface Params {
|
||||
section: DashboardSectionV2;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
deleteSection: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a section: removes its Grid layout and deletes every panel it
|
||||
* contained from `spec.panels` (orphan cleanup), as one atomic patch.
|
||||
*/
|
||||
export function useDeleteSection({
|
||||
section,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const deleteSection = useCallback(async (): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = section.items.map((i) =>
|
||||
removePanelOp(i.id),
|
||||
);
|
||||
ops.push(removeSectionOp(section.layoutIndex));
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, ops);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [section, dashboardId, onRefetch, showErrorModal]);
|
||||
|
||||
return { deleteSection, isSaving };
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { addSectionOp, titleUntitledSectionOp } from '../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
migrate: (newSectionTitle: string) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a free-flowing dashboard into a sectioned one: every existing
|
||||
* untitled layout that holds panels is titled in place ("Section 1", "Section
|
||||
* 2", …), then the brand-new section the user asked for is appended — all in one
|
||||
* atomic patch. Used once the user confirms the migration prompt.
|
||||
*/
|
||||
export function useFirstSectionMigration({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const migrate = useCallback(
|
||||
async (newSectionTitle: string): Promise<void> => {
|
||||
const trimmed = newSectionTitle.trim();
|
||||
if (!dashboardId || !trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
let counter = 1;
|
||||
sections.forEach((s) => {
|
||||
if (!s.title && s.items.length > 0) {
|
||||
ops.push(titleUntitledSectionOp(s.layoutIndex, `Section ${counter}`));
|
||||
counter += 1;
|
||||
}
|
||||
});
|
||||
ops.push(addSectionOp(trimmed));
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, ops);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { migrate, isSaving };
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { movePanelBetweenSectionsOps } from '../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../utils';
|
||||
|
||||
export interface MovePanelArgs {
|
||||
panelId: string;
|
||||
fromLayoutIndex: number;
|
||||
toLayoutIndex: number;
|
||||
}
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocates a panel's item ref from one section to another. The panel itself
|
||||
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
|
||||
* the bottom of the target section. Persisted as one atomic patch.
|
||||
*/
|
||||
export function useMovePanelToSection({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): (args: MovePanelArgs) => Promise<void> {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
async ({
|
||||
panelId,
|
||||
fromLayoutIndex,
|
||||
toLayoutIndex,
|
||||
}: MovePanelArgs): Promise<void> => {
|
||||
if (!dashboardId || fromLayoutIndex === toLayoutIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = sections.find((s) => s.layoutIndex === fromLayoutIndex);
|
||||
const target = sections.find((s) => s.layoutIndex === toLayoutIndex);
|
||||
if (!source || !target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const moved = source.items.find((i) => i.id === panelId);
|
||||
if (!moved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceItems = source.items.filter((i) => i.id !== panelId);
|
||||
// Place at a fresh row at the bottom of the target section.
|
||||
const nextY = target.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
|
||||
|
||||
try {
|
||||
await patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
movePanelBetweenSectionsOps({
|
||||
sourceIndex: fromLayoutIndex,
|
||||
sourceItems,
|
||||
targetIndex: toLayoutIndex,
|
||||
targetItems,
|
||||
}),
|
||||
);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { Layout } from 'react-grid-layout';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { replaceSectionItemsOp } from '../../patchOps';
|
||||
import type { GridItemV2 } from '../../utils';
|
||||
|
||||
interface Params {
|
||||
layoutIndex: number;
|
||||
items: GridItemV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
handleLayoutChange: (rglLayout: Layout[]) => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/** Maps an RGL layout back onto the section's grid items, preserving panel refs. */
|
||||
function mergeRglLayout(
|
||||
rglLayout: Layout[],
|
||||
items: GridItemV2[],
|
||||
): GridItemV2[] {
|
||||
const byId = new Map(items.map((item) => [item.id, item]));
|
||||
return rglLayout
|
||||
.map((entry) => {
|
||||
const existing = byId.get(entry.i);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
x: entry.x,
|
||||
y: entry.y,
|
||||
width: entry.w,
|
||||
height: entry.h,
|
||||
};
|
||||
})
|
||||
.filter((item): item is GridItemV2 => item !== null);
|
||||
}
|
||||
|
||||
function hasGeometryChanged(next: GridItemV2[], prev: GridItemV2[]): boolean {
|
||||
if (next.length !== prev.length) {
|
||||
return true;
|
||||
}
|
||||
const prevById = new Map(prev.map((item) => [item.id, item]));
|
||||
return next.some((item) => {
|
||||
const before = prevById.get(item.id);
|
||||
if (!before) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
before.x !== item.x ||
|
||||
before.y !== item.y ||
|
||||
before.width !== item.width ||
|
||||
before.height !== item.height
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists panel geometry within a single section. Call the returned handler
|
||||
* from RGL's `onDragStop`/`onResizeStop` (stop events only — not continuous
|
||||
* `onLayoutChange`) to limit network churn.
|
||||
*/
|
||||
export function usePersistLayout({
|
||||
layoutIndex,
|
||||
items,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
async (rglLayout: Layout[]): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const nextItems = mergeRglLayout(rglLayout, items);
|
||||
if (!hasGeometryChanged(nextItems, items)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
replaceSectionItemsOp(layoutIndex, nextItems),
|
||||
]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, items, layoutIndex, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { handleLayoutChange, isSaving };
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { renameSectionOp } from '../../patchOps';
|
||||
|
||||
interface Params {
|
||||
layoutIndex: number;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
rename: (title: string) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/** Renames a section's title via `replace /spec/layouts/<i>/spec/display/title`. */
|
||||
export function useRenameSection({
|
||||
layoutIndex,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const rename = useCallback(
|
||||
async (title: string): Promise<boolean> => {
|
||||
const trimmed = title.trim();
|
||||
if (!dashboardId || !trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
renameSectionOp(layoutIndex, trimmed),
|
||||
]);
|
||||
onRefetch();
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, layoutIndex, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { rename, isSaving };
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { reorderLayoutsOp } from '../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
sensors: ReturnType<typeof useSensors>;
|
||||
/** Display order — optimistically reordered on drop so the UI doesn't wait on refetch. */
|
||||
orderedSections: DashboardSectionV2[];
|
||||
/** The section currently being dragged (for the DragOverlay preview), or null. */
|
||||
activeSection: DashboardSectionV2 | null;
|
||||
onDragStart: (event: DragStartEvent) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onDragCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns section-reorder drag state. Reorders happen optimistically in local
|
||||
* state (keyed by stable section id) and persist via a single
|
||||
* `replace /spec/layouts` patch; the optimistic order is cleared once fresh
|
||||
* server data arrives. Each section maps 1:1 to a Grid layout via `layoutIndex`,
|
||||
* so the new layouts array is rebuilt by mapping the reordered sections back.
|
||||
*/
|
||||
export function useSectionDragReorder({
|
||||
sections,
|
||||
layouts,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
// Server data is the source of truth — drop optimistic order whenever it changes.
|
||||
useEffect(() => {
|
||||
setLocalOrderIds(null);
|
||||
}, [sections]);
|
||||
|
||||
const orderedSections = useMemo<DashboardSectionV2[]>(() => {
|
||||
if (!localOrderIds) {
|
||||
return sections;
|
||||
}
|
||||
const byId = new Map(sections.map((s) => [s.id, s]));
|
||||
const ordered = localOrderIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((s): s is DashboardSectionV2 => s !== undefined);
|
||||
return ordered.length === sections.length ? ordered : sections;
|
||||
}, [sections, localOrderIds]);
|
||||
|
||||
const onDragStart = useCallback((event: DragStartEvent): void => {
|
||||
setActiveId(String(event.active.id));
|
||||
}, []);
|
||||
|
||||
const onDragCancel = useCallback((): void => {
|
||||
setActiveId(null);
|
||||
}, []);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
async (event: DragEndEvent): Promise<void> => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || !dashboardId || !layouts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldIndex = orderedSections.findIndex((s) => s.id === active.id);
|
||||
const newIndex = orderedSections.findIndex((s) => s.id === over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrdered = arrayMove(orderedSections, oldIndex, newIndex);
|
||||
setLocalOrderIds(newOrdered.map((s) => s.id));
|
||||
|
||||
const newLayouts = newOrdered
|
||||
.map((s) => layouts[s.layoutIndex])
|
||||
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
|
||||
|
||||
try {
|
||||
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
setLocalOrderIds(null); // revert optimistic order on failure
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[orderedSections, layouts, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
const activeSection = useMemo(
|
||||
() => orderedSections.find((s) => s.id === activeId) ?? null,
|
||||
[orderedSections, activeId],
|
||||
);
|
||||
|
||||
return {
|
||||
sensors,
|
||||
orderedSections,
|
||||
activeSection,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragCancel,
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { setSectionCollapseOp } from '../../patchOps';
|
||||
|
||||
interface Params {
|
||||
layoutIndex: number;
|
||||
initialOpen: boolean;
|
||||
dashboardId: string | undefined;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns a section's expand/collapse state. The toggle is optimistic (snappy UI)
|
||||
* and persists to `spec.layouts[i].spec.display.collapse.open` in the
|
||||
* background — no refetch, since collapse is a lightweight UI preference and a
|
||||
* full dashboard refetch would flicker. Reverts on patch failure.
|
||||
*/
|
||||
export function useToggleSectionCollapse({
|
||||
layoutIndex,
|
||||
initialOpen,
|
||||
dashboardId,
|
||||
}: Params): Result {
|
||||
const [open, setOpen] = useState<boolean>(initialOpen);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Reconcile with server state when it changes (e.g. after a refetch).
|
||||
useEffect(() => {
|
||||
setOpen(initialOpen);
|
||||
}, [initialOpen]);
|
||||
|
||||
const toggle = useCallback((): void => {
|
||||
setOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (dashboardId) {
|
||||
patchDashboardV2({ id: dashboardId }, [
|
||||
setSectionCollapseOp(layoutIndex, next),
|
||||
]).catch((error) => {
|
||||
setOpen(prev); // revert on failure
|
||||
showErrorModal(error as APIError);
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [dashboardId, layoutIndex, showErrorModal]);
|
||||
|
||||
return { open, toggle };
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Empty } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { layoutsToSections } from '../utils';
|
||||
import AddSectionControl from './AddSectionControl';
|
||||
import Section from './Section';
|
||||
import SectionList from './SectionList';
|
||||
import styles from './GridCardLayoutV2.module.scss';
|
||||
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
interface Props {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function GridCardLayoutV2({
|
||||
layouts,
|
||||
panels,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(layouts, panels),
|
||||
[layouts, panels],
|
||||
);
|
||||
|
||||
const isEmpty =
|
||||
sections.length === 0 || sections.every((s) => s.items.length === 0);
|
||||
|
||||
// Sectioned mode = at least one titled layout. Sections then become a
|
||||
// draggable, reorderable list; otherwise the dashboard is a single
|
||||
// free-flowing grid with no section chrome or reordering.
|
||||
const isSectioned = useMemo(() => sections.some((s) => !!s.title), [sections]);
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Typography.Text>No panels in this dashboard yet</Typography.Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSectioned) {
|
||||
return (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
layouts={layouts}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{sections.map((section) => (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderContent()}
|
||||
{isEditable ? (
|
||||
<AddSectionControl
|
||||
sections={sections}
|
||||
layouts={layouts}
|
||||
dashboardId={dashboardId}
|
||||
isSectioned={isSectioned}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default GridCardLayoutV2;
|
||||
@@ -1,63 +0,0 @@
|
||||
.dashboard-breadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
|
||||
.dashboard-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.id-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0px 2px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
.id-btn:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
|
||||
.dashboard-icon-image {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import getSessionStorageApi from 'api/browser/sessionstorage/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
|
||||
import { Base64Icons } from '../../../DashboardContainer/DashboardSettings/General/utils';
|
||||
|
||||
import './DashboardBreadcrumbs.styles.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
function DashboardBreadcrumbs({ title, image }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const goToListPage = useCallback(() => {
|
||||
const dashboardsListQueryParamsString = getSessionStorageApi(
|
||||
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (dashboardsListQueryParamsString) {
|
||||
safeNavigate({
|
||||
pathname: ROUTES.ALL_DASHBOARD,
|
||||
search: `?${dashboardsListQueryParamsString}`,
|
||||
});
|
||||
} else {
|
||||
safeNavigate(ROUTES.ALL_DASHBOARD);
|
||||
}
|
||||
}, [safeNavigate]);
|
||||
|
||||
return (
|
||||
<div className="dashboard-breadcrumbs">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LayoutGrid size={14} />}
|
||||
className="dashboard-btn"
|
||||
onClick={goToListPage}
|
||||
>
|
||||
Dashboard /
|
||||
</Button>
|
||||
<Button type="text" className="id-btn dashboard-name-btn">
|
||||
<img
|
||||
src={image || Base64Icons[0]}
|
||||
alt="dashboard-icon"
|
||||
className="dashboard-icon-image"
|
||||
/>
|
||||
{title}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardBreadcrumbs;
|
||||
@@ -1,9 +0,0 @@
|
||||
.dashboard-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
|
||||
|
||||
import './DashboardHeader.styles.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
function DashboardHeader({ title, image }: Props): JSX.Element {
|
||||
return (
|
||||
<div className="dashboard-header">
|
||||
<DashboardBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardHeader);
|
||||
@@ -1,46 +0,0 @@
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import DashboardDescriptionV2 from './DashboardDescriptionV2';
|
||||
import GridCardLayoutV2 from './GridCardLayoutV2';
|
||||
import type { V2Dashboard } from './utils';
|
||||
import styles from './DashboardContainerV2.module.scss';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function DashboardContainerV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
const spec = dashboard?.spec;
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const isEditable = !dashboard?.locked && editDashboard;
|
||||
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
<DashboardDescriptionV2
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
<div className={styles.body}>
|
||||
<GridCardLayoutV2
|
||||
layouts={spec?.layouts}
|
||||
panels={spec?.panels ?? undefined}
|
||||
dashboardId={dashboard?.id}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreen>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardContainerV2;
|
||||
@@ -1,189 +0,0 @@
|
||||
import type {
|
||||
DashboardGridItemDTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { GridItemV2 } from './utils';
|
||||
|
||||
/**
|
||||
* Pure RFC-6902 JSON-Patch builders for the V2 dashboard spec. These are
|
||||
* intentionally side-effect-free (no React, no network) so they can be unit
|
||||
* tested and reused by the layout hooks. JSON pointers target the postable
|
||||
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
|
||||
* patches in DashboardSettings/General and DashboardDescriptionV2).
|
||||
*/
|
||||
|
||||
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
|
||||
|
||||
const PANEL_REF_PREFIX = '#/spec/panels/';
|
||||
|
||||
export function panelRef(panelId: string): string {
|
||||
return `${PANEL_REF_PREFIX}${panelId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal, backend-valid panel for a given plugin kind. The spec
|
||||
* requires exactly one query whose plugin kind is allowed for the panel;
|
||||
* `signoz/BuilderQuery` is allowed for every panel kind and its contents are not
|
||||
* validated, so an empty builder query is the safe default. The real query is
|
||||
* filled in once the panel editor lands.
|
||||
*/
|
||||
export function createDefaultPanel(pluginKind: string): DashboardtypesPanelDTO {
|
||||
// The DTO types plugin/query kinds as large generated enum unions; the kind
|
||||
// here is chosen dynamically by the user, so we build the structurally-valid
|
||||
// shape and assert the type.
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'New panel' },
|
||||
plugin: { kind: pluginKind, spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { name: 'A' } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
/** Converts a UI grid item back into the spec's grid-item DTO shape. */
|
||||
export function gridItemToDTO(item: GridItemV2): DashboardGridItemDTO {
|
||||
return {
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
content: { $ref: panelRef(item.id) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace the entire items array of one section (used on panel move/resize). */
|
||||
export function replaceSectionItemsOp(
|
||||
layoutIndex: number,
|
||||
items: GridItemV2[],
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: replace,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/items`,
|
||||
value: items.map(gridItemToDTO),
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace the whole layouts array (used on section reorder — avoids move-index ambiguity). */
|
||||
export function reorderLayoutsOp(
|
||||
layouts: DashboardtypesLayoutDTO[],
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: replace, path: '/spec/layouts', value: layouts };
|
||||
}
|
||||
|
||||
/** An empty titled Grid layout (one section). */
|
||||
export function newGridLayout(title: string): DashboardtypesLayoutDTO {
|
||||
return {
|
||||
kind: 'Grid' as DashboardtypesLayoutDTO['kind'],
|
||||
spec: { display: { title, collapse: { open: true } }, items: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/** Append a new, empty titled Grid section. */
|
||||
export function addSectionOp(
|
||||
title: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: add, path: '/spec/layouts/-', value: newGridLayout(title) };
|
||||
}
|
||||
|
||||
interface AddPanelToSectionArgs {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
layoutIndex: number;
|
||||
item: DashboardGridItemDTO;
|
||||
}
|
||||
|
||||
/** Add a panel to `spec.panels` and an item ref into a section, as one atomic patch. */
|
||||
export function addPanelToSectionOps({
|
||||
panelId,
|
||||
panel,
|
||||
layoutIndex,
|
||||
item,
|
||||
}: AddPanelToSectionArgs): DashboardtypesJSONPatchOperationDTO[] {
|
||||
return [
|
||||
{ op: add, path: `/spec/panels/${panelId}`, value: panel },
|
||||
{ op: add, path: `/spec/layouts/${layoutIndex}/spec/items/-`, value: item },
|
||||
];
|
||||
}
|
||||
|
||||
interface MovePanelArgs {
|
||||
sourceIndex: number;
|
||||
sourceItems: GridItemV2[];
|
||||
targetIndex: number;
|
||||
targetItems: GridItemV2[];
|
||||
}
|
||||
|
||||
/** Move a panel's item ref from one section to another (panel stays in spec.panels). */
|
||||
export function movePanelBetweenSectionsOps({
|
||||
sourceIndex,
|
||||
sourceItems,
|
||||
targetIndex,
|
||||
targetItems,
|
||||
}: MovePanelArgs): DashboardtypesJSONPatchOperationDTO[] {
|
||||
return [
|
||||
replaceSectionItemsOp(sourceIndex, sourceItems),
|
||||
replaceSectionItemsOp(targetIndex, targetItems),
|
||||
];
|
||||
}
|
||||
|
||||
/** Rename an existing section's title. */
|
||||
export function renameSectionOp(
|
||||
layoutIndex: number,
|
||||
title: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: replace,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/display/title`,
|
||||
value: title,
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist a section's collapse state. `add` safely replaces the collapse object if present. */
|
||||
export function setSectionCollapseOp(
|
||||
layoutIndex: number,
|
||||
open: boolean,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: add,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/display/collapse`,
|
||||
value: { open },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* First-section migration: give an existing untitled (free-flowing) layout a
|
||||
* title, turning it into a section in place while preserving its panels.
|
||||
*/
|
||||
export function titleUntitledSectionOp(
|
||||
layoutIndex: number,
|
||||
title: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: add,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/display`,
|
||||
value: { title, collapse: { open: true } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove a section. Panel cleanup (orphaned refs) is handled by the caller. */
|
||||
export function removeSectionOp(
|
||||
layoutIndex: number,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: remove, path: `/spec/layouts/${layoutIndex}` };
|
||||
}
|
||||
|
||||
/** Remove a panel definition from `spec.panels`. */
|
||||
export function removePanelOp(
|
||||
panelId: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: remove, path: `/spec/panels/${panelId}` };
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type V2Dashboard = DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
export interface GridItemV2 {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
}
|
||||
|
||||
const PANEL_REF_PREFIX = '#/spec/panels/';
|
||||
|
||||
export function extractPanelIdFromRef(ref: string | undefined): string | null {
|
||||
if (!ref) {
|
||||
return null;
|
||||
}
|
||||
if (!ref.startsWith(PANEL_REF_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return ref.slice(PANEL_REF_PREFIX.length);
|
||||
}
|
||||
|
||||
export function flattenGridLayout(
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null,
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
|
||||
): GridItemV2[] {
|
||||
if (!layouts?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: GridItemV2[] = [];
|
||||
layouts.forEach((layoutEnvelope) => {
|
||||
if (layoutEnvelope?.kind !== 'Grid') {
|
||||
return;
|
||||
}
|
||||
const gridItems = layoutEnvelope.spec?.items ?? [];
|
||||
gridItems.forEach((item) => {
|
||||
const id = extractPanelIdFromRef(item.content?.$ref);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
items.push({
|
||||
id,
|
||||
x: item.x ?? 0,
|
||||
y: item.y ?? 0,
|
||||
width: item.width ?? 6,
|
||||
height: item.height ?? 6,
|
||||
panel: panels?.[id],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* A section corresponds to one entry in `spec.layouts`. If the Grid has a
|
||||
* `display.title`, it renders with a collapsible header; otherwise it is a
|
||||
* "default" untitled section (visually just the grid).
|
||||
*/
|
||||
export interface DashboardSectionV2 {
|
||||
/**
|
||||
* Stable identity used for React keys and dnd-kit sortable item ids. Derived
|
||||
* from the section's content (its first panel ref) so it survives reordering
|
||||
* — unlike the positional `layoutIndex`. See `getSectionStableId`.
|
||||
*/
|
||||
id: string;
|
||||
/** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */
|
||||
layoutIndex: number;
|
||||
title: string | undefined;
|
||||
open: boolean;
|
||||
items: GridItemV2[];
|
||||
repeatVariable: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a stable id for a section from its content. Reordering sections changes
|
||||
* their `layoutIndex` but not their content, so keying off the first panel ref
|
||||
* keeps React component instances (and any local state) bound to the right
|
||||
* section across a reorder. Empty sections fall back to a positional id — they
|
||||
* are rarely reordered, and a future backend `id` on the layout spec is the
|
||||
* proper long-term fix.
|
||||
*/
|
||||
export function getSectionStableId(
|
||||
items: GridItemV2[],
|
||||
layoutIndex: number,
|
||||
): string {
|
||||
if (items.length > 0) {
|
||||
return `sec-${items[0].id}`;
|
||||
}
|
||||
return `sec-empty-${layoutIndex}`;
|
||||
}
|
||||
|
||||
export function layoutsToSections(
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null,
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
|
||||
): DashboardSectionV2[] {
|
||||
if (!layouts?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return layouts
|
||||
.map((layoutEnvelope, idx) => {
|
||||
if (layoutEnvelope?.kind !== 'Grid') {
|
||||
return null;
|
||||
}
|
||||
const spec = layoutEnvelope.spec;
|
||||
const items: GridItemV2[] = (spec?.items ?? [])
|
||||
.map((item) => {
|
||||
const id = extractPanelIdFromRef(item.content?.$ref);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
x: item.x ?? 0,
|
||||
y: item.y ?? 0,
|
||||
width: item.width ?? 6,
|
||||
height: item.height ?? 6,
|
||||
panel: panels?.[id],
|
||||
};
|
||||
})
|
||||
.filter((it): it is GridItemV2 => it !== null);
|
||||
|
||||
const title = spec?.display?.title;
|
||||
// `open` defaults to true when no collapse field is set (the section
|
||||
// is expanded by default).
|
||||
const open = spec?.display?.collapse?.open !== false;
|
||||
|
||||
return {
|
||||
id: getSectionStableId(items, idx),
|
||||
layoutIndex: idx,
|
||||
title,
|
||||
open,
|
||||
items,
|
||||
repeatVariable: spec?.repeatVariable,
|
||||
};
|
||||
})
|
||||
.filter((s): s is DashboardSectionV2 => s !== null);
|
||||
}
|
||||
|
||||
export function getPanelKindLabel(
|
||||
panel: DashboardtypesPanelDTO | undefined,
|
||||
): string {
|
||||
const kind = panel?.spec?.plugin?.kind;
|
||||
if (!kind) {
|
||||
return 'unknown';
|
||||
}
|
||||
return kind.replace(/^signoz\//, '');
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainerV2 from 'container/DashboardContainerV2';
|
||||
|
||||
function DashboardPageV2(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
|
||||
id: dashboardId,
|
||||
});
|
||||
|
||||
const dashboard = data?.data;
|
||||
const name = dashboard?.spec?.display?.name;
|
||||
|
||||
useEffect(() => {
|
||||
if (name) {
|
||||
document.title = name;
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>{(error as Error)?.message}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainerV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageV2;
|
||||
@@ -1,3 +0,0 @@
|
||||
import DashboardPageV2 from './DashboardPageV2';
|
||||
|
||||
export default DashboardPageV2;
|
||||
5
tests/fixtures/http.py
vendored
5
tests/fixtures/http.py
vendored
@@ -17,6 +17,8 @@ from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
ZEUS_NETWORK_ALIAS = "signoz-zeus"
|
||||
|
||||
|
||||
@pytest.fixture(name="zeus", scope="package")
|
||||
def zeus(
|
||||
@@ -31,6 +33,7 @@ def zeus(
|
||||
def create() -> types.TestContainerDocker:
|
||||
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
|
||||
container.with_network(network)
|
||||
container.with_network_aliases(ZEUS_NETWORK_ALIAS)
|
||||
container.start()
|
||||
|
||||
return types.TestContainerDocker(
|
||||
@@ -42,7 +45,7 @@ def zeus(
|
||||
container.get_exposed_port(8080),
|
||||
)
|
||||
},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", ZEUS_NETWORK_ALIAS, 8080)},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker):
|
||||
|
||||
136
tests/fixtures/signoz.py
vendored
136
tests/fixtures/signoz.py
vendored
@@ -1,14 +1,19 @@
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
|
||||
import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
import requests
|
||||
from testcontainers.core.container import DockerContainer, Network
|
||||
from testcontainers.core.image import DockerImage
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures.logger import setup_logger
|
||||
@@ -16,6 +21,110 @@ from fixtures.logger import setup_logger
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SigNozImageBuild:
|
||||
process: subprocess.Popen
|
||||
command: list[str]
|
||||
cache_path: Path | None = None
|
||||
next_cache_path: Path | None = None
|
||||
reader: threading.Thread | None = None
|
||||
|
||||
|
||||
def _stream_build_output(pipe, log) -> None:
|
||||
try:
|
||||
for line in iter(pipe.readline, ""):
|
||||
log.info("buildx: %s", line.rstrip())
|
||||
finally:
|
||||
pipe.close()
|
||||
|
||||
|
||||
def start_signoz_image_build(pytestconfig: pytest.Config, dockerfile_path: str, arch: str, zeus_url: str) -> SigNozImageBuild:
|
||||
root = pytestconfig.rootpath.parent
|
||||
command = [
|
||||
"docker",
|
||||
"buildx",
|
||||
"build",
|
||||
"--load",
|
||||
"--progress",
|
||||
"plain",
|
||||
"--tag",
|
||||
"signoz:integration",
|
||||
"--file",
|
||||
dockerfile_path,
|
||||
"--build-arg",
|
||||
f"TARGETARCH={arch}",
|
||||
"--build-arg",
|
||||
f"ZEUSURL={zeus_url}",
|
||||
str(root),
|
||||
]
|
||||
|
||||
cache_path = None
|
||||
next_cache_path = None
|
||||
if os.environ.get("ACTIONS_RUNTIME_TOKEN"):
|
||||
# Running in GitHub Actions — use BuildKit's native GHA cache backend.
|
||||
# Avoids the local-write races and partial exports seen with type=local.
|
||||
scope = os.environ.get("SIGNOZ_BUILDX_GHA_SCOPE", "signoz-integration")
|
||||
command.extend(["--cache-from", f"type=gha,scope={scope}"])
|
||||
command.extend(["--cache-to", f"type=gha,scope={scope},mode=max"])
|
||||
elif build_cache_dir := os.environ.get("SIGNOZ_INTEGRATION_BUILD_CACHE_DIR"):
|
||||
# Local cache for developer machines / non-GHA CI.
|
||||
cache_path = Path(build_cache_dir)
|
||||
next_cache_path = Path(f"{build_cache_dir}-next")
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.rmtree(next_cache_path, ignore_errors=True)
|
||||
|
||||
if cache_path.exists():
|
||||
command.extend(["--cache-from", f"type=local,src={cache_path}"])
|
||||
command.extend(["--cache-to", f"type=local,dest={next_cache_path},mode=max"])
|
||||
|
||||
logger.info("Building SigNoz integration image with %s", " ".join(command))
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
reader = threading.Thread(target=_stream_build_output, args=(process.stdout, logger), daemon=True)
|
||||
reader.start()
|
||||
|
||||
return SigNozImageBuild(
|
||||
process=process,
|
||||
command=command,
|
||||
cache_path=cache_path,
|
||||
next_cache_path=next_cache_path,
|
||||
reader=reader,
|
||||
)
|
||||
|
||||
|
||||
def wait_for_signoz_image_build(build: SigNozImageBuild) -> None:
|
||||
returncode = build.process.wait()
|
||||
if build.reader is not None:
|
||||
build.reader.join(timeout=5)
|
||||
if returncode != 0:
|
||||
raise subprocess.CalledProcessError(returncode, build.command)
|
||||
|
||||
if build.cache_path and build.next_cache_path and build.next_cache_path.exists():
|
||||
shutil.rmtree(build.cache_path, ignore_errors=True)
|
||||
shutil.move(build.next_cache_path, build.cache_path)
|
||||
|
||||
|
||||
def stop_signoz_image_build(build: SigNozImageBuild) -> None:
|
||||
if build.process.poll() is not None:
|
||||
return
|
||||
|
||||
build.process.terminate()
|
||||
try:
|
||||
build.process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
build.process.kill()
|
||||
build.process.wait()
|
||||
|
||||
if build.reader is not None:
|
||||
build.reader.join(timeout=5)
|
||||
|
||||
|
||||
def create_signoz(
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
@@ -33,9 +142,6 @@ def create_signoz(
|
||||
"""
|
||||
|
||||
def create() -> types.SigNoz:
|
||||
# Run the migrations for clickhouse
|
||||
request.getfixturevalue("migrator")
|
||||
|
||||
# Get the no-web flag
|
||||
with_web = pytestconfig.getoption("--with-web")
|
||||
|
||||
@@ -48,19 +154,15 @@ def create_signoz(
|
||||
if with_web:
|
||||
dockerfile_path = "cmd/enterprise/Dockerfile.with-web.integration"
|
||||
|
||||
# Docker build context is the repo root — one up from pytest's
|
||||
# rootdir (tests/).
|
||||
self = DockerImage(
|
||||
path=str(pytestconfig.rootpath.parent),
|
||||
dockerfile_path=dockerfile_path,
|
||||
tag="signoz:integration",
|
||||
buildargs={
|
||||
"TARGETARCH": arch,
|
||||
"ZEUSURL": zeus.container_configs["8080"].base(),
|
||||
},
|
||||
)
|
||||
|
||||
self.build()
|
||||
# The SigNoz image build does not depend on ClickHouse migrations, so
|
||||
# build it while the migrator container runs.
|
||||
image_build = start_signoz_image_build(pytestconfig, dockerfile_path, arch, zeus.container_configs["8080"].base())
|
||||
try:
|
||||
request.getfixturevalue("migrator")
|
||||
wait_for_signoz_image_build(image_build)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
stop_signoz_image_build(image_build)
|
||||
raise
|
||||
|
||||
env = (
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user