mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 07:00:38 +01:00
Compare commits
6 Commits
fix/query-
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57a929a654 | ||
|
|
71bc81694e | ||
|
|
6db7858f74 | ||
|
|
80d18bc4d6 | ||
|
|
48be573afb | ||
|
|
7476a2cacb |
@@ -79,6 +79,7 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"html-to-image": "1.11.13",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
|
||||
8
frontend/pnpm-lock.yaml
generated
8
frontend/pnpm-lock.yaml
generated
@@ -164,6 +164,9 @@ importers:
|
||||
history:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
html-to-image:
|
||||
specifier: 1.11.13
|
||||
version: 1.11.13
|
||||
http-status-codes:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -5451,6 +5454,9 @@ packages:
|
||||
resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==}
|
||||
engines: {node: '>=20.10'}
|
||||
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
@@ -14495,6 +14501,8 @@ snapshots:
|
||||
|
||||
html-tags@5.1.0: {}
|
||||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
http-proxy-agent@5.0.0:
|
||||
|
||||
1
frontend/src/assets/Logos/cassandra.svg
Normal file
1
frontend/src/assets/Logos/cassandra.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#1287b1" viewBox="0 0 24 24"><title>Apache Cassandra</title><path d="M10.374 10.53a3 3 0 0 1-.428-.222l.555.143c0 .02-.01.036-.01.055l-.117.025zm-.283 1.506-.315.253.852-1.079-1.078.391c.002.017.009.033.009.05a.57.57 0 0 1-.184.42q.154.327.375.616a3.2 3.2 0 0 1 .34-.651zm.717-2.347-.652-.82a.43.43 0 0 1-.506.162c-.054.073-.083.162-.13.24l1.258.463zm-1.666.444c-.07.314-.087.637-.05.956a.57.57 0 0 1 .451.475l.946-.606c-.067-.022-.126-.06-.191-.088l-1.119-.08.64-.14a3.2 3.2 0 0 1-.668-.554zM20.1 11.648c-.164.202.833 1.022.833 1.022s-1.654-1.022-2.234-.72c-.278.144.574.811 1.175 1.242-.428-.274-.982-.571-1.175-.408-.328.277 1.565 2.549 1.565 2.549s-2.145-2.322-2.36-2.209c-.214.114.593 1.224.593 1.224s-1.06-1.16-1.35-.959c-.29.202 1.514 3.218 1.514 3.218s-1.956-3.091-2.763-2.574c1.268 2.782.795 3.18.795 3.18s-.162-2.839-1.742-2.764c-.795.038.379 2.12.379 2.12s-1.08-1.902-1.8-1.864c1.326 2.51.854 3.53.854 3.53s.219-2.143-1.58-3.336c.682.606-.427 3.336-.427 3.336s.976-4.023-.719-3.256c-.268.121-.019 2.007-.019 2.007s-.34-2.158-.851-2.045c-.298.066-1.893 2.99-1.893 2.99s1.306-3.16.908-3.027c-.29.096-.833 1.4-.833 1.4s.265-1.287 0-1.363c-.264-.075-1.74 1.363-1.74 1.363s1.097-1.287.908-1.552c-.287-.402-.623-.42-1.022-.265-.581.226-1.363 1.287-1.363 1.287s.78-1.074.643-1.476c-.219-.647-2.46 1.249-2.46 1.249s1.325-1.25 1.022-1.514c-.303-.265-1.947-.183-2.46-.185-1.515-.004-2.039-.36-2.498-.724 1.987.997 3.803-.151 6.094.494l.21.06c-1.3-.558-2.144-1.378-2.226-2.354-.036-.416.074-.827.297-1.222.619-.4 1.29-.773 2.06-1.095a4 4 0 0 0-.064.698c0 2.44 2.203 4.417 4.92 4.417s4.92-1.977 4.92-4.417c0-.45-.083-.881-.223-1.29 1.431.404 2.45.968 3.132 1.335.022.092.045.184.053.279.024.274-.018.547-.11.814.095-.147.198-.288.28-.445.367-.997 1.855.227 1.855.227s-1.085-.454-1.06-.24c.026.215 1.628.96 1.628.96s-1.45-.455-1.362-.114c.088.34 1.817 1.703 1.817 1.703s-1.956-1.489-2.12-1.287zm-7.268 2.65.042-.008-.06.01zM9.256 9.753c.12.13.26.234.396.343l.927-.029-1.064-.788c-.093.154-.195.303-.26.474Zm10.62 3.44c.3.215.54.373.54.373s-.24-.181-.54-.374zM7.507 8.617c-.14.229-.214.492-.215.76a3.99 3.99 0 0 0 2.358 3.64q0-.008.003-.014a3.2 3.2 0 0 1-.58-.788c-.648.099-.926-.794-.336-1.08a3.2 3.2 0 0 1 .138-1.388 3.16 3.16 0 0 1-.52-1.36q-.444.105-.848.23m1.488.82q.163-.36.402-.661a.435.435 0 0 1 .568-.557c.077-.059.166-.099.248-.15a16 16 0 0 0-1.727.284c.114.388.272.76.509 1.084m2.285 3.928c1.4 0 2.633-.723 3.344-1.816a3.4 3.4 0 0 0-1.265-.539l-.297-.023.916.9-1.197-.467.704 1.078-1.074-.832-.012.006.347 1.278-.596-1.134-.098 1.33-.401-1.326-.472 1.261.114-1.359-.015-.008-.814 1.154.286-1.067c-.34.322-.605.713-.781 1.146q.143.153.303.29.484.126 1.008.128m10.145-4.434c.971-.567 1.716-1.955 1.716-1.955s-1.893 1.955-3.205 1.665c1.186-.934 1.766-2.549 1.766-2.549s-1.506 2.325-2.448 2.423c1.086-.959 1.54-2.322 1.54-2.322s-1.237 1.817-2.196 1.944c1.287-1.161 1.338-1.893 1.338-1.893s-1.781 2.302-2.499 1.943c.858-.934 1.439-2.12 1.439-2.12s-1.489 2.019-1.893 1.69c-.277-.05.454-.958.454-.958s-.908.807-1.16.606c.454-.278 1.236-1.64 1.236-1.64S16 7.505 15.621 7.304l.731-1.483s-.73 1.483-1.715 1.23c.454-.58.63-1.112.63-1.112s-.756 1.213-1.69.885c-.22-.077.273-.635.273-.635s-.626.61-1.055.534c-.43-.076.025-.858.025-.858s-.757 1.186-.908 1.136.075-.833.075-.833-.555.908-.858.858 0-.934 0-.934-.328.984-.58.909c-.252-.076-.303-.656-.303-.656s-.068.788-.429.858c-2.725.53-5.728 1.69-9.489 5.45C3.887 10.738 5.3 7.91 11.962 7.659c5.044-.191 7.399 2.137 8.177 2.17C22.51 9.93 24 7.633 24 7.633s-1.489 1.716-2.574 1.3zm-7.74.872-.608.464v.001l.054.003a3.4 3.4 0 0 0 .554-.468m1.583-.426c0-.536-.237-.929-.594-1.217a3.2 3.2 0 0 1-.165.825.393.393 0 0 1-.328.681c-.154.233-.34.445-.549.63l.661.034-.995.237c-.025.018-.045.041-.07.058a3.2 3.2 0 0 1 1.536.691c.32-.574.504-1.235.504-1.94zM10.99 7.996a3.5 3.5 0 0 0-.785.46.43.43 0 0 1-.013.357l.885.643.023-.016-.36-1.262.627 1.12c.018-.006.04-.006.058-.011l-.02-1.251.398 1.163.477-1.15.016 1.268.012.007.713-1.005-.363 1.218.009.01 1.04-.69-.759 1.05.002.005.95-.34.041-.045a.395.395 0 0 1 .394-.632 3.4 3.4 0 0 0 .27-.784 14 14 0 0 0-2.798-.168c-.286.011-.55.033-.817.053"/></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
1
frontend/src/assets/Logos/fluxcd.svg
Normal file
1
frontend/src/assets/Logos/fluxcd.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 -2.25 262.5 364"><defs><style>.cls-1{fill:#326ce5}.cls-3{fill:none}</style></defs><path d="M59.724 97.778a10.183 10.183 0 0 1 0-17.073l114.07-74.16a10.18 10.18 0 0 1 11.1 0l114.07 74.16a10.183 10.183 0 0 1 0 17.073l-114.07 74.16a10.18 10.18 0 0 1-11.1 0z" class="cls-1"/><path fill="#c1d2f7" d="M197.356 110.866h7.912a6.003 6.003 0 0 0 5.2-9.005l-25.924-44.902a6.004 6.004 0 0 0-10.399 0l-25.924 44.902a6.003 6.003 0 0 0 5.2 9.005h7.912a6.003 6.003 0 0 1 6.004 6.003v51.257l5.31 3.452a12.29 12.29 0 0 0 13.395 0l5.31-3.452v-51.257a6.003 6.003 0 0 1 6.004-6.003"/><path d="M173.793 353.271a10.1 10.1 0 0 0 3.457 1.402 119 119 0 0 0-6.623-3.46zm-6.456-161.195-11.316 7.356a111 111 0 0 0 11.316 6.6zm24.015 23.716c9.739 3.115 19.813 5.648 30.11 8.186 10.92 2.692 21.973 5.427 32.818 9.009l-35.416-23.025a216 216 0 0 1-27.512-9.41zm0 62.191v.586c0 2.57-2.688 4.652-6.003 4.652H173.34c-3.315 0-6.003-2.083-6.003-4.652v-6.605c-24.906-6.402-49.873-14.485-70.86-33.82l-14.529 9.445c22.468 22.05 50.28 28.933 79.715 36.189 27.178 6.698 55.18 13.651 78.685 33.442l14.68-9.544c-18.532-16.504-40.41-23.687-63.676-29.693M93.315 300.95l38.908 25.295c20.064 5.113 40.2 11.248 58.31 23.36l14.901-9.687c-19.752-13.672-42.55-19.313-66.419-25.197-15.287-3.767-30.835-7.616-45.7-13.771m74.022-82.254a124.3 124.3 0 0 1-21.534-12.62l-14.805 9.625a124.5 124.5 0 0 0 36.339 18.708zm51.41 16.294c-9.106-2.245-18.304-4.524-27.395-7.299v13.682q5.051 1.286 10.177 2.538c29.934 7.379 60.888 15.01 85.708 39.829.61.61 1.162 1.24 1.752 1.857l9.974-6.484a10.1 10.1 0 0 0 3.475-3.8 101 101 0 0 0-3.289-3.486c-22.585-22.585-50.669-29.508-80.402-36.837m-19.933 19.933q-3.722-.918-7.462-1.855v13.21c25.817 6.561 51.803 14.688 73.455 35.04l14.521-9.44-.11-.118c-22.586-22.585-50.67-29.509-80.404-36.837m-31.477-8.587c-16.28-5.278-32.118-12.397-46.344-24.131l-14.682 9.545c17.87 15.557 38.784 22.673 61.026 28.5z" class="cls-3"/><path d="M73.24 254.96c-.348-.348-.658-.71-1-1.06l-12.514 8.136a10 10 0 0 0-1.644 1.397c1.053 1.157 2.115 2.31 3.245 3.44 22.585 22.585 50.67 29.509 80.403 36.837 25.343 6.247 51.401 12.717 73.865 29.603l14.806-9.626c-20.865-16.42-45.53-22.509-71.453-28.898-29.934-7.379-60.888-15.01-85.708-39.83" class="cls-3"/><path d="m218.864 209.962-27.512-17.886v8.476a216 216 0 0 0 27.512 9.41m-27.512 5.83v11.9c9.091 2.774 18.289 5.053 27.395 7.298 29.733 7.329 57.817 14.252 80.402 36.837a101 101 0 0 1 3.29 3.486 10.193 10.193 0 0 0-3.476-13.277l-44.683-29.05c-10.845-3.58-21.898-6.316-32.818-9.008-10.297-2.538-20.371-5.071-30.11-8.186m-24.015-9.76a111 111 0 0 1-11.316-6.6l-10.218 6.644a124.3 124.3 0 0 0 21.534 12.62zm34.192 37.88q-5.125-1.264-10.177-2.539v11.695q3.737.938 7.462 1.855c29.733 7.328 57.818 14.252 80.403 36.837.039.039.073.079.111.118l9.66-6.28c-.589-.618-1.142-1.248-1.751-1.858-24.82-24.82-55.774-32.45-85.708-39.829m-34.192-9.503a124.5 124.5 0 0 1-36.34-18.708l-10.004 6.504c14.226 11.734 30.064 18.853 46.344 24.13zm0 25.842c-22.242-5.828-43.156-12.944-61.026-28.5l-9.834 6.392c20.987 19.336 45.954 27.42 70.86 33.821zm24.015 17.732c23.266 6.006 45.144 13.19 63.675 29.693l9.78-6.358c-21.652-20.352-47.638-28.479-73.455-35.04zM81.948 247.59l-9.707 6.311c.341.35.652.712 1 1.06 24.82 24.82 55.773 32.45 85.707 39.829 25.922 6.39 50.588 12.478 71.452 28.898l9.948-6.467c-23.505-19.791-51.507-26.744-78.685-33.442-29.436-7.256-57.247-14.138-79.715-36.189m-20.621 19.284c-1.13-1.13-2.192-2.283-3.245-3.44a10.154 10.154 0 0 0 1.644 15.68l33.589 21.837c14.865 6.155 30.413 10.004 45.7 13.771 23.869 5.884 46.667 11.525 66.419 25.197l10.16-6.605c-22.463-16.886-48.521-23.356-73.864-29.603-29.733-7.328-57.818-14.252-80.403-36.837m109.3 84.34a119 119 0 0 1 6.623 3.46 10.16 10.16 0 0 0 7.645-1.402l5.639-3.666c-18.11-12.112-38.247-18.247-58.311-23.36z" class="cls-1"/></svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
1
frontend/src/assets/Logos/planetscale.svg
Normal file
1
frontend/src/assets/Logos/planetscale.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><title>PlanetScale</title><path d="M0 12C0 5.373 5.373 0 12 0c4.873 0 9.067 2.904 10.947 7.077l-15.87 15.87a12 12 0 0 1-1.935-1.099L14.99 12H12l-8.485 8.485A11.96 11.96 0 0 1 0 12m12.004 12L24 12.004C23.998 18.628 18.628 23.998 12.004 24"/></svg>
|
||||
|
After Width: | Height: | Size: 326 B |
@@ -20,6 +20,7 @@ import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg';
|
||||
import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg';
|
||||
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
|
||||
import basetenUrl from '@/assets/Logos/baseten.svg';
|
||||
import cassandraUrl from '@/assets/Logos/cassandra.svg';
|
||||
import celeryUrl from '@/assets/Logos/celery.svg';
|
||||
import certManagerUrl from '@/assets/Logos/cert-manager.svg';
|
||||
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
|
||||
@@ -51,6 +52,7 @@ import externalApiMonitoringUrl from '@/assets/Logos/external-api-monitoring.svg
|
||||
import fluentbitUrl from '@/assets/Logos/fluentbit.svg';
|
||||
import fluentdUrl from '@/assets/Logos/fluentd.svg';
|
||||
import flutterMonitoringUrl from '@/assets/Logos/flutter-monitoring.svg';
|
||||
import fluxcdUrl from '@/assets/Logos/fluxcd.svg';
|
||||
import flyIoUrl from '@/assets/Logos/fly-io.svg';
|
||||
import fromLogFileUrl from '@/assets/Logos/from-log-file.svg';
|
||||
import gcpAppEngineUrl from '@/assets/Logos/gcp-app-engine.svg';
|
||||
@@ -94,6 +96,7 @@ import langtraceUrl from '@/assets/Logos/langtrace.svg';
|
||||
import litellmUrl from '@/assets/Logos/litellm.svg';
|
||||
import livekitUrl from '@/assets/Logos/livekit.svg';
|
||||
import llamaindexUrl from '@/assets/Logos/llamaindex.svg';
|
||||
import llmMonitoringUrl from '@/assets/Logos/llm-monitoring.svg';
|
||||
import logrusUrl from '@/assets/Logos/logrus.svg';
|
||||
import logsUrl from '@/assets/Logos/logs.svg';
|
||||
import logstashUrl from '@/assets/Logos/logstash.svg';
|
||||
@@ -120,6 +123,7 @@ import opentelemetryUrl from '@/assets/Logos/opentelemetry.svg';
|
||||
import phpUrl from '@/assets/Logos/php.svg';
|
||||
import pinoUrl from '@/assets/Logos/pino.svg';
|
||||
import pipecatUrl from '@/assets/Logos/pipecat.svg';
|
||||
import planetscaleUrl from '@/assets/Logos/planetscale.svg';
|
||||
import postgresqlUrl from '@/assets/Logos/postgresql.svg';
|
||||
import prometheusUrl from '@/assets/Logos/prometheus.svg';
|
||||
import pydanticAiUrl from '@/assets/Logos/pydantic-ai.svg';
|
||||
@@ -1526,6 +1530,28 @@ const onboardingConfigWithLinks = [
|
||||
id: 'nginx-tracing',
|
||||
link: '/docs/instrumentation/opentelemetry-nginx/',
|
||||
},
|
||||
{
|
||||
dataSource: 'nginx-ingress-controller',
|
||||
label: 'NGINX Ingress Controller',
|
||||
imgUrl: nginxUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'ingress',
|
||||
'ingress controller',
|
||||
'kubernetes ingress',
|
||||
'monitoring',
|
||||
'nginx ingress',
|
||||
'nginx ingress controller',
|
||||
'nginx ingress metrics',
|
||||
'nginx ingress monitoring',
|
||||
'nginx ingress observability',
|
||||
'observability',
|
||||
'opentelemetry nginx ingress',
|
||||
],
|
||||
id: 'nginx-ingress-controller',
|
||||
link: '/docs/metrics-management/nginx-ingress-controller/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opentelemetry-cloudflare',
|
||||
label: 'Cloudflare Tracing',
|
||||
@@ -1586,6 +1612,119 @@ const onboardingConfigWithLinks = [
|
||||
id: 'opentelemetry-cloudflare-logs',
|
||||
link: '/docs/logs-management/send-logs/cloudflare-logs/',
|
||||
},
|
||||
{
|
||||
dataSource: 'cloudflare-workers',
|
||||
label: 'Cloudflare Workers',
|
||||
imgUrl: cloudflareUrl,
|
||||
tags: ['apm/traces'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'cloudflare',
|
||||
'cloudflare workers',
|
||||
'cloudflare workers monitoring',
|
||||
'cloudflare workers observability',
|
||||
'cloudflare workers otlp',
|
||||
'edge computing monitoring',
|
||||
'monitor cloudflare workers',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry cloudflare workers',
|
||||
'otlp',
|
||||
'serverless monitoring',
|
||||
],
|
||||
id: 'cloudflare-workers',
|
||||
link: '/docs/integrations/outposts/cloudflare-workers/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opentelemetry-cassandra',
|
||||
label: 'Cassandra',
|
||||
imgUrl: cassandraUrl,
|
||||
tags: ['database'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'apache cassandra',
|
||||
'cassandra',
|
||||
'cassandra database',
|
||||
'cassandra logs',
|
||||
'cassandra metrics',
|
||||
'cassandra monitoring',
|
||||
'cassandra observability',
|
||||
'database',
|
||||
'monitoring',
|
||||
'nosql',
|
||||
'observability',
|
||||
'opentelemetry cassandra',
|
||||
],
|
||||
id: 'opentelemetry-cassandra',
|
||||
link: '/docs/integrations/opentelemetry-cassandra/',
|
||||
},
|
||||
{
|
||||
dataSource: 'fluxcd',
|
||||
label: 'FluxCD',
|
||||
imgUrl: fluxcdUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'continuous delivery',
|
||||
'flux',
|
||||
'fluxcd',
|
||||
'fluxcd dashboard',
|
||||
'fluxcd metrics',
|
||||
'fluxcd monitoring',
|
||||
'fluxcd observability',
|
||||
'gitops',
|
||||
'kubernetes',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry fluxcd',
|
||||
],
|
||||
id: 'fluxcd',
|
||||
link: '/docs/metrics-management/fluxcd-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'planetscale',
|
||||
label: 'PlanetScale',
|
||||
imgUrl: planetscaleUrl,
|
||||
tags: ['database'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'monitoring',
|
||||
'mysql',
|
||||
'observability',
|
||||
'opentelemetry planetscale',
|
||||
'planetscale',
|
||||
'planetscale database',
|
||||
'planetscale metrics',
|
||||
'planetscale monitoring',
|
||||
'planetscale observability',
|
||||
'serverless database',
|
||||
],
|
||||
id: 'planetscale',
|
||||
link: '/docs/metrics-management/opentelemetry-planetscale/',
|
||||
},
|
||||
{
|
||||
dataSource: 'hermes-agent',
|
||||
label: 'Hermes Agent',
|
||||
imgUrl: llmMonitoringUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'ai agent monitoring',
|
||||
'hermes',
|
||||
'hermes agent',
|
||||
'hermes agent monitoring',
|
||||
'hermes agent observability',
|
||||
'hermes monitoring',
|
||||
'llm monitoring',
|
||||
'monitoring',
|
||||
'nous research',
|
||||
'observability',
|
||||
'opentelemetry hermes',
|
||||
],
|
||||
id: 'hermes-agent',
|
||||
link: '/docs/hermes-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'convex-logs',
|
||||
label: 'Convex Logs',
|
||||
|
||||
@@ -96,6 +96,7 @@ function PreviewPane({
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: false,
|
||||
|
||||
@@ -34,7 +34,7 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: false,
|
||||
search: true,
|
||||
drilldown: false,
|
||||
|
||||
@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -20,7 +20,7 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -17,7 +17,10 @@ function panelWith(
|
||||
): PanelOfKind<'signoz/TablePanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TablePanel', spec } },
|
||||
spec: {
|
||||
display: { name: 'Table panel' },
|
||||
plugin: { kind: 'signoz/TablePanel', spec },
|
||||
},
|
||||
} as unknown as PanelOfKind<'signoz/TablePanel'>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelTable,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../../types/rendererProps';
|
||||
import { prepareScalarTables } from '../../../../queryV5/prepareScalarTables';
|
||||
import { buildTableCsvRows, getTableCsvRows } from '../tableCsv';
|
||||
|
||||
// Stub number/unit formatting so assertions cover only the row-building.
|
||||
jest.mock('../../../utils/formatPanelValue', () => ({
|
||||
formatPanelValue: (value: number, unit?: string): string =>
|
||||
`${value}${unit ?? ''}`,
|
||||
}));
|
||||
|
||||
jest.mock('../../../../queryV5/prepareScalarTables', () => ({
|
||||
prepareScalarTables: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../queryV5/v5ResponseData', () => ({
|
||||
getScalarResults: jest.fn(() => []),
|
||||
}));
|
||||
|
||||
const mockPrepareScalarTables = prepareScalarTables as jest.MockedFunction<
|
||||
typeof prepareScalarTables
|
||||
>;
|
||||
|
||||
const table: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [
|
||||
{ name: 'service', queryName: 'A', isValueColumn: false, id: 'service' },
|
||||
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A' },
|
||||
],
|
||||
rows: [
|
||||
{ data: { service: 'frontend', A: 1234 } },
|
||||
{ data: { service: 'cart', A: 56 } },
|
||||
],
|
||||
};
|
||||
|
||||
describe('buildTableCsvRows', () => {
|
||||
it('keys rows by column name in display order and formats value columns', () => {
|
||||
const rows = buildTableCsvRows({
|
||||
table,
|
||||
columnUnits: { A: 'ms' },
|
||||
decimalPrecision: undefined,
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([
|
||||
{ service: 'frontend', p99: '1234ms' },
|
||||
{ service: 'cart', p99: '56ms' },
|
||||
]);
|
||||
expect(Object.keys(rows[0])).toStrictEqual(['service', 'p99']);
|
||||
});
|
||||
|
||||
it('renders group columns and non-numeric value cells as raw text', () => {
|
||||
const rows = buildTableCsvRows({
|
||||
table: {
|
||||
...table,
|
||||
rows: [{ data: { service: 'api', A: 'n/a' } }],
|
||||
},
|
||||
columnUnits: {},
|
||||
decimalPrecision: undefined,
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([{ service: 'api', p99: 'n/a' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTableCsvRows', () => {
|
||||
const panel = {
|
||||
spec: { plugin: { spec: { formatting: { columnUnits: { A: 'ms' } } } } },
|
||||
} as unknown as PanelOfKind<'signoz/TablePanel'>;
|
||||
const data = {} as PanelQueryData;
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('prepares the scalar table and flattens the first non-empty one to rows', () => {
|
||||
mockPrepareScalarTables.mockReturnValue([table]);
|
||||
|
||||
expect(getTableCsvRows(panel, data)).toStrictEqual([
|
||||
{ service: 'frontend', p99: '1234ms' },
|
||||
{ service: 'cart', p99: '56ms' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no rows when the response has no table', () => {
|
||||
mockPrepareScalarTables.mockReturnValue([]);
|
||||
|
||||
expect(getTableCsvRows(panel, data)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: true,
|
||||
download: { csv: true, png: true, svg: true },
|
||||
createAlert: false,
|
||||
// V1 parity: only tables (and lists) expose the header search box.
|
||||
search: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { TableProps } from 'antd';
|
||||
import type { DashboardtypesTableThresholdDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type {
|
||||
PanelTable,
|
||||
PanelTableColumn,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { coerceToString } from 'utils/stringUtils';
|
||||
|
||||
import type { PanelThreshold } from '../../types/threshold';
|
||||
@@ -28,6 +31,26 @@ export function mapTableThresholds(
|
||||
return byColumn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text value of a table cell (value columns formatted through unit +
|
||||
* precision, group columns raw). Shared by the renderer and the CSV export.
|
||||
*/
|
||||
export function formatTableCellText(
|
||||
col: PanelTableColumn,
|
||||
raw: unknown,
|
||||
unit: string | undefined,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): string {
|
||||
if (!col.isValueColumn) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
return formatPanelValue(num, unit, decimalPrecision);
|
||||
}
|
||||
|
||||
// Sort comparator: numeric when both cells parse as numbers (value columns and
|
||||
// numeric group keys), otherwise a locale string compare. Nullish sorts last.
|
||||
function compareCells(a: unknown, b: unknown): number {
|
||||
@@ -87,15 +110,13 @@ export function buildTableColumns({
|
||||
sorter: (a: TableRowData, b: TableRowData): number =>
|
||||
compareCells(a[key], b[key]),
|
||||
render: (raw: unknown): React.ReactNode => {
|
||||
if (!col.isValueColumn) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
const text = formatTableCellText(col, raw, unit, decimalPrecision);
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
const text = formatPanelValue(num, unit, decimalPrecision);
|
||||
if (colThresholds.length === 0) {
|
||||
if (
|
||||
!col.isValueColumn ||
|
||||
colThresholds.length === 0 ||
|
||||
!Number.isFinite(num)
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelTable,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
import { getColumnUnit } from '../../utils/getColumnUnit';
|
||||
import type { PanelOfKind } from '../../types/rendererProps';
|
||||
|
||||
import { formatTableCellText } from './tableColumns';
|
||||
|
||||
interface BuildTableCsvRowsArgs {
|
||||
table: PanelTable;
|
||||
/** Per-column display unit (`formatting.columnUnits`), keyed by column key. */
|
||||
columnUnits: Record<string, string>;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a prepared table into CSV rows keyed by column name, reusing the
|
||||
* on-screen cell formatting in display column order. Exports the full result
|
||||
* set, not the paginated view (V1 parity).
|
||||
*/
|
||||
export function buildTableCsvRows({
|
||||
table,
|
||||
columnUnits,
|
||||
decimalPrecision,
|
||||
}: BuildTableCsvRowsArgs): Record<string, string>[] {
|
||||
return table.rows.map((row) => {
|
||||
const csvRow: Record<string, string> = {};
|
||||
table.columns.forEach((col) => {
|
||||
const key = col.id || col.name;
|
||||
const unit = getColumnUnit(key, columnUnits);
|
||||
csvRow[col.name] = formatTableCellText(
|
||||
col,
|
||||
row.data[key],
|
||||
unit,
|
||||
decimalPrecision,
|
||||
);
|
||||
});
|
||||
return csvRow;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the scalar table from the query response and flattens it to CSV rows,
|
||||
* reusing the on-screen formatting. Returns [] when the response has no table.
|
||||
*/
|
||||
export function getTableCsvRows(
|
||||
panel: PanelOfKind<'signoz/TablePanel'>,
|
||||
data: PanelQueryData,
|
||||
): Record<string, string>[] {
|
||||
const spec = panel.spec.plugin.spec;
|
||||
const table = prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}).find((candidate) => candidate.columns.length > 0);
|
||||
if (!table) {
|
||||
return [];
|
||||
}
|
||||
return buildTableCsvRows({
|
||||
table,
|
||||
columnUnits: spec.formatting?.columnUnits ?? {},
|
||||
decimalPrecision: resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -8,27 +8,29 @@ import type { PanelKind } from './panelKind';
|
||||
import type { QueryBuilderFieldRule } from './panelCapabilities';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
|
||||
/** Export formats offered under the single "Download" action. */
|
||||
export enum DownloadFormat {
|
||||
CSV = 'csv',
|
||||
PNG = 'png',
|
||||
SVG = 'svg',
|
||||
}
|
||||
|
||||
/**
|
||||
* Which panel actions a kind supports. Required field, so registering a new
|
||||
* kind forces an explicit decision for every action. Chrome actions (move to
|
||||
* section, clone, delete) are dashboard-layout concerns available to every
|
||||
* panel and are intentionally not declarable here.
|
||||
* Which actions a kind supports, declared per-kind in `kinds/<Kind>/definition.ts`.
|
||||
* Chrome actions (move, clone, delete) are layout concerns and aren't declared here.
|
||||
*/
|
||||
export interface PanelActionCapabilities {
|
||||
/** Kind has a full-screen view — gates the "View" action. */
|
||||
/** Gates the "View" action. */
|
||||
view: boolean;
|
||||
/** Kind is editable in the V2 panel editor — gates the "Edit panel" action. */
|
||||
/** Gates the "Edit panel" action. */
|
||||
edit: boolean;
|
||||
/** Kind can be cloned — gates the "Clone" action. */
|
||||
/** Gates the "Clone" action. */
|
||||
clone: boolean;
|
||||
/** Gates "Download as CSV". V1 parity: only table panels carry exportable data. */
|
||||
download: boolean;
|
||||
/** Kind's query can seed a new alert — gates "Create Alerts". */
|
||||
/** Which formats this kind can be downloaded as (CSV is table-only). */
|
||||
download: Record<DownloadFormat, boolean>;
|
||||
/** Gates "Create Alerts". */
|
||||
createAlert: boolean;
|
||||
/**
|
||||
* Header search box that filters rendered rows client-side (V1 parity: only
|
||||
* tabular kinds). Not a menu action — the renderer must consume `searchTerm`.
|
||||
*/
|
||||
/** Client-side header search box, consumed by the renderer via `searchTerm`. */
|
||||
search: boolean;
|
||||
/**
|
||||
* Kind supports click-to-drilldown (context menu + View/Breakout). V1 parity: charts + scalar
|
||||
@@ -51,13 +53,10 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
// Total over PanelKind: every kind must be registered (missing → compile error),
|
||||
// so getPanelDefinition never returns undefined.
|
||||
// Every kind must be registered, so getPanelDefinition never returns undefined.
|
||||
export type PanelRegistry = { [K in PanelKind]: PanelDefinition<K> };
|
||||
|
||||
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
|
||||
// getPanelDefinition resolves to this, concentrating the unavoidable cast in one
|
||||
// place rather than leaking it to every call site (the kind isn't known statically).
|
||||
export interface RenderablePanelDefinition extends Omit<
|
||||
PanelDefinition,
|
||||
'Renderer'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { unparse } from 'papaparse';
|
||||
|
||||
import { toSafeFileName } from './toSafeFileName';
|
||||
|
||||
/** Serializes rows (keyed by column header) to CSV and downloads the file. */
|
||||
export function downloadCsv(
|
||||
rows: Record<string, string>[],
|
||||
fileBaseName: string,
|
||||
): void {
|
||||
const csv = unparse(rows);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${toSafeFileName(fileBaseName)}.csv`;
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Makes a download filename safe across OSes; falls back when empty. */
|
||||
export function toSafeFileName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return 'panel';
|
||||
}
|
||||
return trimmed.replace(/[\\/:*?"<>|]+/g, '-');
|
||||
}
|
||||
@@ -76,10 +76,14 @@ function Panel({
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
// Stable locator so the "Download as PNG" action can find this node to
|
||||
// capture, without threading a ref through the header/actions chain.
|
||||
data-panel-root={panelId}
|
||||
>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EllipsisVertical } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
@@ -10,8 +11,10 @@ import styles from './PanelActionsMenu.module.scss';
|
||||
|
||||
interface PanelActionsMenuProps {
|
||||
panelId: string;
|
||||
/** The panel itself — its query seeds "Create Alerts". */
|
||||
/** The panel itself — seeds "Create Alerts" and the download filename. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** The panel's query response — the source for "Download as CSV". */
|
||||
data: PanelQueryData;
|
||||
/** Layout context for move/delete — absent outside editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
@@ -24,11 +27,13 @@ interface PanelActionsMenuProps {
|
||||
function PanelActionsMenu({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelActions,
|
||||
}: PanelActionsMenuProps): JSX.Element | null {
|
||||
const { items, deleteConfirm } = usePanelActionItems({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelActions,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
@@ -47,6 +48,13 @@ jest.mock('../../hooks/useCreateAlertFromPanel', () => ({
|
||||
useCreateAlertFromPanel: (): jest.Mock => mockCreateAlert,
|
||||
}));
|
||||
|
||||
const mockDownloadImage = jest.fn();
|
||||
jest.mock('../../hooks/useDownloadPanelImage', () => ({
|
||||
useDownloadPanelImage: (): { downloadPanelImage: jest.Mock } => ({
|
||||
downloadPanelImage: mockDownloadImage,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Role is the only thing read off the app context; useComponentPermission runs
|
||||
// for real so the tests exercise the actual role → permission mapping.
|
||||
let mockRole: ROLES = 'ADMIN';
|
||||
@@ -84,9 +92,16 @@ const mockPanel = {
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const mockData = {
|
||||
response: undefined,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
} as PanelQueryData;
|
||||
|
||||
const baseArgs = {
|
||||
panelId: 'panel-1',
|
||||
panel: mockPanel,
|
||||
data: mockData,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
};
|
||||
|
||||
@@ -110,14 +125,15 @@ describe('usePanelActionItems', () => {
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
// download stays hidden: no current kind declares the capability
|
||||
// (V1 parity — CSV export was table-only).
|
||||
// The single "Download" entry is a submenu (PNG/SVG, plus CSV on tables);
|
||||
// it's present for every renderable kind.
|
||||
});
|
||||
|
||||
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
|
||||
@@ -126,6 +142,7 @@ describe('usePanelActionItems', () => {
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
@@ -134,26 +151,28 @@ describe('usePanelActionItems', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('VIEWER keeps only the role-ungated actions (view, create-alert)', () => {
|
||||
it('VIEWER keeps only the role-ungated actions (view, download, create-alert)', () => {
|
||||
mockRole = 'VIEWER';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('read-only dashboard keeps View and Create Alerts (V1 parity: both survive a lock)', () => {
|
||||
it('read-only dashboard keeps the non-mutating actions (View, Download, Create Alerts)', () => {
|
||||
useDashboardStore.setState({ isEditable: false });
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
);
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so it
|
||||
// isn't gated on edit access — matching V1's locked-dashboard menu.
|
||||
// View, the Download submenu (PNG/SVG) and Create Alerts are all
|
||||
// non-mutating, so they survive on a read-only dashboard (V1 parity).
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
@@ -277,6 +296,25 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('the Download submenu captures the panel by id, name and chosen format', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const download = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'download',
|
||||
) as { children: { key: string; onClick: () => void }[] };
|
||||
|
||||
// TimeSeries declares no CSV capability, so the submenu is just PNG + SVG.
|
||||
expect(download.children.map((c) => c.key)).toStrictEqual([
|
||||
'download-png',
|
||||
'download-svg',
|
||||
]);
|
||||
|
||||
download.children.find((c) => c.key === 'download-png')?.onClick();
|
||||
expect(mockDownloadImage).toHaveBeenCalledWith('panel-1', 'CPU', 'png');
|
||||
|
||||
download.children.find((c) => c.key === 'download-svg')?.onClick();
|
||||
expect(mockDownloadImage).toHaveBeenCalledWith('panel-1', 'CPU', 'svg');
|
||||
});
|
||||
|
||||
it('view opens the View modal for the panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const view = result.current.items.find(
|
||||
@@ -294,13 +332,4 @@ describe('usePanelActionItems', () => {
|
||||
(createAlert as { onClick: () => void }).onClick();
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
|
||||
it('create-alert seeds an alert from this panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const createAlert = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'create-alert',
|
||||
);
|
||||
(createAlert as { onClick: () => void }).onClick();
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,8 @@ export const PANEL_ACTION_META: Record<PanelActionId, PanelActionMeta> = {
|
||||
view: { capability: 'view' },
|
||||
edit: { permission: 'edit_widget', capability: 'edit' },
|
||||
clone: { permission: 'edit_widget' },
|
||||
// Single entry for every export format (CSV/PNG/SVG); like view it isn't
|
||||
// role-gated (V1 parity). The per-format options live in usePanelActionItems.
|
||||
download: { capability: 'download' },
|
||||
createAlert: { capability: 'createAlert' },
|
||||
// Moving a panel between sections mutates the dashboard layout.
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
CloudDownload,
|
||||
Copy,
|
||||
FolderInput,
|
||||
FolderOutput,
|
||||
Fullscreen,
|
||||
PenLine,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import { Bell, Copy, Fullscreen, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
@@ -18,6 +9,7 @@ import {
|
||||
} from 'hooks/useConfirmableAction';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
@@ -26,87 +18,22 @@ import type { PanelActionsConfig } from '../Panel';
|
||||
import { useClonePanel } from '../hooks/useClonePanel';
|
||||
import { useCreateAlertFromPanel } from '../hooks/useCreateAlertFromPanel';
|
||||
import { useDeletePanel } from '../hooks/useDeletePanel';
|
||||
import {
|
||||
type MovePanelArgs,
|
||||
useMovePanelToSection,
|
||||
} from '../hooks/useMovePanelToSection';
|
||||
import { useDownloadPanelMenuItem } from '../hooks/useDownloadPanelMenuItem';
|
||||
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
import { buildMoveItems } from '../utils/buildMoveItems';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
const EMPTY_SECTIONS: DashboardSection[] = [];
|
||||
|
||||
/** Placeholder for V1-parity actions whose V2 implementations land later. */
|
||||
function notImplementedYet(feature: string): void {
|
||||
// eslint-disable-next-line no-alert -- temporary placeholder, see above
|
||||
alert(`${feature} option clicked`);
|
||||
}
|
||||
|
||||
interface MoveItemsArgs {
|
||||
sections: DashboardSection[];
|
||||
currentLayoutIndex: number;
|
||||
panelId: string;
|
||||
movePanel: (args: MovePanelArgs) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Move to section" submenu (other titled sections) plus a direct "Move out
|
||||
* of section" to the untitled root, shown only when the panel sits in a titled
|
||||
* section and a root section exists to receive it.
|
||||
*/
|
||||
function buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex,
|
||||
panelId,
|
||||
movePanel,
|
||||
}: MoveItemsArgs): MenuItem[] {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
);
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
...(targets.length === 0
|
||||
? { disabled: true }
|
||||
: {
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const rootSection = sections.find((s) => !s.title);
|
||||
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
|
||||
items.push({
|
||||
key: 'move-to-root',
|
||||
label: 'Move out of section',
|
||||
icon: <FolderOutput size={14} />,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: rootSection.layoutIndex,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
interface UsePanelActionItemsArgs {
|
||||
panelId: string;
|
||||
/** The panel itself — its query seeds the "Create Alerts" action. */
|
||||
/** The panel itself — seeds "Create Alerts" and the download filename. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** The panel's query response — the source for "Download as CSV". */
|
||||
data: PanelQueryData;
|
||||
/** Layout context for move/delete — absent outside editable mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
@@ -118,19 +45,15 @@ export interface PanelActionItems {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the panel actions menu items (V1 WidgetHeader set plus V2's "Move to
|
||||
* section"). Every action passes three gates before it appears:
|
||||
*
|
||||
* kind — what the panel kind declares it supports (PanelDefinition.actions);
|
||||
* unknown kinds support no kind-gated actions.
|
||||
* role — componentPermission lookup for the current user (PANEL_ACTION_META;
|
||||
* actions without a permission key are open to every role, V1 parity).
|
||||
* context — runtime state: dashboard editable (store), layout config present.
|
||||
* View and Download remain available on read-only dashboards, as in V1.
|
||||
* Resolves the panel actions menu items. Each action passes three gates before
|
||||
* it appears: kind (PanelDefinition.actions), role (useComponentPermission) and
|
||||
* context (dashboard editable + layout config present). View and Download stay
|
||||
* available on read-only dashboards, as in V1.
|
||||
*/
|
||||
export function usePanelActionItems({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelActions,
|
||||
}: UsePanelActionItemsArgs): PanelActionItems {
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
@@ -149,14 +72,19 @@ export function usePanelActionItems({
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
|
||||
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
|
||||
// supplies data (`sections`), so no callbacks are threaded through it.
|
||||
// Mutations are store-backed; the layout tree only supplies `sections`.
|
||||
const sections = panelActions?.sections ?? EMPTY_SECTIONS;
|
||||
const movePanel = useMovePanelToSection({ sections });
|
||||
const deletePanel = useDeletePanel({ sections });
|
||||
const clonePanel = useClonePanel({ sections });
|
||||
|
||||
const panelCapabilities = getPanelDefinition(panelKind).actions;
|
||||
const downloadItem = useDownloadPanelMenuItem({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
actions: panelCapabilities,
|
||||
});
|
||||
|
||||
// Delete runs on confirm, not on click — the menu item opens a prompt.
|
||||
const deleteConfirm = useConfirmableAction(
|
||||
@@ -191,8 +119,8 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openPanelEditor(panelId),
|
||||
});
|
||||
}
|
||||
// Clone needs the section context (source spec + dimensions) to place the
|
||||
// copy, so — unlike Edit — it requires panelActions.
|
||||
// Clone needs the section context to place the copy, so — unlike Edit —
|
||||
// it requires panelActions.
|
||||
if (isEditable && canEditWidget && panelActions && panelCapabilities.clone) {
|
||||
panelGroup.push({
|
||||
key: 'clone-panel',
|
||||
@@ -207,17 +135,12 @@ export function usePanelActionItems({
|
||||
}
|
||||
|
||||
const dataGroup: MenuItem[] = [];
|
||||
if (panelCapabilities.download) {
|
||||
dataGroup.push({
|
||||
key: 'download-panel',
|
||||
label: 'Download as CSV',
|
||||
icon: <CloudDownload size={14} />,
|
||||
onClick: (): void => notImplementedYet('Download'),
|
||||
});
|
||||
if (downloadItem) {
|
||||
dataGroup.push(downloadItem);
|
||||
}
|
||||
// Seeding an alert opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity: available
|
||||
// on locked dashboards too).
|
||||
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity).
|
||||
if (panelCapabilities.createAlert) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
@@ -265,6 +188,7 @@ export function usePanelActionItems({
|
||||
panelActions,
|
||||
sections,
|
||||
panelId,
|
||||
downloadItem,
|
||||
openView,
|
||||
openPanelEditor,
|
||||
createAlert,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
|
||||
@@ -23,6 +24,8 @@ interface PanelHeaderProps {
|
||||
panelId: string;
|
||||
/** The panel itself — its query seeds the menu's "Create Alerts" action. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** The panel's query response — the menu's source for "Download as CSV". */
|
||||
data: PanelQueryData;
|
||||
/** Background refresh in flight — shows a spinner without blinking the chart. */
|
||||
isFetching: boolean;
|
||||
/** Latest query error — surfaced as a header error indicator. */
|
||||
@@ -51,6 +54,7 @@ interface PanelHeaderProps {
|
||||
function PanelHeader({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
error,
|
||||
warning,
|
||||
@@ -117,6 +121,7 @@ function PanelHeader({
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
@@ -43,6 +44,11 @@ function makePanel(overrides?: {
|
||||
const baseProps = {
|
||||
panel: makePanel(),
|
||||
panelId: 'panel-1',
|
||||
data: {
|
||||
response: undefined,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
} as PanelQueryData,
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getTableCsvRows } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/TablePanel/tableCsv';
|
||||
import { downloadCsv } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/downloadCsv';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { useDownloadPanelCsv } from '../useDownloadPanelCsv';
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/kinds/TablePanel/tableCsv',
|
||||
() => ({ getTableCsvRows: jest.fn() }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/utils/downloadCsv',
|
||||
() => ({ downloadCsv: jest.fn() }),
|
||||
);
|
||||
|
||||
const mockGetTableCsvRows = getTableCsvRows as jest.Mock;
|
||||
const mockDownloadCsv = downloadCsv as jest.Mock;
|
||||
|
||||
const data = {} as PanelQueryData;
|
||||
const panelOf = (kind: string): DashboardtypesPanelDTO =>
|
||||
({
|
||||
spec: { display: { name: 'CPU' }, plugin: { kind } },
|
||||
}) as DashboardtypesPanelDTO;
|
||||
|
||||
describe('useDownloadPanelCsv', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('exports the table rows as CSV named after the panel', () => {
|
||||
mockGetTableCsvRows.mockReturnValue([{ service: 'frontend', p99: '1ms' }]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDownloadPanelCsv({ panel: panelOf('signoz/TablePanel'), data }),
|
||||
);
|
||||
result.current();
|
||||
|
||||
expect(mockGetTableCsvRows).toHaveBeenCalledTimes(1);
|
||||
expect(mockDownloadCsv).toHaveBeenCalledWith(
|
||||
[{ service: 'frontend', p99: '1ms' }],
|
||||
'CPU',
|
||||
);
|
||||
});
|
||||
|
||||
it('no-ops when the response has no rows', () => {
|
||||
mockGetTableCsvRows.mockReturnValue([]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDownloadPanelCsv({ panel: panelOf('signoz/TablePanel'), data }),
|
||||
);
|
||||
result.current();
|
||||
|
||||
expect(mockDownloadCsv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no-ops for non-table kinds without building rows', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDownloadPanelCsv({ panel: panelOf('signoz/TimeSeriesPanel'), data }),
|
||||
);
|
||||
result.current();
|
||||
|
||||
expect(mockGetTableCsvRows).not.toHaveBeenCalled();
|
||||
expect(mockDownloadCsv).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { DownloadFormat } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
|
||||
import { downloadElementAsImage } from '../../utils/downloadPanelImage';
|
||||
import { useDownloadPanelImage } from '../useDownloadPanelImage';
|
||||
|
||||
jest.mock('../../utils/downloadPanelImage', () => ({
|
||||
downloadElementAsImage: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { error: jest.fn(), dismiss: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockCapture = downloadElementAsImage as jest.MockedFunction<
|
||||
typeof downloadElementAsImage
|
||||
>;
|
||||
const mockToastError = toast.error as jest.Mock;
|
||||
|
||||
function mountPanel(panelId: string): HTMLElement {
|
||||
const node = document.createElement('div');
|
||||
node.setAttribute('data-panel-root', panelId);
|
||||
document.body.appendChild(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
describe('useDownloadPanelImage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('captures the panel node located by its data-panel-root marker, forwarding the format', async () => {
|
||||
const node = mountPanel('panel-1');
|
||||
mockCapture.mockResolvedValue();
|
||||
|
||||
const { result } = renderHook(() => useDownloadPanelImage());
|
||||
await result.current.downloadPanelImage(
|
||||
'panel-1',
|
||||
'My panel',
|
||||
DownloadFormat.SVG,
|
||||
);
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledWith(
|
||||
node,
|
||||
'My panel',
|
||||
DownloadFormat.SVG,
|
||||
);
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no panel matches the id (e.g. unmounted)', async () => {
|
||||
const { result } = renderHook(() => useDownloadPanelImage());
|
||||
await result.current.downloadPanelImage('missing', 'x', DownloadFormat.PNG);
|
||||
|
||||
expect(mockCapture).not.toHaveBeenCalled();
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces an error notification when the capture fails', async () => {
|
||||
mountPanel('panel-2');
|
||||
mockCapture.mockRejectedValue(new Error('capture boom'));
|
||||
|
||||
const { result } = renderHook(() => useDownloadPanelImage());
|
||||
await result.current.downloadPanelImage('panel-2', 'x', DownloadFormat.PNG);
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelActionCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { useDownloadPanelMenuItem } from '../useDownloadPanelMenuItem';
|
||||
|
||||
const mockDownloadCsv = jest.fn();
|
||||
jest.mock('../useDownloadPanelCsv', () => ({
|
||||
useDownloadPanelCsv: (): jest.Mock => mockDownloadCsv,
|
||||
}));
|
||||
|
||||
const mockDownloadImage = jest.fn();
|
||||
jest.mock('../useDownloadPanelImage', () => ({
|
||||
useDownloadPanelImage: (): { downloadPanelImage: jest.Mock } => ({
|
||||
downloadPanelImage: mockDownloadImage,
|
||||
}),
|
||||
}));
|
||||
|
||||
const panel = {
|
||||
spec: { display: { name: 'CPU' }, plugin: { kind: 'signoz/TablePanel' } },
|
||||
} as DashboardtypesPanelDTO;
|
||||
const data = {} as PanelQueryData;
|
||||
|
||||
const download = (
|
||||
formats: PanelActionCapabilities['download'],
|
||||
): PanelActionCapabilities => ({
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: formats,
|
||||
createAlert: true,
|
||||
search: true,
|
||||
drilldown: true,
|
||||
});
|
||||
|
||||
type Submenu = { children: { key: string; onClick: () => void }[] };
|
||||
|
||||
function render(actions: PanelActionCapabilities): { current: unknown } {
|
||||
return renderHook(() =>
|
||||
useDownloadPanelMenuItem({ panelId: 'panel-1', panel, data, actions }),
|
||||
).result;
|
||||
}
|
||||
|
||||
describe('useDownloadPanelMenuItem', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('returns null when the kind supports no download format', () => {
|
||||
const result = render(download({ csv: false, png: false, svg: false }));
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it('offers only the supported formats, dispatching CSV and image to their hooks', () => {
|
||||
const result = render(download({ csv: true, png: true, svg: true }));
|
||||
const item = result.current as Submenu;
|
||||
|
||||
expect(item.children.map((c) => c.key)).toStrictEqual([
|
||||
'download-csv',
|
||||
'download-png',
|
||||
'download-svg',
|
||||
]);
|
||||
|
||||
item.children.find((c) => c.key === 'download-csv')?.onClick();
|
||||
expect(mockDownloadCsv).toHaveBeenCalledTimes(1);
|
||||
expect(mockDownloadImage).not.toHaveBeenCalled();
|
||||
|
||||
item.children.find((c) => c.key === 'download-png')?.onClick();
|
||||
expect(mockDownloadImage).toHaveBeenCalledWith('panel-1', 'CPU', 'png');
|
||||
|
||||
item.children.find((c) => c.key === 'download-svg')?.onClick();
|
||||
expect(mockDownloadImage).toHaveBeenCalledWith('panel-1', 'CPU', 'svg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getTableCsvRows } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/TablePanel/tableCsv';
|
||||
import type { PanelOfKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { downloadCsv } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/downloadCsv';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
interface UseDownloadPanelCsvArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
data: PanelQueryData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a callback that exports the panel's data as CSV. Only tables have
|
||||
* tabular data to export; other kinds get a no-op.
|
||||
*/
|
||||
export function useDownloadPanelCsv({
|
||||
panel,
|
||||
data,
|
||||
}: UseDownloadPanelCsvArgs): () => void {
|
||||
const kind = panel.spec.plugin.kind;
|
||||
const fileName = panel.spec.display.name;
|
||||
|
||||
return useCallback((): void => {
|
||||
if (kind !== 'signoz/TablePanel') {
|
||||
return;
|
||||
}
|
||||
const rows = getTableCsvRows(panel as PanelOfKind<'signoz/TablePanel'>, data);
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
downloadCsv(rows, fileName);
|
||||
}, [kind, fileName, panel, data]);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import {
|
||||
downloadElementAsImage,
|
||||
type PanelImageFormat,
|
||||
} from '../utils/downloadPanelImage';
|
||||
|
||||
interface UseDownloadPanelImage {
|
||||
downloadPanelImage: (
|
||||
panelId: string,
|
||||
panelName: string,
|
||||
format: PanelImageFormat,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a V2 panel as an image (PNG or SVG). Locates the panel's root node
|
||||
* by its `data-panel-root` marker (set in Panel.tsx) so the capture works
|
||||
* without threading a ref through the header → actions-menu chain, then
|
||||
* delegates to the pure capture util. Failures surface as an error toast.
|
||||
*/
|
||||
export function useDownloadPanelImage(): UseDownloadPanelImage {
|
||||
const downloadPanelImage = useCallback(
|
||||
async (
|
||||
panelId: string,
|
||||
panelName: string,
|
||||
format: PanelImageFormat,
|
||||
): Promise<void> => {
|
||||
const node = document.querySelector<HTMLElement>(
|
||||
`[data-panel-root="${CSS.escape(panelId)}"]`,
|
||||
);
|
||||
// The menu lives inside the panel, so the node is normally present;
|
||||
// bail quietly if the panel unmounted between open and click.
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await downloadElementAsImage(node, panelName, format);
|
||||
} catch {
|
||||
toast.error('Could not download panel.', {
|
||||
action: {
|
||||
label: 'Dismiss',
|
||||
onClick: (): void => {
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
description: 'Something went wrong while capturing the panel image.',
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { downloadPanelImage };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DownloadFormat,
|
||||
type PanelActionCapabilities,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { buildDownloadMenuItem } from '../utils/buildDownloadMenuItem';
|
||||
import { useDownloadPanelCsv } from './useDownloadPanelCsv';
|
||||
import { useDownloadPanelImage } from './useDownloadPanelImage';
|
||||
|
||||
interface UseDownloadPanelMenuItemArgs {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
data: PanelQueryData;
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the panel's "Download" submenu item: CSV from the query response,
|
||||
* PNG/SVG from the rendered node. Null when the kind supports no format.
|
||||
*/
|
||||
export function useDownloadPanelMenuItem({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
actions,
|
||||
}: UseDownloadPanelMenuItemArgs): MenuItem | null {
|
||||
const panelName = panel.spec.display.name;
|
||||
const downloadPanelCsv = useDownloadPanelCsv({ panel, data });
|
||||
const { downloadPanelImage } = useDownloadPanelImage();
|
||||
|
||||
const onDownload = useCallback(
|
||||
(format: DownloadFormat): void => {
|
||||
if (format === DownloadFormat.CSV) {
|
||||
downloadPanelCsv();
|
||||
return;
|
||||
}
|
||||
void downloadPanelImage(panelId, panelName, format);
|
||||
},
|
||||
[downloadPanelCsv, downloadPanelImage, panelId, panelName],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => buildDownloadMenuItem({ supported: actions.download, onDownload }),
|
||||
[actions.download, onDownload],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { toPng, toSvg } from 'html-to-image';
|
||||
import { DownloadFormat } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
|
||||
import { downloadElementAsImage } from '../downloadPanelImage';
|
||||
|
||||
jest.mock('html-to-image', () => ({ toPng: jest.fn(), toSvg: jest.fn() }));
|
||||
|
||||
const mockToPng = toPng as jest.MockedFunction<typeof toPng>;
|
||||
const mockToSvg = toSvg as jest.MockedFunction<typeof toSvg>;
|
||||
|
||||
describe('downloadElementAsImage', () => {
|
||||
let node: HTMLElement;
|
||||
let fakeLink: {
|
||||
href: string;
|
||||
download: string;
|
||||
click: jest.Mock;
|
||||
remove: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockToPng.mockReset();
|
||||
mockToPng.mockResolvedValue('data:image/png;base64,AAAA');
|
||||
mockToSvg.mockReset();
|
||||
mockToSvg.mockResolvedValue('data:image/svg+xml;base64,BBBB');
|
||||
|
||||
fakeLink = { href: '', download: '', click: jest.fn(), remove: jest.fn() };
|
||||
// Only stub the anchor used for the download; let every other tag (the
|
||||
// elements the filter test builds) fall through to the real DOM.
|
||||
const realCreateElement = document.createElement.bind(document);
|
||||
jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tag: string) =>
|
||||
tag === 'a'
|
||||
? (fakeLink as unknown as HTMLAnchorElement)
|
||||
: realCreateElement(tag),
|
||||
);
|
||||
|
||||
node = document.createElement('div');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('captures a PNG via the png encoder, named after the panel', async () => {
|
||||
await downloadElementAsImage(node, 'My panel', DownloadFormat.PNG);
|
||||
|
||||
expect(mockToPng).toHaveBeenCalledTimes(1);
|
||||
expect(mockToPng.mock.calls[0][0]).toBe(node);
|
||||
expect(mockToSvg).not.toHaveBeenCalled();
|
||||
expect(fakeLink.href).toBe('data:image/png;base64,AAAA');
|
||||
expect(fakeLink.download).toBe('My panel.png');
|
||||
expect(fakeLink.click).toHaveBeenCalledTimes(1);
|
||||
expect(fakeLink.remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('captures an SVG via the svg encoder and extension', async () => {
|
||||
await downloadElementAsImage(node, 'My panel', DownloadFormat.SVG);
|
||||
|
||||
expect(mockToSvg).toHaveBeenCalledTimes(1);
|
||||
expect(mockToPng).not.toHaveBeenCalled();
|
||||
expect(fakeLink.href).toBe('data:image/svg+xml;base64,BBBB');
|
||||
expect(fakeLink.download).toBe('My panel.svg');
|
||||
});
|
||||
|
||||
it('filters the actions cluster (.panel-no-drag) out of the capture but keeps content', async () => {
|
||||
await downloadElementAsImage(node, 'x', DownloadFormat.PNG);
|
||||
|
||||
const { filter } = mockToPng.mock.calls[0][1] as {
|
||||
filter: (n: HTMLElement) => boolean;
|
||||
};
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.classList.add('panel-no-drag');
|
||||
const chart = document.createElement('canvas');
|
||||
|
||||
expect(filter(actions)).toBe(false);
|
||||
expect(filter(chart)).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to "panel" when untitled and sanitizes filesystem-unsafe characters', async () => {
|
||||
await downloadElementAsImage(node, ' ', DownloadFormat.PNG);
|
||||
expect(fakeLink.download).toBe('panel.png');
|
||||
|
||||
await downloadElementAsImage(node, 'errors/sec: p99', DownloadFormat.SVG);
|
||||
expect(fakeLink.download).toBe('errors-sec- p99.svg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
CloudDownload,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileSpreadsheet,
|
||||
} from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import {
|
||||
DownloadFormat,
|
||||
type PanelActionCapabilities,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
|
||||
const DOWNLOAD_FORMAT_OPTIONS: {
|
||||
format: DownloadFormat;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
}[] = [
|
||||
{
|
||||
format: DownloadFormat.CSV,
|
||||
label: 'Download as CSV',
|
||||
icon: <FileSpreadsheet size={14} />,
|
||||
},
|
||||
{
|
||||
format: DownloadFormat.PNG,
|
||||
label: 'Download as PNG',
|
||||
icon: <FileImage size={14} />,
|
||||
},
|
||||
{
|
||||
format: DownloadFormat.SVG,
|
||||
label: 'Download as SVG',
|
||||
icon: <FileCode size={14} />,
|
||||
},
|
||||
];
|
||||
|
||||
interface DownloadMenuItemArgs {
|
||||
supported?: PanelActionCapabilities['download'];
|
||||
onDownload: (format: DownloadFormat) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Download" submenu: one option per format the kind supports, each handing
|
||||
* the format to `onDownload`. Null when the kind supports no format.
|
||||
*/
|
||||
export function buildDownloadMenuItem({
|
||||
supported,
|
||||
onDownload,
|
||||
}: DownloadMenuItemArgs): MenuItem | null {
|
||||
if (!supported) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children: MenuItem[] = DOWNLOAD_FORMAT_OPTIONS.filter(
|
||||
({ format }) => supported[format],
|
||||
).map(({ format, label, icon }) => ({
|
||||
key: `download-${format}`,
|
||||
label,
|
||||
icon,
|
||||
onClick: (): void => onDownload(format),
|
||||
}));
|
||||
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: 'download',
|
||||
label: 'Download',
|
||||
icon: <CloudDownload size={14} />,
|
||||
children,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { FolderInput, FolderOutput } from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
|
||||
interface MoveItemsArgs {
|
||||
sections: DashboardSection[];
|
||||
currentLayoutIndex: number;
|
||||
panelId: string;
|
||||
movePanel: (args: MovePanelArgs) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Move to section" submenu plus a direct "Move out of section" to the
|
||||
* untitled root, shown only when the panel sits in a titled section and a root
|
||||
* section exists to receive it.
|
||||
*/
|
||||
export function buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex,
|
||||
panelId,
|
||||
movePanel,
|
||||
}: MoveItemsArgs): MenuItem[] {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
);
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
...(targets.length === 0
|
||||
? { disabled: true }
|
||||
: {
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const rootSection = sections.find((s) => !s.title);
|
||||
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
|
||||
items.push({
|
||||
key: 'move-to-root',
|
||||
label: 'Move out of section',
|
||||
icon: <FolderOutput size={14} />,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: rootSection.layoutIndex,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { toPng, toSvg } from 'html-to-image';
|
||||
import { DownloadFormat } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { toSafeFileName } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/toSafeFileName';
|
||||
|
||||
/** Image formats a panel can be exported to (both via html-to-image). */
|
||||
export type PanelImageFormat = DownloadFormat.PNG | DownloadFormat.SVG;
|
||||
|
||||
// The actions cluster (search box, status popovers, three-dot menu) is chrome,
|
||||
// not content — it carries this class to opt out of the grid drag handle (see
|
||||
// PanelHeader), and we reuse it to drop the whole cluster from the capture.
|
||||
const ACTIONS_CONTAINER_CLASS = 'panel-no-drag';
|
||||
|
||||
// Render raster output at 2x so the PNG stays crisp on retina displays. SVG is
|
||||
// vector, so the ratio is irrelevant there (html-to-image ignores it).
|
||||
const CAPTURE_PIXEL_RATIO = 2;
|
||||
|
||||
// Per-format encoder: html-to-image's toPng/toSvg share a signature and both
|
||||
// return a ready-to-download data URL, so they differ only by file extension.
|
||||
const FORMAT_ENCODERS: Record<
|
||||
PanelImageFormat,
|
||||
{ encode: typeof toPng; extension: PanelImageFormat }
|
||||
> = {
|
||||
[DownloadFormat.PNG]: { encode: toPng, extension: DownloadFormat.PNG },
|
||||
[DownloadFormat.SVG]: { encode: toSvg, extension: DownloadFormat.SVG },
|
||||
};
|
||||
|
||||
/**
|
||||
* Captures a panel's rendered DOM node as an image (PNG or SVG) and triggers a
|
||||
* browser download. The hover-only actions cluster is filtered out so the image
|
||||
* is just the panel title plus its chart/table. Resolves once the download is
|
||||
* initiated; rejects if the capture fails (the caller surfaces the error).
|
||||
*/
|
||||
export async function downloadElementAsImage(
|
||||
node: HTMLElement,
|
||||
fileBaseName: string,
|
||||
format: PanelImageFormat,
|
||||
): Promise<void> {
|
||||
const { encode, extension } = FORMAT_ENCODERS[format];
|
||||
|
||||
const dataUrl = await encode(node, {
|
||||
// Skip the actions cluster (and its subtree) — search/status/menu chrome.
|
||||
filter: (domNode) => !domNode.classList?.contains(ACTIONS_CONTAINER_CLASS),
|
||||
// `.panel` paints --l2-background; pass it explicitly so the rounded
|
||||
// corners fill with the panel colour instead of staying transparent.
|
||||
backgroundColor: window.getComputedStyle(node).backgroundColor,
|
||||
pixelRatio: CAPTURE_PIXEL_RATIO,
|
||||
cacheBust: true,
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = `${toSafeFileName(fileBaseName)}.${extension}`;
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
Reference in New Issue
Block a user