Compare commits
31 Commits
cursor/cop
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
529a9e7009 | ||
|
|
b00687b43f | ||
|
|
8771919de6 | ||
|
|
497972f23c | ||
|
|
a9e30919d1 | ||
|
|
925c4c4a3d | ||
|
|
e66bfe5961 | ||
|
|
42943f72b7 | ||
|
|
7a72a209e5 | ||
|
|
44f00943a8 | ||
|
|
8867e1ef38 | ||
|
|
c08e520941 | ||
|
|
139cc4452d | ||
|
|
2f3baeb302 | ||
|
|
3d42b0058e | ||
|
|
ed70e3c5f5 | ||
|
|
7d6918f8b6 | ||
|
|
2885bc851e | ||
|
|
857258f8c3 | ||
|
|
ece5c2b7ad | ||
|
|
1078f98388 | ||
|
|
b4e2326f38 | ||
|
|
c79b154215 | ||
|
|
a59c0188cc | ||
|
|
3df426625a | ||
|
|
646f359f33 | ||
|
|
81167c6947 | ||
|
|
bc1295b93a | ||
|
|
3db0e1f66a | ||
|
|
d52b54aeb3 | ||
|
|
c8608c18ae |
40
.github/CODEOWNERS
vendored
@@ -3,51 +3,11 @@
|
||||
# that they own.
|
||||
|
||||
/frontend/ @YounixM @aks07
|
||||
/frontend/src/container/MetricsApplication @srikanthccv
|
||||
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
|
||||
|
||||
# Onboarding
|
||||
/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.json @makeavish
|
||||
/frontend/src/container/OnboardingV2Container/AddDataSource/AddDataSource.tsx @makeavish
|
||||
|
||||
# Dashboard, Alert, Metrics, Service Map, Services
|
||||
/frontend/src/container/ListOfDashboard/ @srikanthccv
|
||||
/frontend/src/container/NewDashboard/ @srikanthccv
|
||||
/frontend/src/pages/DashboardsListPage/ @srikanthccv
|
||||
/frontend/src/pages/DashboardWidget/ @srikanthccv
|
||||
/frontend/src/pages/NewDashboard/ @srikanthccv
|
||||
/frontend/src/providers/Dashboard/ @srikanthccv
|
||||
|
||||
# Alerts
|
||||
/frontend/src/container/AlertHistory/ @srikanthccv
|
||||
/frontend/src/container/AllAlertChannels/ @srikanthccv
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @srikanthccv
|
||||
/frontend/src/container/CreateAlertChannels/ @srikanthccv
|
||||
/frontend/src/container/CreateAlertRule/ @srikanthccv
|
||||
/frontend/src/container/EditAlertChannels/ @srikanthccv
|
||||
/frontend/src/container/FormAlertChannels/ @srikanthccv
|
||||
/frontend/src/container/FormAlertRules/ @srikanthccv
|
||||
/frontend/src/container/ListAlertRules/ @srikanthccv
|
||||
/frontend/src/container/TriggeredAlerts/ @srikanthccv
|
||||
/frontend/src/pages/AlertChannelCreate/ @srikanthccv
|
||||
/frontend/src/pages/AlertDetails/ @srikanthccv
|
||||
/frontend/src/pages/AlertHistory/ @srikanthccv
|
||||
/frontend/src/pages/AlertList/ @srikanthccv
|
||||
/frontend/src/pages/CreateAlert/ @srikanthccv
|
||||
/frontend/src/providers/Alert.tsx @srikanthccv
|
||||
|
||||
# Metrics
|
||||
/frontend/src/container/MetricsExplorer/ @srikanthccv
|
||||
/frontend/src/pages/MetricsApplication/ @srikanthccv
|
||||
/frontend/src/pages/MetricsExplorer/ @srikanthccv
|
||||
|
||||
# Services and Service Map
|
||||
/frontend/src/container/ServiceApplication/ @srikanthccv
|
||||
/frontend/src/container/ServiceTable/ @srikanthccv
|
||||
/frontend/src/pages/Services/ @srikanthccv
|
||||
/frontend/src/pages/ServiceTopLevelOperations/ @srikanthccv
|
||||
/frontend/src/container/Home/Services/ @srikanthccv
|
||||
|
||||
/deploy/ @SigNoz/devops
|
||||
.github @SigNoz/devops
|
||||
|
||||
|
||||
16
.github/workflows/goci.yaml
vendored
@@ -73,3 +73,19 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
make docker-build-enterprise
|
||||
openapi:
|
||||
if: |
|
||||
(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'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: generate-openapi
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate openapi
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
|
||||
|
||||
@@ -13,6 +13,7 @@ func main() {
|
||||
|
||||
// register a list of commands to the root command
|
||||
registerServer(cmd.RootCmd, logger)
|
||||
cmd.RegisterGenerate(cmd.RootCmd, logger)
|
||||
|
||||
cmd.Execute(logger)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ func main() {
|
||||
|
||||
// register a list of commands to the root command
|
||||
registerServer(cmd.RootCmd, logger)
|
||||
cmd.RegisterGenerate(cmd.RootCmd, logger)
|
||||
|
||||
cmd.Execute(logger)
|
||||
}
|
||||
|
||||
21
cmd/generate.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
|
||||
var generateCmd = &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate artifacts",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
|
||||
}
|
||||
|
||||
registerGenerateOpenAPI(generateCmd)
|
||||
|
||||
parentCmd.AddCommand(generateCmd)
|
||||
}
|
||||
41
cmd/openapi.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func registerGenerateOpenAPI(parentCmd *cobra.Command) {
|
||||
openapiCmd := &cobra.Command{
|
||||
Use: "openapi",
|
||||
Short: "Generate OpenAPI schema for SigNoz",
|
||||
RunE: func(currCmd *cobra.Command, args []string) error {
|
||||
return runGenerateOpenAPI(currCmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
parentCmd.AddCommand(openapiCmd)
|
||||
}
|
||||
|
||||
func runGenerateOpenAPI(ctx context.Context) error {
|
||||
instrumentation, err := instrumentation.New(ctx, instrumentation.Config{Logs: instrumentation.LogsConfig{Level: slog.LevelInfo}}, version.Info, "signoz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
openapi, err := signoz.NewOpenAPI(ctx, instrumentation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := openapi.CreateAndWrite("docs/api/openapi.yml"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.103.0
|
||||
image: signoz/signoz:v0.104.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.103.0
|
||||
image: signoz/signoz:v0.104.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.103.0}
|
||||
image: signoz/signoz:${VERSION:-v0.104.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.103.0}
|
||||
image: signoz/signoz:${VERSION:-v0.104.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
2293
docs/api/openapi.yml
Normal file
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
@@ -60,6 +61,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
|
||||
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
|
||||
Signoz: signoz,
|
||||
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
|
||||
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -92,10 +94,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// routes available only in ee version
|
||||
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
|
||||
|
||||
// paid plans specific routes
|
||||
router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionBySAMLCallback)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/complete/oidc", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionByOIDCCallback)).Methods(http.MethodGet)
|
||||
|
||||
// base overrides
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
|
||||
|
||||
|
||||
@@ -243,6 +243,11 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.MetricExplorerRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
err := s.signoz.APIServer.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
@@ -253,7 +258,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
|
||||
handler = handlers.CompressHandler(handler)
|
||||
|
||||
err := web.AddToRouter(r)
|
||||
err = web.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
"@signozhq/button": "0.0.2",
|
||||
"@signozhq/calendar": "0.0.0",
|
||||
"@signozhq/callout": "0.0.2",
|
||||
"@signozhq/checkbox": "0.0.2",
|
||||
"@signozhq/command": "0.0.0",
|
||||
"@signozhq/design-tokens": "1.1.4",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
@@ -103,7 +105,6 @@
|
||||
"i18next-http-backend": "^1.3.2",
|
||||
"jest": "^27.5.1",
|
||||
"js-base64": "^3.7.2",
|
||||
"kbar": "0.1.0-beta.48",
|
||||
"less": "^4.1.2",
|
||||
"less-loader": "^10.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
1
frontend/public/Logos/dashboards.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><g transform="translate(25 25)"><rect width="60" height="55" fill="#fcd34d" rx="6"/><path stroke="#d97706" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="m10 40 15-15 15 8 15-23"/><rect width="35" height="55" x="65" fill="#6ee7b7" rx="6"/><rect width="20" height="6" x="73" y="10" fill="#059669" rx="3"/><rect width="15" height="6" x="73" y="22" fill="#059669" opacity=".7" rx="3"/><rect width="18" height="6" x="73" y="34" fill="#059669" opacity=".7" rx="3"/><rect width="100" height="40" y="60" fill="#a5b4fc" rx="6"/><rect width="80" height="8" x="10" y="70" fill="#4f46e5" rx="4"/><rect width="50" height="8" x="20" y="83" fill="#6366f1" rx="4"/></g></svg>
|
||||
|
After Width: | Height: | Size: 826 B |
1
frontend/public/Logos/envoy.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#b31aab" d="m33.172 61.48.176 7.325 7.797 4.785-.176-7.324Zm19.031 30.504-.176-7.18-6.84-4.206c-.085-.059-.203-.145-.289-.203l.176 7.207Zm-24.355 9.688L10.012 90.715l-.438-18.367 8.758-3.746-.172-7.352-13.969 5.969c-1.074.46-1.714 1.441-1.687 2.566l.523 22.055c.032 1.125.73 2.25 1.836 2.941l21.383 13.149c.992.605 2.215.78 3.203.46a.8.8 0 0 0 .29-.113l13.124-5.593-7.129-4.383Zm0 0"/><path fill="#d163ce" d="M85.488 61.047c-.031-1.328-.843-2.625-2.125-3.43L57.38 41.672l-.813.344.176 7.726 20.57 12.63.493 20.648 7.86 4.812.433-.172ZM54.383 97.289 30.262 82.47l-.582-24.883 11-4.7-.203-8.562-17.082 7.293c-1.25.547-2.008 1.672-1.977 3l.668 29.18c.031 1.324.844 2.625 2.125 3.402l28.281 17.387c1.164.723 2.563.894 3.754.547.117-.028.234-.086.348-.145l16.703-7.12-8.32-5.102Zm0 0"/><path fill="#e13eaf" d="M122.234 40.633 85.98 18.343c-1.335-.808-2.937-1.038-4.304-.605-.145.028-.262.086-.406.145l-35.383 15.11c-1.426.605-2.297 1.902-2.27 3.429l.903 37.371c.03 1.527.96 2.996 2.445 3.89l36.254 22.262c1.336.805 2.937 1.035 4.277.606.145-.031.262-.09.406-.145l35.383-15.11c1.426-.605 2.297-1.933 2.27-3.433l-.875-37.367c-.028-1.473-.961-2.969-2.446-3.863M85.398 91.64 53.891 72.293l-.79-32.496 30.727-13.121 31.512 19.347.785 32.497Zm0 0"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/Logos/fly-io.svg
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
1
frontend/public/Logos/honeycomb.svg
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
1
frontend/public/Logos/logs.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><g transform="translate(25 40)"><rect width="12" height="12" fill="#059669" opacity=".8" rx="3"/><rect width="80" height="12" x="20" fill="#10b981" rx="3"/><rect width="12" height="12" y="22" fill="#059669" opacity=".8" rx="3"/><rect width="65" height="12" x="20" y="22" fill="#10b981" rx="3"/><rect width="12" height="12" y="44" fill="#059669" opacity=".8" rx="3"/><rect width="75" height="12" x="20" y="44" fill="#10b981" rx="3"/><rect width="12" height="12" y="66" fill="#059669" opacity=".8" rx="3"/><rect width="50" height="12" x="20" y="66" fill="#10b981" rx="3"/></g></svg>
|
||||
|
After Width: | Height: | Size: 726 B |
1
frontend/public/Logos/metrics.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><defs><linearGradient id="a" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#f59e0b" stop-opacity=".5"/><stop offset="100%" stop-color="#f59e0b" stop-opacity=".05"/></linearGradient></defs><g transform="translate(25 35)"><path stroke="#d1d5db" stroke-linecap="round" stroke-width="2" d="M0 80h100M0 80V0"/><path fill="url(#a)" d="M2 78c18 0 28-28 48-23s30-35 48-40v63z"/><path stroke="#d97706" stroke-linecap="round" stroke-width="5" d="M2 78c18 0 28-28 48-23s30-35 48-40"/><circle cx="50" cy="55" r="4" fill="#f59e0b"/><circle cx="98" cy="15" r="4" fill="#f59e0b"/></g></svg>
|
||||
|
After Width: | Height: | Size: 733 B |
1
frontend/public/Logos/traces.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><rect width="100" height="14" x="25" y="45" fill="#4f46e5" rx="4"/><rect width="60" height="14" x="40" y="68" fill="#6366f1" rx="4"/><rect width="20" height="14" x="105" y="91" fill="#818cf8" rx="4"/><path stroke="#c7d2fe" stroke-width="2" d="M35 59v16m5 0h-5M100 59v39m5 0h-5"/></svg>
|
||||
|
After Width: | Height: | Size: 431 B |
@@ -245,6 +245,14 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
history.replace(newLocation);
|
||||
return;
|
||||
}
|
||||
|
||||
// if the current route is public dashboard then don't redirect to login
|
||||
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
|
||||
|
||||
if (isPublicDashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the current route
|
||||
if (currentRoute) {
|
||||
const { isPrivate, key } = currentRoute;
|
||||
|
||||
@@ -4,7 +4,7 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AppLoading from 'components/AppLoading/AppLoading';
|
||||
import KBarCommandPalette from 'components/KBarCommandPalette/KBarCommandPalette';
|
||||
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -22,12 +22,11 @@ import { StatusCodes } from 'http-status-codes';
|
||||
import history from 'lib/history';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import posthog from 'posthog-js';
|
||||
import AlertRuleProvider from 'providers/Alert';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
@@ -214,7 +213,10 @@ function App(): JSX.Element {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === ROUTES.ONBOARDING) {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/')
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
window.Pylon('hideChatBubble');
|
||||
@@ -362,35 +364,33 @@ function App(): JSX.Element {
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
<Router history={history}>
|
||||
<CompatRouter>
|
||||
<KBarCommandPaletteProvider>
|
||||
<KBarCommandPalette />
|
||||
<CmdKProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorModalProvider>
|
||||
{isLoggedInState && <CmdKPalette userRole={user.role} />}
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
<DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AlertRuleProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</AlertRuleProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</KeyboardHotkeysProvider>
|
||||
</DashboardProvider>
|
||||
</QueryBuilderProvider>
|
||||
@@ -398,7 +398,7 @@ function App(): JSX.Element {
|
||||
</PrivateRoute>
|
||||
</ErrorModalProvider>
|
||||
</NotificationProvider>
|
||||
</KBarCommandPaletteProvider>
|
||||
</CmdKProvider>
|
||||
</CompatRouter>
|
||||
</Router>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -295,3 +295,10 @@ export const MetricsExplorer = Loadable(
|
||||
export const ApiMonitoring = Loadable(
|
||||
() => import(/* webpackChunkName: "ApiMonitoring" */ 'pages/ApiMonitoring'),
|
||||
);
|
||||
|
||||
export const PublicDashboardPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "Public Dashboard Page" */ 'pages/PublicDashboard'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
OrgOnboarding,
|
||||
PasswordReset,
|
||||
PipelinePage,
|
||||
PublicDashboardPage,
|
||||
ServiceMapPage,
|
||||
ServiceMetricsPage,
|
||||
ServicesTablePage,
|
||||
@@ -169,6 +170,13 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.PUBLIC_DASHBOARD,
|
||||
exact: false,
|
||||
component: PublicDashboardPage,
|
||||
isPrivate: false,
|
||||
key: 'PUBLIC_DASHBOARD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.DASHBOARD_WIDGET,
|
||||
exact: true,
|
||||
|
||||
28
frontend/src/api/dashboard/public/createPublicDashboard.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { CreatePublicDashboardProps } from 'types/api/dashboard/public/create';
|
||||
|
||||
const createPublicDashboard = async (
|
||||
props: CreatePublicDashboardProps,
|
||||
): Promise<SuccessResponseV2<CreatePublicDashboardProps>> => {
|
||||
|
||||
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = '30m' } = props;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/dashboards/${dashboardId}/public`,
|
||||
{ timeRangeEnabled, defaultTimeRange },
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default createPublicDashboard;
|
||||
20
frontend/src/api/dashboard/public/getPublicDashboardData.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetPublicDashboardDataProps, PayloadProps,PublicDashboardDataProps } from 'types/api/dashboard/public/get';
|
||||
|
||||
const getPublicDashboardData = async (props: GetPublicDashboardDataProps): Promise<SuccessResponseV2<PublicDashboardDataProps>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/public/dashboards/${props.id}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getPublicDashboardData;
|
||||
20
frontend/src/api/dashboard/public/getPublicDashboardMeta.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetPublicDashboardMetaProps, PayloadProps,PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
|
||||
|
||||
const getPublicDashboardMeta = async (props: GetPublicDashboardMetaProps): Promise<SuccessResponseV2<PublicDashboardMetaProps>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/dashboards/${props.id}/public`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getPublicDashboardMeta;
|
||||
@@ -0,0 +1,27 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { MetricRangePayloadV5 } from 'api/v5/v5';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetPublicDashboardWidgetDataProps } from 'types/api/dashboard/public/getWidgetData';
|
||||
|
||||
|
||||
const getPublicDashboardWidgetData = async (props: GetPublicDashboardWidgetDataProps): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
|
||||
try {
|
||||
const response = await axios.get(`/public/dashboards/${props.id}/widgets/${props.index}/query_range`, {
|
||||
params: {
|
||||
startTime: props.startTime,
|
||||
endTime: props.endTime,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getPublicDashboardWidgetData;
|
||||
@@ -0,0 +1,22 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps,RevokePublicDashboardAccessProps } from 'types/api/dashboard/public/delete';
|
||||
|
||||
const revokePublicDashboardAccess = async (
|
||||
props: RevokePublicDashboardAccessProps,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.delete<PayloadProps>(`/dashboards/${props.id}/public`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default revokePublicDashboardAccess;
|
||||
28
frontend/src/api/dashboard/public/updatePublicDashboard.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { UpdatePublicDashboardProps } from 'types/api/dashboard/public/update';
|
||||
|
||||
const updatePublicDashboard = async (
|
||||
props: UpdatePublicDashboardProps,
|
||||
): Promise<SuccessResponseV2<UpdatePublicDashboardProps>> => {
|
||||
|
||||
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = '30m' } = props;
|
||||
|
||||
try {
|
||||
const response = await axios.put(
|
||||
`/dashboards/${dashboardId}/public`,
|
||||
{ timeRangeEnabled, defaultTimeRange },
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default updatePublicDashboard;
|
||||
2
frontend/src/auto-import-registry.d.ts
vendored
@@ -14,6 +14,8 @@ import '@signozhq/badge';
|
||||
import '@signozhq/button';
|
||||
import '@signozhq/calendar';
|
||||
import '@signozhq/callout';
|
||||
import '@signozhq/checkbox';
|
||||
import '@signozhq/command';
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
|
||||
@@ -62,6 +62,8 @@ interface CustomTimePickerProps {
|
||||
showLiveLogs?: boolean;
|
||||
onGoLive?: () => void;
|
||||
onExitLiveLogs?: () => void;
|
||||
/** When false, hides the "Recently Used" time ranges section */
|
||||
showRecentlyUsed?: boolean;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -81,6 +83,7 @@ function CustomTimePicker({
|
||||
onGoLive,
|
||||
onExitLiveLogs,
|
||||
showLiveLogs,
|
||||
showRecentlyUsed = true,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [
|
||||
selectedTimePlaceholderValue,
|
||||
@@ -395,6 +398,7 @@ function CustomTimePicker({
|
||||
setActiveView={setActiveView}
|
||||
setIsOpenedFromFooter={setIsOpenedFromFooter}
|
||||
isOpenedFromFooter={isOpenedFromFooter}
|
||||
showRecentlyUsed={showRecentlyUsed}
|
||||
/>
|
||||
) : (
|
||||
content
|
||||
@@ -464,4 +468,5 @@ CustomTimePicker.defaultProps = {
|
||||
onCustomTimeStatusUpdate: noop,
|
||||
onExitLiveLogs: noop,
|
||||
showLiveLogs: false,
|
||||
showRecentlyUsed: true,
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ interface CustomTimePickerPopoverContentProps {
|
||||
isOpenedFromFooter: boolean;
|
||||
setIsOpenedFromFooter: Dispatch<SetStateAction<boolean>>;
|
||||
onExitLiveLogs: () => void;
|
||||
showRecentlyUsed: boolean;
|
||||
}
|
||||
|
||||
interface RecentlyUsedDateTimeRange {
|
||||
@@ -72,6 +73,7 @@ function CustomTimePickerPopoverContent({
|
||||
isOpenedFromFooter,
|
||||
setIsOpenedFromFooter,
|
||||
onExitLiveLogs,
|
||||
showRecentlyUsed = true,
|
||||
}: CustomTimePickerPopoverContentProps): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
@@ -224,33 +226,35 @@ function CustomTimePickerPopoverContent({
|
||||
<div>{getTimeChips(RelativeDurationSuggestionOptions)}</div>
|
||||
</div>
|
||||
|
||||
<div className="recently-used-container">
|
||||
<div className="time-heading">RECENTLY USED</div>
|
||||
<div className="recently-used-range">
|
||||
{recentlyUsedTimeRanges.map((range: RecentlyUsedDateTimeRange) => (
|
||||
<div
|
||||
className="recently-used-range-item"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
{showRecentlyUsed && (
|
||||
<div className="recently-used-container">
|
||||
<div className="time-heading">RECENTLY USED</div>
|
||||
<div className="recently-used-range">
|
||||
{recentlyUsedTimeRanges.map((range: RecentlyUsedDateTimeRange) => (
|
||||
<div
|
||||
className="recently-used-range-item"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleExitLiveLogs();
|
||||
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
key={range.value}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
key={range.value}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{range.label}
|
||||
</div>
|
||||
))}
|
||||
}}
|
||||
>
|
||||
{range.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
.kbar-command-palette__positioner {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.kbar-command-palette__animator {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.kbar-command-palette__card {
|
||||
background: var(--bg-ink-500);
|
||||
color: var(--text-vanilla-100);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kbar-command-palette__search {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-ink-200);
|
||||
color: var(--text-vanilla-100);
|
||||
outline: none;
|
||||
background-color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.kbar-command-palette__section {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-robin-500);
|
||||
font-family: 'Inter', sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.kbar-command-palette__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.kbar-command-palette__item:hover,
|
||||
.kbar-command-palette__item--active {
|
||||
background: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.kbar-command-palette__icon {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.kbar-command-palette__shortcut {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kbar-command-palette__key {
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--text-vanilla-300);
|
||||
text-transform: uppercase;
|
||||
font-family: 'Space Mono', monospace;
|
||||
}
|
||||
|
||||
.kbar-command-palette__results-container {
|
||||
div {
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.kbar-command-palette__positioner {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.kbar-command-palette__card {
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.kbar-command-palette__search {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
color: var(--text-ink-500);
|
||||
background-color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.kbar-command-palette__item {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
.kbar-command-palette__item:hover,
|
||||
.kbar-command-palette__item--active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.kbar-command-palette__icon {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.kbar-command-palette__key {
|
||||
background: #eee;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.kbar-command-palette__results-container {
|
||||
div {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import './KBarCommandPalette.scss';
|
||||
|
||||
import {
|
||||
KBarAnimator,
|
||||
KBarPortal,
|
||||
KBarPositioner,
|
||||
KBarResults,
|
||||
KBarSearch,
|
||||
useMatches,
|
||||
} from 'kbar';
|
||||
|
||||
function Results(): JSX.Element {
|
||||
const { results } = useMatches();
|
||||
|
||||
const renderResults = ({
|
||||
item,
|
||||
active,
|
||||
}: {
|
||||
item: any;
|
||||
active: boolean;
|
||||
}): JSX.Element =>
|
||||
typeof item === 'string' ? (
|
||||
<div className="kbar-command-palette__section">{item}</div>
|
||||
) : (
|
||||
<div
|
||||
className={`kbar-command-palette__item ${
|
||||
active ? 'kbar-command-palette__item--active' : ''
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.name}</span>
|
||||
{item.shortcut?.length ? (
|
||||
<span className="kbar-command-palette__shortcut">
|
||||
{item.shortcut.map((sc: string) => (
|
||||
<kbd key={sc} className="kbar-command-palette__key">
|
||||
{sc}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="kbar-command-palette__results-container">
|
||||
<KBarResults items={results} onRender={renderResults} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KBarCommandPalette(): JSX.Element {
|
||||
return (
|
||||
<KBarPortal>
|
||||
<KBarPositioner className="kbar-command-palette__positioner">
|
||||
<KBarAnimator className="kbar-command-palette__animator">
|
||||
<div className="kbar-command-palette__card">
|
||||
<KBarSearch
|
||||
className="kbar-command-palette__search"
|
||||
placeholder="Search or type a command..."
|
||||
/>
|
||||
<Results />
|
||||
</div>
|
||||
</KBarAnimator>
|
||||
</KBarPositioner>
|
||||
</KBarPortal>
|
||||
);
|
||||
}
|
||||
|
||||
export default KBarCommandPalette;
|
||||
@@ -0,0 +1,16 @@
|
||||
.overflow-input {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.overflow-input-mirror {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
white-space: pre;
|
||||
pointer-events: none;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import OverflowInputToolTip from './OverflowInputToolTip';
|
||||
|
||||
const TOOLTIP_INNER_SELECTOR = '.ant-tooltip-inner';
|
||||
// Utility to mock overflow behaviour on inputs / elements.
|
||||
// Stubs HTMLElement.prototype.clientWidth, scrollWidth and offsetWidth used by component.
|
||||
function mockOverflow(clientWidth: number, scrollWidth: number): void {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: clientWidth,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
|
||||
configurable: true,
|
||||
value: scrollWidth,
|
||||
});
|
||||
// mirror.offsetWidth is used to compute mirrorWidth = offsetWidth + 24.
|
||||
// Use clientWidth so the mirror measurement aligns with the mocked client width in tests.
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
||||
configurable: true,
|
||||
value: clientWidth,
|
||||
});
|
||||
}
|
||||
|
||||
function queryTooltipInner(): HTMLElement | null {
|
||||
// find element that has role="tooltip" (could be the inner itself)
|
||||
const tooltip = document.querySelector<HTMLElement>('[role="tooltip"]');
|
||||
if (!tooltip) return document.querySelector(TOOLTIP_INNER_SELECTOR);
|
||||
|
||||
// if the role element is already the inner, return it; otherwise return its descendant
|
||||
if (tooltip.classList.contains('ant-tooltip-inner')) return tooltip;
|
||||
return (
|
||||
(tooltip.querySelector(TOOLTIP_INNER_SELECTOR) as HTMLElement) ??
|
||||
document.querySelector(TOOLTIP_INNER_SELECTOR)
|
||||
);
|
||||
}
|
||||
|
||||
describe('OverflowInputToolTip', () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('shows tooltip when content overflows and input is clamped at maxAutoWidth', async () => {
|
||||
mockOverflow(150, 250); // clientWidth >= maxAutoWidth (150), scrollWidth > clientWidth
|
||||
|
||||
render(<OverflowInputToolTip value="Very long overflowing text" />);
|
||||
|
||||
await userEvent.hover(screen.getByRole('textbox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryTooltipInner()).not.toBeNull();
|
||||
});
|
||||
|
||||
const tooltipInner = queryTooltipInner();
|
||||
if (!tooltipInner) throw new Error('Tooltip inner not found');
|
||||
expect(
|
||||
within(tooltipInner).getByText('Very long overflowing text'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does NOT show tooltip when content does not overflow', async () => {
|
||||
mockOverflow(150, 100); // content fits (scrollWidth <= clientWidth)
|
||||
|
||||
render(<OverflowInputToolTip value="Short text" />);
|
||||
|
||||
await userEvent.hover(screen.getByRole('textbox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryTooltipInner()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('does NOT show tooltip when content overflows but input is NOT at maxAutoWidth', async () => {
|
||||
mockOverflow(100, 250); // clientWidth < maxAutoWidth (150), scrollWidth > clientWidth
|
||||
|
||||
render(<OverflowInputToolTip value="Long but input not clamped" />);
|
||||
|
||||
await userEvent.hover(screen.getByRole('textbox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryTooltipInner()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('uncontrolled input allows typing', async () => {
|
||||
render(<OverflowInputToolTip defaultValue="Init" />);
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
await userEvent.type(input, 'ABC');
|
||||
|
||||
expect(input).toHaveValue('InitABC');
|
||||
});
|
||||
|
||||
test('disabled input never shows tooltip even if overflowing', async () => {
|
||||
mockOverflow(150, 300);
|
||||
|
||||
render(<OverflowInputToolTip value="Overflowing!" disabled />);
|
||||
|
||||
await userEvent.hover(screen.getByRole('textbox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryTooltipInner()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders mirror span and input correctly (structural assertions instead of snapshot)', () => {
|
||||
const { container } = render(<OverflowInputToolTip value="Snapshot" />);
|
||||
const mirror = container.querySelector('.overflow-input-mirror');
|
||||
const input = container.querySelector('input') as HTMLInputElement | null;
|
||||
|
||||
expect(mirror).toBeTruthy();
|
||||
expect(mirror?.textContent).toBe('Snapshot');
|
||||
expect(input).toBeTruthy();
|
||||
expect(input?.value).toBe('Snapshot');
|
||||
|
||||
// width should be set inline (component calculates width on mount)
|
||||
expect(input?.getAttribute('style')).toContain('width:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable react/require-default-props */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
|
||||
import './OverflowInputToolTip.scss';
|
||||
|
||||
import { Input, InputProps, InputRef, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface OverflowTooltipInputProps extends InputProps {
|
||||
tooltipPlacement?: 'top' | 'bottom' | 'left' | 'right';
|
||||
minAutoWidth?: number;
|
||||
maxAutoWidth?: number;
|
||||
}
|
||||
|
||||
function OverflowInputToolTip({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
disabled = false,
|
||||
tooltipPlacement = 'top',
|
||||
className,
|
||||
minAutoWidth = 70,
|
||||
maxAutoWidth = 150,
|
||||
...rest
|
||||
}: OverflowTooltipInputProps): JSX.Element {
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const mirrorRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [isOverflowing, setIsOverflowing] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current?.input;
|
||||
const mirror = mirrorRef.current;
|
||||
if (!input || !mirror) {
|
||||
setIsOverflowing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
mirror.textContent = String(value ?? '') || ' ';
|
||||
const mirrorWidth = mirror.offsetWidth + 24;
|
||||
const newWidth = Math.min(maxAutoWidth, Math.max(minAutoWidth, mirrorWidth));
|
||||
input.style.width = `${newWidth}px`;
|
||||
|
||||
// consider clamped when mirrorWidth reaches maxAutoWidth (allow -5px tolerance)
|
||||
const isClamped = mirrorWidth >= maxAutoWidth - 5;
|
||||
const overflow = input.scrollWidth > input.clientWidth && isClamped;
|
||||
|
||||
setIsOverflowing(overflow);
|
||||
}, [value, disabled, minAutoWidth, maxAutoWidth]);
|
||||
|
||||
const tooltipTitle = !disabled && isOverflowing ? String(value ?? '') : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={mirrorRef} aria-hidden className="overflow-input-mirror" />
|
||||
<Tooltip title={tooltipTitle} placement={tooltipPlacement}>
|
||||
<Input
|
||||
{...rest}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
className={cx('overflow-input', className)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
OverflowInputToolTip.displayName = 'OverflowInputToolTip';
|
||||
|
||||
export default OverflowInputToolTip;
|
||||
3
frontend/src/components/OverflowInputToolTip/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import OverflowInputToolTip from './OverflowInputToolTip';
|
||||
|
||||
export default OverflowInputToolTip;
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* src/components/cmdKPalette/__test__/cmdkPalette.test.tsx
|
||||
*/
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
// ---- Mocks (must run BEFORE importing the component) ----
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { CmdKPalette } from '../cmdKPalette';
|
||||
|
||||
const HOME_LABEL = 'Go to Home';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// restore
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete (HTMLElement.prototype as any).scrollIntoView;
|
||||
});
|
||||
|
||||
// mock history.push / replace / go / location
|
||||
jest.mock('lib/history', () => {
|
||||
const location = { pathname: '/', search: '', hash: '' };
|
||||
|
||||
const stack: { pathname: string; search: string }[] = [
|
||||
{ pathname: '/', search: '' },
|
||||
];
|
||||
|
||||
const push = jest.fn((path: string) => {
|
||||
const [rawPath, rawQuery] = path.split('?');
|
||||
const pathname = rawPath || '/';
|
||||
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
|
||||
|
||||
location.pathname = pathname;
|
||||
location.search = search;
|
||||
|
||||
stack.push({ pathname, search });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const replace = jest.fn((path: string) => {
|
||||
const [rawPath, rawQuery] = path.split('?');
|
||||
const pathname = rawPath || '/';
|
||||
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
|
||||
|
||||
location.pathname = pathname;
|
||||
location.search = search;
|
||||
|
||||
if (stack.length > 0) {
|
||||
stack[stack.length - 1] = { pathname, search };
|
||||
} else {
|
||||
stack.push({ pathname, search });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const listen = jest.fn();
|
||||
const go = jest.fn((n: number) => {
|
||||
if (n < 0 && stack.length > 1) {
|
||||
stack.pop();
|
||||
}
|
||||
const top = stack[stack.length - 1] || { pathname: '/', search: '' };
|
||||
location.pathname = top.pathname;
|
||||
location.search = top.search;
|
||||
});
|
||||
|
||||
return {
|
||||
push,
|
||||
replace,
|
||||
listen,
|
||||
go,
|
||||
location,
|
||||
__stack: stack,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ResizeObserver for Jest/jsdom
|
||||
class ResizeObserver {
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
|
||||
observe() {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
|
||||
unobserve() {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
(global as any).ResizeObserver = ResizeObserver;
|
||||
|
||||
// mock cmdK provider hook (open state + setter)
|
||||
const mockSetOpen = jest.fn();
|
||||
jest.mock('providers/cmdKProvider', (): unknown => ({
|
||||
useCmdK: (): {
|
||||
open: boolean;
|
||||
setOpen: jest.Mock;
|
||||
openCmdK: jest.Mock;
|
||||
closeCmdK: jest.Mock;
|
||||
} => ({
|
||||
open: true,
|
||||
setOpen: mockSetOpen,
|
||||
openCmdK: jest.fn(),
|
||||
closeCmdK: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// mock notifications hook
|
||||
jest.mock('hooks/useNotifications', (): unknown => ({
|
||||
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
|
||||
}));
|
||||
|
||||
// mock theme hook
|
||||
jest.mock('hooks/useDarkMode', (): unknown => ({
|
||||
useThemeMode: (): {
|
||||
setAutoSwitch: jest.Mock;
|
||||
setTheme: jest.Mock;
|
||||
theme: string;
|
||||
} => ({
|
||||
setAutoSwitch: jest.fn(),
|
||||
setTheme: jest.fn(),
|
||||
theme: 'dark',
|
||||
}),
|
||||
}));
|
||||
|
||||
// mock updateUserPreference API and react-query mutation
|
||||
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());
|
||||
jest.mock('react-query', (): unknown => {
|
||||
const actual = jest.requireActual('react-query');
|
||||
return {
|
||||
...actual,
|
||||
useMutation: (): { mutate: jest.Mock } => ({ mutate: jest.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
// mock other side-effecty modules
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
jest.mock('api/browser/localstorage/set', () => jest.fn());
|
||||
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
|
||||
|
||||
// ---- Tests ----
|
||||
describe('CmdKPalette', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders navigation and settings groups and items', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
expect(screen.getByText('Go to Dashboards')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open Sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking a navigation item calls history.push with correct route', async () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const homeItem = screen.getByText(HOME_LABEL);
|
||||
await userEvent.click(homeItem);
|
||||
|
||||
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
|
||||
});
|
||||
|
||||
test('role-based filtering (basic smoke)', () => {
|
||||
render(<CmdKPalette userRole="VIEWER" />);
|
||||
|
||||
// VIEWER still sees basic navigation items
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keyboard shortcut opens palette via setOpen', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('items render with icons when provided', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const iconHolders = document.querySelectorAll('.cmd-item-icon');
|
||||
expect(iconHolders.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('closing the palette via handleInvoke sets open to false', async () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const dashItem = screen.getByText('Go to Dashboards');
|
||||
await userEvent.click(dashItem);
|
||||
|
||||
// last call from handleInvoke should set open to false
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
55
frontend/src/components/cmdKPalette/cmdKPalette.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
/* Overlay stays below content */
|
||||
[data-slot='dialog-overlay'] {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Dialog content always above overlay */
|
||||
[data-slot='dialog-content'] {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.cmdk-section-heading [cmdk-group-heading] {
|
||||
text-transform: uppercase;
|
||||
color: var(--bg-slate-100);
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep scroll */
|
||||
.cmdk-list-scroll {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.cmdk-list-scroll::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Edge */
|
||||
}
|
||||
|
||||
.cmdk-list-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.cmdk-input-wrapper {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.cmdk-item-light:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-vanilla-200) !important;
|
||||
}
|
||||
|
||||
.cmdk-item-light[data-selected='true'] {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.cmdk-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[cmdk-item] svg {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.cmd-item-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
336
frontend/src/components/cmdKPalette/cmdKPalette.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import './cmdKPalette.scss';
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from '@signozhq/command';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { AxiosError } from 'axios';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import {
|
||||
BellDot,
|
||||
BugIcon,
|
||||
DraftingCompass,
|
||||
Expand,
|
||||
HardDrive,
|
||||
Home,
|
||||
LayoutGrid,
|
||||
ListMinus,
|
||||
ScrollText,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
|
||||
import { useAppContext } from '../../providers/App/App';
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
|
||||
type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortcut?: string[];
|
||||
keywords?: string;
|
||||
section?: string;
|
||||
icon?: React.ReactNode;
|
||||
roles?: UserRole[];
|
||||
perform: () => void;
|
||||
};
|
||||
|
||||
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
export function CmdKPalette({
|
||||
userRole,
|
||||
}: {
|
||||
userRole: UserRole;
|
||||
}): JSX.Element | null {
|
||||
const { open, setOpen } = useCmdK();
|
||||
|
||||
const { updateUserPreferenceInContext } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
const { setAutoSwitch, setTheme, theme } = useThemeMode();
|
||||
|
||||
const { mutate: updateUserPreferenceMutation } = useMutation(
|
||||
updateUserPreference,
|
||||
{
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// toggle palette with ⌘/Ctrl+K
|
||||
function handleGlobalCmdK(
|
||||
e: KeyboardEvent,
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
): void {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
const cmdKEffect = (): void | (() => void) => {
|
||||
const listener = (e: KeyboardEvent): void => {
|
||||
handleGlobalCmdK(e, setOpen);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', listener);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', listener);
|
||||
setOpen(false);
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(cmdKEffect, [setOpen]);
|
||||
|
||||
function handleThemeChange(value: string): void {
|
||||
logEvent('Account Settings: Theme Changed', { theme: value });
|
||||
if (value === 'auto') {
|
||||
setAutoSwitch(true);
|
||||
} else {
|
||||
setAutoSwitch(false);
|
||||
setTheme(value);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickHandler(key: string): void {
|
||||
history.push(key);
|
||||
}
|
||||
|
||||
function handleOpenSidebar(): void {
|
||||
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
|
||||
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: true };
|
||||
updateUserPreferenceInContext(save as UserPreference);
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCloseSidebar(): void {
|
||||
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
|
||||
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: false };
|
||||
updateUserPreferenceInContext(save as UserPreference);
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: false,
|
||||
});
|
||||
}
|
||||
|
||||
const actions: CmdAction[] = [
|
||||
{
|
||||
id: 'home',
|
||||
name: 'Go to Home',
|
||||
shortcut: ['shift + h'],
|
||||
keywords: 'home',
|
||||
section: 'Navigation',
|
||||
icon: <Home size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.HOME),
|
||||
},
|
||||
{
|
||||
id: 'dashboards',
|
||||
name: 'Go to Dashboards',
|
||||
shortcut: ['shift + d'],
|
||||
keywords: 'dashboards',
|
||||
section: 'Navigation',
|
||||
icon: <LayoutGrid size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.ALL_DASHBOARD),
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
name: 'Go to Services',
|
||||
shortcut: ['shift + s'],
|
||||
keywords: 'services monitoring',
|
||||
section: 'Navigation',
|
||||
icon: <HardDrive size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.APPLICATION),
|
||||
},
|
||||
{
|
||||
id: 'traces',
|
||||
name: 'Go to Traces',
|
||||
shortcut: ['shift + t'],
|
||||
keywords: 'traces',
|
||||
section: 'Navigation',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.TRACES_EXPLORER),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs',
|
||||
shortcut: ['shift + l'],
|
||||
keywords: 'logs',
|
||||
section: 'Navigation',
|
||||
icon: <ScrollText size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.LOGS),
|
||||
},
|
||||
{
|
||||
id: 'alerts',
|
||||
name: 'Go to Alerts',
|
||||
shortcut: ['shift + a'],
|
||||
keywords: 'alerts',
|
||||
section: 'Navigation',
|
||||
icon: <BellDot size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.LIST_ALL_ALERT),
|
||||
},
|
||||
{
|
||||
id: 'exceptions',
|
||||
name: 'Go to Exceptions',
|
||||
shortcut: ['shift + e'],
|
||||
keywords: 'exceptions errors',
|
||||
section: 'Navigation',
|
||||
icon: <BugIcon size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.ALL_ERROR),
|
||||
},
|
||||
{
|
||||
id: 'messaging-queues',
|
||||
name: 'Go to Messaging Queues',
|
||||
shortcut: ['shift + m'],
|
||||
keywords: 'messaging queues mq',
|
||||
section: 'Navigation',
|
||||
icon: <ListMinus size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW),
|
||||
},
|
||||
{
|
||||
id: 'my-settings',
|
||||
name: 'Go to Account Settings',
|
||||
keywords: 'account settings',
|
||||
section: 'Navigation',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.MY_SETTINGS),
|
||||
},
|
||||
|
||||
// Settings
|
||||
{
|
||||
id: 'open-sidebar',
|
||||
name: 'Open Sidebar',
|
||||
keywords: 'sidebar navigation menu expand',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleOpenSidebar(),
|
||||
},
|
||||
{
|
||||
id: 'collapse-sidebar',
|
||||
name: 'Collapse Sidebar',
|
||||
keywords: 'sidebar navigation menu collapse',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleCloseSidebar(),
|
||||
},
|
||||
{
|
||||
id: 'dark-mode',
|
||||
name: 'Switch to Dark Mode',
|
||||
keywords: 'theme dark mode appearance',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.DARK),
|
||||
},
|
||||
{
|
||||
id: 'light-mode',
|
||||
name: 'Switch to Light Mode [Beta]',
|
||||
keywords: 'theme light mode appearance',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.LIGHT),
|
||||
},
|
||||
{
|
||||
id: 'system-theme',
|
||||
name: 'Switch to System Theme',
|
||||
keywords: 'system theme appearance',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.SYSTEM),
|
||||
},
|
||||
];
|
||||
|
||||
// RBAC filter: show action if no roles set OR current user role is included
|
||||
const permitted = actions.filter(
|
||||
(a) => !a.roles || a.roles.includes(userRole),
|
||||
);
|
||||
|
||||
// group permitted actions by section
|
||||
const grouped: [string, CmdAction[]][] = ((): [string, CmdAction[]][] => {
|
||||
const map = new Map<string, CmdAction[]>();
|
||||
|
||||
permitted.forEach((a) => {
|
||||
const section = a.section ?? 'Other';
|
||||
const existing = map.get(section);
|
||||
|
||||
if (existing) {
|
||||
existing.push(a);
|
||||
} else {
|
||||
map.set(section, [a]);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.entries());
|
||||
})();
|
||||
|
||||
const handleInvoke = (action: CmdAction): void => {
|
||||
try {
|
||||
action.perform();
|
||||
} catch (e) {
|
||||
console.error('Error invoking action', e);
|
||||
} finally {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
|
||||
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
|
||||
<CommandList className="cmdk-list-scroll">
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
{grouped.map(([section, items]) => (
|
||||
<CommandGroup
|
||||
key={section}
|
||||
heading={section}
|
||||
className="cmdk-section-heading"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<CommandItem
|
||||
key={it.id}
|
||||
onSelect={(): void => handleInvoke(it)}
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
>
|
||||
<span className="cmd-item-icon">{it.icon}</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export const REACT_QUERY_KEY = {
|
||||
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
|
||||
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
|
||||
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
|
||||
GET_ALL_LICENCES: 'GET_ALL_LICENCES',
|
||||
GET_QUERY_RANGE: 'GET_QUERY_RANGE',
|
||||
GET_ALL_DASHBOARDS: 'GET_ALL_DASHBOARDS',
|
||||
|
||||
@@ -81,6 +81,7 @@ const ROUTES = {
|
||||
METER_EXPLORER: '/meter/explorer',
|
||||
METER_EXPLORER_VIEWS: '/meter/explorer/views',
|
||||
HOME_PAGE: '/',
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -391,6 +391,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
|
||||
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
|
||||
const pageTitle = t(routeKey);
|
||||
|
||||
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
|
||||
|
||||
const renderFullScreen =
|
||||
pathname === ROUTES.GET_STARTED ||
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
@@ -399,7 +402,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING ||
|
||||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
|
||||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
|
||||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING;
|
||||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
|
||||
isPublicDashboard;
|
||||
|
||||
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);
|
||||
|
||||
|
||||
@@ -266,7 +266,10 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
<div className="custom-domain-settings-modal-error">
|
||||
{updateDomainError.status === 409 ? (
|
||||
<Alert
|
||||
message="You’ve already updated the custom domain once today. To make further changes, please contact our support team for assistance."
|
||||
message={
|
||||
(updateDomainError?.response?.data as { error?: string })?.error ||
|
||||
'You’ve already updated the custom domain once today. To make further changes, please contact our support team for assistance.'
|
||||
}
|
||||
type="warning"
|
||||
className="update-limit-reached-error"
|
||||
/>
|
||||
|
||||
@@ -138,9 +138,9 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography>{errorDetail.exceptionType}</Typography>
|
||||
<Typography>{errorDetail.exceptionMessage}</Typography>
|
||||
<div className="error-details-container">
|
||||
<Typography.Title level={4}>{errorDetail.exceptionType}</Typography.Title>
|
||||
<Typography.Text>{errorDetail.exceptionMessage}</Typography.Text>
|
||||
<Divider />
|
||||
|
||||
<EventContainer>
|
||||
@@ -200,7 +200,7 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
|
||||
<ResizeTable columns={columns} tableLayout="fixed" dataSource={data} />
|
||||
</Space>
|
||||
</EditorContainer>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.error-details-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
@@ -393,15 +393,21 @@ function ExplorerOptions({
|
||||
backwardCompatibleOptions = omit(options, 'version');
|
||||
}
|
||||
|
||||
// Use the correct default columns based on the current data source
|
||||
const defaultColumns =
|
||||
sourcepage === DataSource.TRACES
|
||||
? defaultTraceSelectedColumns
|
||||
: defaultLogsSelectedColumns;
|
||||
|
||||
if (extraData.selectColumns?.length) {
|
||||
handleOptionsChange({
|
||||
...backwardCompatibleOptions,
|
||||
selectColumns: extraData.selectColumns,
|
||||
});
|
||||
} else if (!isEqual(defaultTraceSelectedColumns, options.selectColumns)) {
|
||||
} else if (!isEqual(defaultColumns, options.selectColumns)) {
|
||||
handleOptionsChange({
|
||||
...backwardCompatibleOptions,
|
||||
selectColumns: defaultTraceSelectedColumns,
|
||||
selectColumns: defaultColumns,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -304,14 +304,19 @@ function WidgetHeader({
|
||||
data-testid="widget-header-search"
|
||||
/>
|
||||
)}
|
||||
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
|
||||
<MoreOutlined
|
||||
data-testid="widget-header-options"
|
||||
className={`widget-header-more-options ${
|
||||
parentHover ? 'widget-header-hover' : ''
|
||||
} ${globalSearchAvailable ? 'widget-header-more-options-visible' : ''}`}
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
{menu && Array.isArray(menu.items) && menu.items.length > 0 && (
|
||||
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
|
||||
<MoreOutlined
|
||||
data-testid="widget-header-options"
|
||||
className={`widget-header-more-options ${
|
||||
parentHover ? 'widget-header-hover' : ''
|
||||
} ${
|
||||
globalSearchAvailable ? 'widget-header-more-options-visible' : ''
|
||||
}`}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useCallback } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import { TitleWrapper } from './BodyTitleRenderer.styles';
|
||||
import { DROPDOWN_KEY } from './constant';
|
||||
@@ -24,6 +27,8 @@ function BodyTitleRenderer({
|
||||
value,
|
||||
}: BodyTitleRendererProps): JSX.Element {
|
||||
const { onAddToQuery } = useActiveLog();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const filterHandler = (isFilterIn: boolean) => (): void => {
|
||||
if (parentIsArray) {
|
||||
@@ -75,18 +80,53 @@ function BodyTitleRenderer({
|
||||
onClick: onClickHandler,
|
||||
};
|
||||
|
||||
const handleTextSelection = (e: React.MouseEvent): void => {
|
||||
// Prevent tree node click when user is trying to select text
|
||||
e.stopPropagation();
|
||||
};
|
||||
const handleNodeClick = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
// Prevent tree node expansion/collapse
|
||||
e.stopPropagation();
|
||||
const cleanedKey = removeObjectFromString(nodeKey);
|
||||
let copyText: string;
|
||||
|
||||
// Check if value is an object or array
|
||||
const isObject = typeof value === 'object' && value !== null;
|
||||
|
||||
if (isObject) {
|
||||
// For objects/arrays, stringify the entire structure
|
||||
copyText = `"${cleanedKey}": ${JSON.stringify(value, null, 2)}`;
|
||||
} else if (parentIsArray) {
|
||||
// For array elements, copy just the value
|
||||
copyText = `"${cleanedKey}": ${value}`;
|
||||
} else {
|
||||
// For primitive values, format as JSON key-value pair
|
||||
const valueStr = typeof value === 'string' ? `"${value}"` : String(value);
|
||||
copyText = `"${cleanedKey}": ${valueStr}`;
|
||||
}
|
||||
|
||||
setCopy(copyText);
|
||||
|
||||
if (copyText) {
|
||||
const notificationMessage = isObject
|
||||
? `${cleanedKey} object copied to clipboard`
|
||||
: `${cleanedKey} copied to clipboard`;
|
||||
|
||||
notifications.success({
|
||||
message: notificationMessage,
|
||||
key: notificationMessage,
|
||||
});
|
||||
}
|
||||
},
|
||||
[nodeKey, parentIsArray, setCopy, value, notifications],
|
||||
);
|
||||
|
||||
return (
|
||||
<TitleWrapper onMouseDown={handleTextSelection}>
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</Dropdown>
|
||||
<TitleWrapper onClick={handleNodeClick}>
|
||||
{typeof value !== 'object' && (
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</Dropdown>
|
||||
)}
|
||||
{title.toString()}{' '}
|
||||
{!parentIsArray && (
|
||||
{!parentIsArray && typeof value !== 'object' && (
|
||||
<span>
|
||||
: <span style={{ color: orange[6] }}>{`${value}`}</span>
|
||||
</span>
|
||||
|
||||
@@ -202,9 +202,7 @@ export default function TableViewActions(
|
||||
if (record.field === 'body') {
|
||||
return (
|
||||
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
|
||||
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
|
||||
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
|
||||
</CopyClipboardHOC>
|
||||
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
|
||||
{!isListViewPanel && !RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
|
||||
<span className="action-btn">
|
||||
<Tooltip title="Filter for value">
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import BodyTitleRenderer from '../BodyTitleRenderer';
|
||||
|
||||
let mockSetCopy: jest.Mock;
|
||||
const mockNotification = jest.fn();
|
||||
|
||||
jest.mock('hooks/logs/useActiveLog', () => ({
|
||||
useActiveLog: (): any => ({
|
||||
onAddToQuery: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
useCopyToClipboard: (): any => {
|
||||
mockSetCopy = jest.fn();
|
||||
return [{ value: null }, mockSetCopy];
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({
|
||||
notifications: {
|
||||
success: mockNotification,
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
open: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('BodyTitleRenderer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should copy primitive value when node is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="name"
|
||||
nodeKey="user.name"
|
||||
value="John"
|
||||
parentIsArray={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('name'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('"user.name": "John"');
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('user.name'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy array element value when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="0"
|
||||
nodeKey="items[*].0"
|
||||
value="arrayElement"
|
||||
parentIsArray
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('0'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('"items[*].0": arrayElement');
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy entire object when object node is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testObject = { id: 123, active: true };
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="metadata"
|
||||
nodeKey="user.metadata"
|
||||
value={testObject}
|
||||
parentIsArray={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('metadata'));
|
||||
|
||||
await waitFor(() => {
|
||||
const callArg = mockSetCopy.mock.calls[0][0];
|
||||
expect(callArg).toContain('"user.metadata":');
|
||||
expect(callArg).toContain('"id": 123');
|
||||
expect(callArg).toContain('"active": true');
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('object copied'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,9 +39,17 @@ export const computeDataNode = (
|
||||
valueIsArray: boolean,
|
||||
value: unknown,
|
||||
nodeKey: string,
|
||||
parentIsArray: boolean,
|
||||
): DataNode => ({
|
||||
key: uniqueId(),
|
||||
title: `${key} ${valueIsArray ? '[...]' : ''}`,
|
||||
title: (
|
||||
<BodyTitleRenderer
|
||||
title={`${key} ${valueIsArray ? '[...]' : ''}`}
|
||||
nodeKey={nodeKey}
|
||||
value={value}
|
||||
parentIsArray={parentIsArray}
|
||||
/>
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
children: jsonToDataNodes(
|
||||
value as Record<string, unknown>,
|
||||
@@ -67,7 +75,7 @@ export function jsonToDataNodes(
|
||||
|
||||
if (parentIsArray) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return computeDataNode(key, valueIsArray, value, nodeKey);
|
||||
return computeDataNode(key, valueIsArray, value, nodeKey, parentIsArray);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -85,7 +93,7 @@ export function jsonToDataNodes(
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return computeDataNode(key, valueIsArray, value, nodeKey);
|
||||
return computeDataNode(key, valueIsArray, value, nodeKey, parentIsArray);
|
||||
}
|
||||
return {
|
||||
key: uniqueId(),
|
||||
|
||||
@@ -209,6 +209,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-series-view-container {
|
||||
.time-series-view-container-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getListQuery,
|
||||
getQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||
@@ -110,6 +111,8 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string>('');
|
||||
|
||||
const listQuery = useMemo(() => getListQuery(stagedQuery) || null, [
|
||||
stagedQuery,
|
||||
]);
|
||||
@@ -350,6 +353,10 @@ function LogsExplorerViewsContainer({
|
||||
orderBy,
|
||||
]);
|
||||
|
||||
const onUnitChangeHandler = useCallback((value: string): void => {
|
||||
setYAxisUnit(value);
|
||||
}, []);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!stagedQuery) return [];
|
||||
|
||||
@@ -457,15 +464,24 @@ function LogsExplorerViewsContainer({
|
||||
)}
|
||||
|
||||
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
|
||||
<TimeSeriesView
|
||||
isLoading={isLoading || isFetching}
|
||||
data={data}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
|
||||
dataSource={DataSource.LOGS}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
<div className="time-series-view-container">
|
||||
<div className="time-series-view-container-header">
|
||||
<BuilderUnitsFilter
|
||||
onChange={onUnitChangeHandler}
|
||||
yAxisUnit={yAxisUnit}
|
||||
/>
|
||||
</div>
|
||||
<TimeSeriesView
|
||||
isLoading={isLoading || isFetching}
|
||||
data={data}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
yAxisUnit={yAxisUnit}
|
||||
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
|
||||
dataSource={DataSource.LOGS}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
max-width: 80%;
|
||||
|
||||
.dashboard-btn {
|
||||
display: flex;
|
||||
@@ -130,7 +130,6 @@
|
||||
|
||||
.left-section {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 45%;
|
||||
@@ -148,16 +147,17 @@
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 150% */
|
||||
letter-spacing: -0.08px;
|
||||
flex-shrink: 0;
|
||||
|
||||
flex: 1;
|
||||
min-width: fit-content;
|
||||
max-width: 80%;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.public-dashboard-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-section {
|
||||
|
||||
@@ -17,7 +17,7 @@ function SettingsDrawer({ drawerTitle }: { drawerTitle: string }): JSX.Element {
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const onClose = (): void => {
|
||||
const handleClose = (): void => {
|
||||
setVisible(false);
|
||||
variableViewModeRef?.current?.();
|
||||
};
|
||||
@@ -38,7 +38,7 @@ function SettingsDrawer({ drawerTitle }: { drawerTitle: string }): JSX.Element {
|
||||
title={drawerTitle}
|
||||
placement="right"
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
open={visible}
|
||||
rootClassName="settings-container-root"
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
FileJson,
|
||||
FolderKanban,
|
||||
Fullscreen,
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
@@ -128,6 +130,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
false,
|
||||
);
|
||||
|
||||
const [isPublicDashboard, setIsPublicDashboard] = useState<boolean>(false);
|
||||
|
||||
let isAuthor = false;
|
||||
|
||||
if (selectedDashboard && user && user.email) {
|
||||
@@ -297,6 +301,38 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
safeNavigate(generatedUrl);
|
||||
}
|
||||
|
||||
const {
|
||||
data: publicDashboardResponse,
|
||||
// refetch: refetchPublicDashboardData,
|
||||
isLoading: isLoadingPublicDashboardData,
|
||||
isFetching: isFetchingPublicDashboardData,
|
||||
error: errorPublicDashboardData,
|
||||
isError: isErrorPublicDashboardData,
|
||||
} = useGetPublicDashboardMeta(selectedDashboard?.id || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoadingPublicDashboardData && !isFetchingPublicDashboardData) {
|
||||
if (isErrorPublicDashboardData) {
|
||||
const errorDetails = errorPublicDashboardData?.getErrorDetails();
|
||||
|
||||
if (errorDetails?.error?.code === 'public_dashboard_not_found') {
|
||||
setIsPublicDashboard(false);
|
||||
}
|
||||
} else {
|
||||
const publicDashboardData = publicDashboardResponse?.data;
|
||||
if (publicDashboardData?.publicPath) {
|
||||
setIsPublicDashboard(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isLoadingPublicDashboardData,
|
||||
isFetchingPublicDashboardData,
|
||||
isErrorPublicDashboardData,
|
||||
errorPublicDashboardData,
|
||||
publicDashboardResponse?.data,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Card className="dashboard-description-container">
|
||||
<div className="dashboard-header">
|
||||
@@ -333,11 +369,21 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
className="dashboard-title"
|
||||
data-testid="dashboard-title"
|
||||
>
|
||||
{' '}
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
{isDashboardLocked && <LockKeyhole size={14} />}
|
||||
|
||||
{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 />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.settings-tabs {
|
||||
.ant-tabs-nav-list {
|
||||
width: 228px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
@@ -13,6 +12,10 @@
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.ant-tabs-tab:not(:last-child) {
|
||||
border-right: 1px solid var(--bg-slate-400) !important;
|
||||
}
|
||||
|
||||
.overview-btn {
|
||||
width: 114px;
|
||||
display: flex;
|
||||
@@ -27,6 +30,13 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.public-dashboard-btn {
|
||||
width: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
display: none;
|
||||
}
|
||||
@@ -41,6 +51,11 @@
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.public-dashboard-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +78,10 @@
|
||||
.variables-btn {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.public-dashboard-btn {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
.public-dashboard-setting-container {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
padding: 16px !important;
|
||||
|
||||
.public-dashboard-setting-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.public-dashboard-setting-content-title {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.timerange-enabled-checkbox {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.default-time-range-select {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.default-time-range-select-label {
|
||||
margin-bottom: 4px;
|
||||
|
||||
.default-time-range-select-label-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
.ant-select-selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.list-item-image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.public-dashboard-url {
|
||||
.url-label-container {
|
||||
margin-bottom: 4px;
|
||||
|
||||
.url-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.url-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
border-radius: 4px;
|
||||
padding: 0px 4px;
|
||||
|
||||
.url-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.default-time-range-select-dropdown {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.public-dashboard-setting-callout {
|
||||
margin-top: 12px;
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 10%, transparent);
|
||||
padding: 12px 8px;
|
||||
border-radius: 3px;
|
||||
|
||||
.public-dashboard-setting-callout-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-robin-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.public-dashboard-setting-actions {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.public-dashboard-setting-container {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.public-dashboard-setting-content {
|
||||
.default-time-range-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
.public-dashboard-url {
|
||||
.url-container {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { fireEvent, within } from '@testing-library/react';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import {
|
||||
publishedPublicDashboardMeta,
|
||||
unpublishedPublicDashboardMeta,
|
||||
} from 'mocks-server/__mockdata__/publicDashboard';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import PublicDashboardSetting from '../index';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('providers/Dashboard/Dashboard');
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: jest.fn(),
|
||||
}));
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUseDashboard = jest.mocked(useDashboard);
|
||||
const mockUseCopyToClipboard = jest.mocked(useCopyToClipboard);
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
// Test constants
|
||||
const MOCK_DASHBOARD_ID = 'test-dashboard-id';
|
||||
const MOCK_PUBLIC_PATH = '/public/dashboard/test-dashboard-id';
|
||||
const DEFAULT_TIME_RANGE = '30m';
|
||||
const DASHBOARD_VARIABLES_WARNING =
|
||||
"Dashboard variables won't work in public dashboards";
|
||||
|
||||
// Use wildcard pattern to match both relative and absolute URLs in MSW
|
||||
const publicDashboardURL = `*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`;
|
||||
|
||||
const mockSelectedDashboard = {
|
||||
id: MOCK_DASHBOARD_ID,
|
||||
data: {
|
||||
title: 'Test Dashboard',
|
||||
widgets: [],
|
||||
layout: [],
|
||||
panelMap: {},
|
||||
variables: {},
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
const mockSetCopyPublicDashboardURL = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock window.open
|
||||
window.open = jest.fn();
|
||||
|
||||
// Mock useDashboard
|
||||
mockUseDashboard.mockReturnValue(({
|
||||
selectedDashboard: mockSelectedDashboard,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
|
||||
// Mock useCopyToClipboard
|
||||
mockUseCopyToClipboard.mockReturnValue(([
|
||||
undefined,
|
||||
mockSetCopyPublicDashboardURL,
|
||||
] as unknown) as ReturnType<typeof useCopyToClipboard>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PublicDashboardSetting', () => {
|
||||
describe('Unpublished Dashboard', () => {
|
||||
it('Unpublished dashboard should be handled correctly', async () => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(StatusCodes.NOT_FOUND),
|
||||
ctx.json(unpublishedPublicDashboardMeta),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/This dashboard is private. Publish it to make it accessible to anyone with the link./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: /enable time range/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/default time range/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(new RegExp(DASHBOARD_VARIABLES_WARNING, 'i')),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /publish dashboard/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Published Dashboard', () => {
|
||||
it('Published dashboard should be handled correctly', async () => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/This dashboard is publicly accessible. Anyone with the link can view it./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: /enable time range/i }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/default time range/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Public Dashboard URL/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /update published dashboard/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /unpublish dashboard/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Time Range Settings', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should toggle time range enabled when checkbox is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
// Wait for checkbox to be rendered and verify initial state
|
||||
const checkbox = await screen.findByRole('checkbox', {
|
||||
name: /enable time range/i,
|
||||
});
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
await user.click(checkbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update default time range when select value changes', async () => {
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
const selectContainer = await screen.findByTestId(
|
||||
'default-time-range-select-dropdown',
|
||||
);
|
||||
|
||||
const combobox = within(selectContainer).getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(combobox);
|
||||
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
const option = await screen.findByText(/Last 1 hour/i, {
|
||||
selector: '.ant-select-item-option-content',
|
||||
});
|
||||
fireEvent.click(option);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(selectContainer).getByText(/Last 1 hour/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Create Public Dashboard', () => {
|
||||
it('should call create API when publish button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let createApiCalled = false;
|
||||
|
||||
server.use(
|
||||
rest.get(publicDashboardURL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(StatusCodes.OK),
|
||||
ctx.json({
|
||||
data: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
publicPath: '',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(publicDashboardURL, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
createApiCalled = true;
|
||||
expect(body).toEqual({
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
});
|
||||
return res(
|
||||
ctx.status(StatusCodes.CREATED),
|
||||
ctx.json({
|
||||
data: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
publicPath: MOCK_PUBLIC_PATH,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
// Find and click publish button
|
||||
const publishButton = await screen.findByRole('button', {
|
||||
name: /publish dashboard/i,
|
||||
});
|
||||
await user.click(publishButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createApiCalled).toBe(true);
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Public dashboard created successfully',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Public Dashboard', () => {
|
||||
it('should call update API when update button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let updateApiCalled = false;
|
||||
let capturedRequestBody: {
|
||||
timeRangeEnabled: boolean;
|
||||
defaultTimeRange: string;
|
||||
} | null = null;
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
|
||||
),
|
||||
rest.put(publicDashboardURL, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
updateApiCalled = true;
|
||||
capturedRequestBody = body;
|
||||
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
// Wait for API response and component update
|
||||
const updateButton = await screen.findByRole(
|
||||
'button',
|
||||
{ name: /update published dashboard/i },
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await user.click(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateApiCalled).toBe(true);
|
||||
expect(capturedRequestBody).toEqual({
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
});
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Public dashboard updated successfully',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Revoke Public Dashboard Access', () => {
|
||||
it('should call revoke API when unpublish button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let revokeApiCalled = false;
|
||||
let capturedDashboardId: string | null = null;
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
|
||||
),
|
||||
rest.delete(publicDashboardURL, (req, res, ctx) => {
|
||||
revokeApiCalled = true;
|
||||
// Extract dashboard ID from URL: /api/v1/dashboards/{id}/public
|
||||
const urlMatch = req.url.pathname.match(
|
||||
/\/api\/v1\/dashboards\/([^/]+)\/public/,
|
||||
);
|
||||
capturedDashboardId = urlMatch ? urlMatch[1] : null;
|
||||
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
// Wait for API response and component update
|
||||
const unpublishButton = await screen.findByRole(
|
||||
'button',
|
||||
{ name: /unpublish dashboard/i },
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await user.click(unpublishButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(revokeApiCalled).toBe(true);
|
||||
expect(capturedDashboardId).toBe(MOCK_DASHBOARD_ID);
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Dashboard unpublished successfully',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import './PublicDashboard.styles.scss';
|
||||
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Button, Select, Typography } from 'antd';
|
||||
import createPublicDashboardAPI from 'api/dashboard/public/createPublicDashboard';
|
||||
import revokePublicDashboardAccessAPI from 'api/dashboard/public/revokePublicDashboardAccess';
|
||||
import updatePublicDashboardAPI from 'api/dashboard/public/updatePublicDashboard';
|
||||
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
|
||||
import { Copy, ExternalLink, Globe, Info, Loader2, Trash } from 'lucide-react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
|
||||
|
||||
export const TIME_RANGE_PRESETS_OPTIONS = [
|
||||
{
|
||||
label: 'Last 5 minutes',
|
||||
value: '5m',
|
||||
},
|
||||
{
|
||||
label: 'Last 15 minutes',
|
||||
value: '15m',
|
||||
},
|
||||
{
|
||||
label: 'Last 30 minutes',
|
||||
value: '30m',
|
||||
},
|
||||
{
|
||||
label: 'Last 1 hour',
|
||||
value: '1h',
|
||||
},
|
||||
{
|
||||
label: 'Last 6 hours',
|
||||
value: '6h',
|
||||
},
|
||||
{
|
||||
label: 'Last 1 day',
|
||||
value: '24h',
|
||||
},
|
||||
];
|
||||
|
||||
function PublicDashboardSetting(): JSX.Element {
|
||||
const [publicDashboardData, setPublicDashboardData] = useState<
|
||||
PublicDashboardMetaProps | undefined
|
||||
>(undefined);
|
||||
const [timeRangeEnabled, setTimeRangeEnabled] = useState(true);
|
||||
const [defaultTimeRange, setDefaultTimeRange] = useState('30m');
|
||||
const [, setCopyPublicDashboardURL] = useCopyToClipboard();
|
||||
|
||||
const { selectedDashboard } = useDashboard();
|
||||
|
||||
const handleDefaultTimeRange = useCallback((value: string): void => {
|
||||
setDefaultTimeRange(value);
|
||||
}, []);
|
||||
|
||||
const handleTimeRangeEnabled = useCallback((): void => {
|
||||
setTimeRangeEnabled((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: publicDashboardResponse,
|
||||
isLoading: isLoadingPublicDashboard,
|
||||
isFetching: isFetchingPublicDashboard,
|
||||
refetch: refetchPublicDashboard,
|
||||
error: errorPublicDashboard,
|
||||
} = useGetPublicDashboardMeta(selectedDashboard?.id || '');
|
||||
|
||||
const isPublicDashboardEnabled = !!publicDashboardData?.publicPath;
|
||||
|
||||
useEffect(() => {
|
||||
if (publicDashboardResponse?.data) {
|
||||
setPublicDashboardData(publicDashboardResponse?.data);
|
||||
}
|
||||
|
||||
if (errorPublicDashboard) {
|
||||
console.error('Error getting public dashboard', errorPublicDashboard);
|
||||
setPublicDashboardData(undefined);
|
||||
setTimeRangeEnabled(true);
|
||||
setDefaultTimeRange('30m');
|
||||
}
|
||||
}, [publicDashboardResponse, errorPublicDashboard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (publicDashboardResponse?.data) {
|
||||
setTimeRangeEnabled(
|
||||
publicDashboardResponse?.data?.timeRangeEnabled || false,
|
||||
);
|
||||
setDefaultTimeRange(
|
||||
publicDashboardResponse?.data?.defaultTimeRange || '30m',
|
||||
);
|
||||
}
|
||||
}, [publicDashboardResponse]);
|
||||
|
||||
const {
|
||||
mutate: createPublicDashboard,
|
||||
isLoading: isLoadingCreatePublicDashboard,
|
||||
data: createPublicDashboardResponse,
|
||||
} = useMutation(createPublicDashboardAPI, {
|
||||
onSuccess: () => {
|
||||
toast.success('Public dashboard created successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to create public dashboard');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: updatePublicDashboard,
|
||||
isLoading: isLoadingUpdatePublicDashboard,
|
||||
data: updatePublicDashboardResponse,
|
||||
} = useMutation(updatePublicDashboardAPI, {
|
||||
onSuccess: () => {
|
||||
toast.success('Public dashboard updated successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update public dashboard');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: revokePublicDashboardAccess,
|
||||
isLoading: isLoadingRevokePublicDashboardAccess,
|
||||
data: revokePublicDashboardAccessResponse,
|
||||
} = useMutation(revokePublicDashboardAccessAPI, {
|
||||
onSuccess: () => {
|
||||
toast.success('Dashboard unpublished successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to unpublish dashboard');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreatePublicDashboard = (): void => {
|
||||
if (!selectedDashboard) return;
|
||||
|
||||
createPublicDashboard({
|
||||
dashboardId: selectedDashboard.id,
|
||||
timeRangeEnabled,
|
||||
defaultTimeRange,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdatePublicDashboard = (): void => {
|
||||
if (!selectedDashboard) return;
|
||||
|
||||
updatePublicDashboard({
|
||||
dashboardId: selectedDashboard.id,
|
||||
timeRangeEnabled,
|
||||
defaultTimeRange,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevokePublicDashboardAccess = (): void => {
|
||||
if (!selectedDashboard) return;
|
||||
|
||||
revokePublicDashboardAccess({
|
||||
id: selectedDashboard.id,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(createPublicDashboardResponse &&
|
||||
createPublicDashboardResponse.httpStatusCode === 201) ||
|
||||
(updatePublicDashboardResponse &&
|
||||
updatePublicDashboardResponse.httpStatusCode === 204) ||
|
||||
(revokePublicDashboardAccessResponse &&
|
||||
revokePublicDashboardAccessResponse.httpStatusCode === 204)
|
||||
) {
|
||||
refetchPublicDashboard();
|
||||
}
|
||||
}, [
|
||||
createPublicDashboardResponse,
|
||||
updatePublicDashboardResponse,
|
||||
revokePublicDashboardAccessResponse,
|
||||
refetchPublicDashboard,
|
||||
]);
|
||||
|
||||
const handleCopyPublicDashboardURL = (): void => {
|
||||
if (!publicDashboardResponse?.data?.publicPath) return;
|
||||
|
||||
try {
|
||||
setCopyPublicDashboardURL(
|
||||
`${window.location.origin}${publicDashboardResponse?.data?.publicPath}`,
|
||||
);
|
||||
toast.success('Copied Public Dashboard URL successfully');
|
||||
} catch (error) {
|
||||
console.error('Error copying public dashboard URL', error);
|
||||
}
|
||||
};
|
||||
|
||||
const publicDashboardURL = useMemo(
|
||||
() => `${window.location.origin}${publicDashboardResponse?.data?.publicPath}`,
|
||||
[publicDashboardResponse],
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
isLoadingCreatePublicDashboard ||
|
||||
isLoadingUpdatePublicDashboard ||
|
||||
isLoadingRevokePublicDashboardAccess ||
|
||||
isLoadingPublicDashboard;
|
||||
|
||||
return (
|
||||
<div className="public-dashboard-setting-container">
|
||||
<div className="public-dashboard-setting-content">
|
||||
<Typography.Title
|
||||
level={5}
|
||||
className="public-dashboard-setting-content-title"
|
||||
>
|
||||
{isPublicDashboardEnabled
|
||||
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
|
||||
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
|
||||
</Typography.Title>
|
||||
|
||||
<div className="timerange-enabled-checkbox">
|
||||
<Checkbox
|
||||
id="enable-time-range"
|
||||
checked={timeRangeEnabled}
|
||||
onCheckedChange={handleTimeRangeEnabled}
|
||||
labelName="Enable time range"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="default-time-range-select">
|
||||
<div className="default-time-range-select-label">
|
||||
<Typography.Text className="default-time-range-select-label-text">
|
||||
Default time range
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
placeholder="Select default time range"
|
||||
options={TIME_RANGE_PRESETS_OPTIONS}
|
||||
value={defaultTimeRange}
|
||||
onChange={handleDefaultTimeRange}
|
||||
data-testid="default-time-range-select-dropdown"
|
||||
className="default-time-range-select-dropdown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPublicDashboardEnabled && (
|
||||
<div className="public-dashboard-url">
|
||||
<div className="url-label-container">
|
||||
<Typography.Text className="url-label">
|
||||
Public Dashboard URL
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="url-container">
|
||||
<Typography.Text className="url-text">
|
||||
{publicDashboardURL}
|
||||
</Typography.Text>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
className="url-copy-btn periscope-btn ghost"
|
||||
icon={<Copy size={12} />}
|
||||
onClick={handleCopyPublicDashboardURL}
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
className="periscope-btn ghost"
|
||||
icon={<ExternalLink size={12} />}
|
||||
onClick={(): void => {
|
||||
if (publicDashboardURL) {
|
||||
window.open(publicDashboardURL, '_blank');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="public-dashboard-setting-callout">
|
||||
<Typography.Text className="public-dashboard-setting-callout-text">
|
||||
<Info size={12} className="public-dashboard-setting-callout-icon" />{' '}
|
||||
Dashboard variables won't work in public dashboards
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="public-dashboard-setting-actions">
|
||||
{!isPublicDashboardEnabled ? (
|
||||
<Button
|
||||
type="primary"
|
||||
className="create-public-dashboard-btn periscope-btn primary"
|
||||
disabled={isLoading}
|
||||
onClick={handleCreatePublicDashboard}
|
||||
loading={
|
||||
isLoadingCreatePublicDashboard ||
|
||||
isFetchingPublicDashboard ||
|
||||
isLoadingPublicDashboard
|
||||
}
|
||||
icon={
|
||||
isLoadingCreatePublicDashboard ||
|
||||
isFetchingPublicDashboard ||
|
||||
isLoadingPublicDashboard ? (
|
||||
<Loader2 className="animate-spin" size={14} />
|
||||
) : (
|
||||
<Globe size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Publish dashboard
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
disabled={isLoading}
|
||||
onClick={handleRevokePublicDashboardAccess}
|
||||
loading={isLoadingRevokePublicDashboardAccess}
|
||||
icon={<Trash size={14} />}
|
||||
>
|
||||
Unpublish dashboard
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="create-public-dashboard-btn periscope-btn primary"
|
||||
disabled={isLoading}
|
||||
onClick={handleUpdatePublicDashboard}
|
||||
loading={isLoadingUpdatePublicDashboard}
|
||||
icon={<Globe size={14} />}
|
||||
>
|
||||
Update published dashboard
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardSetting;
|
||||
@@ -1,9 +1,10 @@
|
||||
import './DashboardSettingsContent.styles.scss';
|
||||
|
||||
import { Button, Tabs } from 'antd';
|
||||
import { Braces, Table } from 'lucide-react';
|
||||
import { Braces, Globe, Table } from 'lucide-react';
|
||||
|
||||
import GeneralDashboardSettings from './General';
|
||||
import PublicDashboardSetting from './PublicDashboard';
|
||||
import VariablesSetting from './Variables';
|
||||
|
||||
function DashboardSettingsContent({
|
||||
@@ -30,6 +31,19 @@ function DashboardSettingsContent({
|
||||
key: 'variables',
|
||||
children: <VariablesSetting variableViewModeRef={variableViewModeRef} />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Globe size={14} />}
|
||||
className="public-dashboard-btn"
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
),
|
||||
key: 'public-dashboard',
|
||||
children: <PublicDashboardSetting />,
|
||||
},
|
||||
];
|
||||
|
||||
return <Tabs items={items} animated className="settings-tabs" />;
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Checkbox, Empty } from 'antd';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { EXCLUDED_COLUMNS } from 'container/OptionsMenu/constants';
|
||||
import { QueryKeySuggestionsResponseProps } from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
type ExplorerAttributeColumnsProps = {
|
||||
isLoading: boolean;
|
||||
data: any;
|
||||
data: AxiosResponse<QueryKeySuggestionsResponseProps> | undefined;
|
||||
searchText: string;
|
||||
isAttributeKeySelected: (key: string) => boolean;
|
||||
handleCheckboxChange: (key: string) => void;
|
||||
dataSource: DataSource;
|
||||
};
|
||||
|
||||
function ExplorerAttributeColumns({
|
||||
@@ -15,6 +20,7 @@ function ExplorerAttributeColumns({
|
||||
searchText,
|
||||
isAttributeKeySelected,
|
||||
handleCheckboxChange,
|
||||
dataSource,
|
||||
}: ExplorerAttributeColumnsProps): JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -27,8 +33,10 @@ function ExplorerAttributeColumns({
|
||||
const filteredAttributeKeys =
|
||||
Object.values(data?.data?.data?.keys || {})
|
||||
?.flat()
|
||||
?.filter((attributeKey: any) =>
|
||||
attributeKey.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||
?.filter(
|
||||
(attributeKey) =>
|
||||
attributeKey.name.toLowerCase().includes(searchText.toLowerCase()) &&
|
||||
!EXCLUDED_COLUMNS[dataSource].includes(attributeKey.name),
|
||||
) || [];
|
||||
if (filteredAttributeKeys.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -183,6 +183,7 @@ function ExplorerColumnsRenderer({
|
||||
searchText={searchText}
|
||||
isAttributeKeySelected={isAttributeKeySelected}
|
||||
handleCheckboxChange={handleCheckboxChange}
|
||||
dataSource={initialDataSource}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -450,4 +450,58 @@ describe('ExplorerColumnsRenderer', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show isRoot or isEntryPoint in add column dropdown (traces, dashboard table panel)', async () => {
|
||||
(useQueryBuilder as jest.Mock).mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: [
|
||||
{ name: 'isRoot', dataType: 'bool', type: '' },
|
||||
{ name: 'isEntryPoint', dataType: 'bool', type: '' },
|
||||
{ name: 'duration', dataType: 'number', type: '' },
|
||||
{ name: 'serviceName', dataType: 'string', type: '' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<ExplorerColumnsRenderer
|
||||
selectedLogFields={[]}
|
||||
setSelectedLogFields={mockSetSelectedLogFields}
|
||||
selectedTracesFields={[]}
|
||||
setSelectedTracesFields={mockSetSelectedTracesFields}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('add-columns-button'));
|
||||
|
||||
// Visible columns should appear
|
||||
expect(screen.getByText('duration')).toBeInTheDocument();
|
||||
expect(screen.getByText('serviceName')).toBeInTheDocument();
|
||||
|
||||
// Hidden columns should NOT appear
|
||||
expect(screen.queryByText('isRoot')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('isEntryPoint')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { useQueries } from 'react-query';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import useOptionsMenu from '../useOptionsMenu';
|
||||
|
||||
// Mock all dependencies
|
||||
jest.mock('hooks/useNotifications');
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider');
|
||||
jest.mock('hooks/useUrlQueryData');
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useOptionsMenu', () => {
|
||||
const mockNotifications = { error: jest.fn(), success: jest.fn() };
|
||||
const mockUpdateColumns = jest.fn();
|
||||
const mockUpdateFormatting = jest.fn();
|
||||
const mockRedirectWithQuery = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
(useNotifications as jest.Mock).mockReturnValue({
|
||||
notifications: mockNotifications,
|
||||
});
|
||||
|
||||
(usePreferenceContext as jest.Mock).mockReturnValue({
|
||||
traces: {
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: {
|
||||
format: 'raw',
|
||||
maxLines: 2,
|
||||
fontSize: 'small',
|
||||
},
|
||||
},
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
logs: {
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: {
|
||||
format: 'raw',
|
||||
maxLines: 2,
|
||||
fontSize: 'small',
|
||||
},
|
||||
},
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
});
|
||||
|
||||
(useUrlQueryData as jest.Mock).mockReturnValue({
|
||||
query: null,
|
||||
redirectWithQuery: mockRedirectWithQuery,
|
||||
});
|
||||
|
||||
(useQueries as jest.Mock).mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('does not show isRoot or isEntryPoint in column options when dataSource is TRACES', () => {
|
||||
// Mock the query key suggestions to return data including isRoot and isEntryPoint
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: [
|
||||
{
|
||||
name: 'isRoot',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'bool',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'isEntryPoint',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'bool',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'duration',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'serviceName',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
// Get the column options from the config
|
||||
const columnOptions = result.current.config.addColumn?.options ?? [];
|
||||
const optionNames = columnOptions.map((option) => option.label);
|
||||
|
||||
// isRoot and isEntryPoint should NOT be in the options
|
||||
expect(optionNames).not.toContain('isRoot');
|
||||
expect(optionNames).not.toContain('body');
|
||||
expect(optionNames).not.toContain('isEntryPoint');
|
||||
|
||||
// Other attributes should be present
|
||||
expect(optionNames).toContain('duration');
|
||||
expect(optionNames).toContain('serviceName');
|
||||
});
|
||||
|
||||
it('does not show body in column options when dataSource is METRICS', () => {
|
||||
// Mock the query key suggestions to return data including body
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: [
|
||||
{
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
signal: 'metrics',
|
||||
fieldDataType: 'int64',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
signal: 'metrics',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
// Get the column options from the config
|
||||
const columnOptions = result.current.config.addColumn?.options ?? [];
|
||||
const optionNames = columnOptions.map((option) => option.label);
|
||||
|
||||
// body should NOT be in the options
|
||||
expect(optionNames).not.toContain('body');
|
||||
|
||||
// Other attributes should be present
|
||||
expect(optionNames).toContain('status');
|
||||
expect(optionNames).toContain('value');
|
||||
});
|
||||
|
||||
it('does not show body in column options when dataSource is LOGS', () => {
|
||||
// Mock the query key suggestions to return data including body
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: [
|
||||
{
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'level',
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
fieldDataType: 'int64',
|
||||
fieldContext: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
// Get the column options from the config
|
||||
const columnOptions = result.current.config.addColumn?.options ?? [];
|
||||
const optionNames = columnOptions.map((option) => option.label);
|
||||
|
||||
// body should be in the options
|
||||
expect(optionNames).toContain('body');
|
||||
|
||||
// Other attributes should be present
|
||||
expect(optionNames).toContain('level');
|
||||
expect(optionNames).toContain('timestamp');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { FontSize, OptionsQuery } from './types';
|
||||
|
||||
@@ -11,6 +12,12 @@ export const defaultOptionsQuery: OptionsQuery = {
|
||||
fontSize: FontSize.SMALL,
|
||||
};
|
||||
|
||||
export const EXCLUDED_COLUMNS: Record<DataSource, string[]> = {
|
||||
[DataSource.TRACES]: ['body', 'isRoot', 'isEntryPoint'],
|
||||
[DataSource.METRICS]: ['body'],
|
||||
[DataSource.LOGS]: [],
|
||||
};
|
||||
|
||||
export const defaultLogsSelectedColumns: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: 'timestamp',
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultOptionsQuery,
|
||||
defaultTraceSelectedColumns,
|
||||
EXCLUDED_COLUMNS,
|
||||
URL_OPTIONS,
|
||||
} from './constants';
|
||||
import {
|
||||
@@ -267,8 +268,9 @@ const useOptionsMenu = ({
|
||||
|
||||
const optionsFromAttributeKeys = useMemo(() => {
|
||||
const filteredAttributeKeys = searchedAttributeKeys.filter((item) => {
|
||||
if (dataSource !== DataSource.LOGS) {
|
||||
return item.name !== 'body';
|
||||
const exclusions = EXCLUDED_COLUMNS[dataSource];
|
||||
if (exclusions) {
|
||||
return !exclusions.includes(item.name);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
146
frontend/src/container/PublicDashboardContainer/Panel.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import EmptyWidget from 'container/GridCardLayout/EmptyWidget';
|
||||
import WidgetGraphComponent from 'container/GridCardLayout/GridCard/WidgetGraphComponent';
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getGraphType } from 'utils/getGraphType';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
|
||||
function Panel({
|
||||
widget,
|
||||
index,
|
||||
dashboardId,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
widget: Widgets;
|
||||
index: number;
|
||||
dashboardId: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
const requestData: GetQueryResultsProps = useMemo(() => {
|
||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||
return {
|
||||
selectedTime: widget?.timePreferance,
|
||||
graphType: getGraphType(widget.panelTypes),
|
||||
query: updatedQuery,
|
||||
variables: {}, // we are not supporting variables in public dashboards
|
||||
fillGaps: widget.fillSpans,
|
||||
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
originalGraphType: widget.panelTypes,
|
||||
};
|
||||
}
|
||||
|
||||
updatedQuery.builder.queryData[0].pageSize = 10;
|
||||
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
|
||||
|
||||
return {
|
||||
query: updatedQuery,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: updatedQuery.builder.queryData[0].limit || 0,
|
||||
},
|
||||
// we do not need select columns in case of logs
|
||||
selectColumns:
|
||||
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
|
||||
},
|
||||
fillGaps: widget.fillSpans,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
};
|
||||
}, [widget, updatedQuery, startTime, endTime]);
|
||||
|
||||
const queryResponse = useGetQueryRange(
|
||||
{
|
||||
...requestData,
|
||||
originalGraphType: widget?.panelTypes,
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey: [
|
||||
widget?.query,
|
||||
widget?.panelTypes,
|
||||
requestData,
|
||||
startTime,
|
||||
endTime,
|
||||
],
|
||||
retry(failureCount, error): boolean {
|
||||
if (
|
||||
String(error).includes('status: error') &&
|
||||
String(error).includes('i/o timeout')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < 2;
|
||||
},
|
||||
keepPreviousData: true,
|
||||
enabled: !!widget?.query,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
{},
|
||||
{
|
||||
isPublic: true,
|
||||
widgetIndex: index,
|
||||
publicDashboardId: dashboardId,
|
||||
},
|
||||
);
|
||||
|
||||
const isEmptyLayout = widget?.id === PANEL_TYPES.EMPTY_WIDGET;
|
||||
|
||||
if (queryResponse.data && widget.panelTypes === PANEL_TYPES.BAR) {
|
||||
const sortedSeriesData = getSortedSeriesData(
|
||||
queryResponse.data?.payload.data.result,
|
||||
);
|
||||
queryResponse.data.payload.data.result = sortedSeriesData;
|
||||
}
|
||||
|
||||
if (queryResponse.data && widget.panelTypes === PANEL_TYPES.PIE) {
|
||||
const transformedData = populateMultipleResults(queryResponse?.data);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
queryResponse.data = transformedData;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const onDragSelect = useCallback((_start: number, _end: number): void => {
|
||||
// Handle drag select if needed - no-op for public dashboards
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="panel-container"
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={graphRef}
|
||||
>
|
||||
{isEmptyLayout ? (
|
||||
<EmptyWidget />
|
||||
) : (
|
||||
<WidgetGraphComponent
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
errorMessage={undefined}
|
||||
headerMenuList={[]}
|
||||
isWarning={false}
|
||||
isFetchingResponse={queryResponse.isFetching || queryResponse.isLoading}
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Panel);
|
||||
@@ -0,0 +1,109 @@
|
||||
.public-dashboard-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.public-dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
|
||||
.public-dashboard-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
width: 50%;
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.public-dashboard-header-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: calc(100% - 100px);
|
||||
|
||||
.public-dashboard-header-title-text {
|
||||
font-family: 'Work Sans', sans-serif;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
|
||||
// ellipsis text
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.public-dashboard-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.datetime-section {
|
||||
.time-range-select-dropdown {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand-logo-name {
|
||||
font-family: 'Work Sans', sans-serif;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.brand-logo-img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.public-dashboard-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fullscreen-grid-container {
|
||||
margin: 0px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.public-dashboard-container {
|
||||
.public-dashboard-header {
|
||||
background: var(--bg-vanilla-100);
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import './PublicDashboardContainer.styles.scss';
|
||||
|
||||
import { Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { Card, CardContainer } from 'container/GridCardLayout/styles';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { useMemo, useState } from 'react';
|
||||
import RGL, { WidthProvider } from 'react-grid-layout';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
|
||||
|
||||
import Panel from './Panel';
|
||||
|
||||
const ReactGridLayoutComponent = WidthProvider(RGL);
|
||||
|
||||
const CUSTOM_TIME_REGEX = /^(\d+)([mhdw])$/;
|
||||
|
||||
const getStartTimeAndEndTimeFromTimeRange = (
|
||||
timeRange: string,
|
||||
): { startTime: number; endTime: number } => {
|
||||
const isValidFormat = CUSTOM_TIME_REGEX.test(timeRange);
|
||||
|
||||
if (isValidFormat) {
|
||||
const match = timeRange.match(CUSTOM_TIME_REGEX) as RegExpMatchArray;
|
||||
|
||||
const timeValue = parseInt(match[1] as string, 10);
|
||||
const timeUnit = match[2] as string;
|
||||
|
||||
switch (timeUnit) {
|
||||
case 'm':
|
||||
return {
|
||||
startTime: dayjs().subtract(timeValue, 'minutes').unix(),
|
||||
endTime: dayjs().unix(),
|
||||
};
|
||||
case 'h':
|
||||
return {
|
||||
startTime: dayjs().subtract(timeValue, 'hours').unix(),
|
||||
endTime: dayjs().unix(),
|
||||
};
|
||||
case 'd':
|
||||
return {
|
||||
startTime: dayjs().subtract(timeValue, 'days').unix(),
|
||||
endTime: dayjs().unix(),
|
||||
};
|
||||
case 'w':
|
||||
return {
|
||||
startTime: dayjs().subtract(timeValue, 'weeks').unix(),
|
||||
endTime: dayjs().unix(),
|
||||
};
|
||||
default:
|
||||
return { startTime: dayjs().unix(), endTime: dayjs().unix() };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: dayjs().subtract(30, 'minutes').unix(),
|
||||
endTime: dayjs().unix(),
|
||||
};
|
||||
};
|
||||
|
||||
function PublicDashboardContainer({
|
||||
publicDashboardId,
|
||||
publicDashboardData,
|
||||
}: {
|
||||
publicDashboardId: string;
|
||||
publicDashboardData: SuccessResponseV2<PublicDashboardDataProps>;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { dashboard, publicDashboard } = publicDashboardData?.data || {};
|
||||
const { widgets } = dashboard?.data || {};
|
||||
|
||||
const [selectedTimeRangeLabel, setSelectedTimeRangeLabel] = useState<string>(
|
||||
publicDashboard?.defaultTimeRange || '30m',
|
||||
);
|
||||
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState<{
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}>(
|
||||
getStartTimeAndEndTimeFromTimeRange(
|
||||
publicDashboard?.defaultTimeRange || '30m',
|
||||
),
|
||||
);
|
||||
|
||||
const isTimeRangeEnabled = publicDashboard?.timeRangeEnabled || false;
|
||||
|
||||
// Memoize dashboardLayout to prevent array recreation on every render
|
||||
const dashboardLayout = useMemo(() => dashboard?.data?.layout || [], [
|
||||
dashboard?.data?.layout,
|
||||
]);
|
||||
|
||||
const currentPanelMap = useMemo(() => dashboard?.data?.panelMap || {}, [
|
||||
dashboard?.data?.panelMap,
|
||||
]);
|
||||
|
||||
const handleTimeChange = (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
): void => {
|
||||
if (dateTimeRange) {
|
||||
setSelectedTimeRange({
|
||||
startTime: Math.floor(dateTimeRange[0] / 1000),
|
||||
endTime: Math.floor(dateTimeRange[1] / 1000),
|
||||
});
|
||||
} else if (interval !== 'custom') {
|
||||
const { maxTime, minTime } = GetMinMax(interval);
|
||||
|
||||
setSelectedTimeRange({
|
||||
startTime: Math.floor(minTime / 1000000000),
|
||||
endTime: Math.floor(maxTime / 1000000000),
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedTimeRangeLabel(interval as string);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="public-dashboard-container">
|
||||
<div className="public-dashboard-header">
|
||||
<div className="public-dashboard-header-left">
|
||||
<div className="brand-logo">
|
||||
<img
|
||||
src="/Logos/signoz-brand-logo.svg"
|
||||
alt="SigNoz"
|
||||
className="brand-logo-img"
|
||||
/>
|
||||
|
||||
<Typography className="brand-logo-name">SigNoz</Typography>
|
||||
</div>
|
||||
|
||||
<div className="public-dashboard-header-title">
|
||||
<Typography.Text className="public-dashboard-header-title-text">
|
||||
{dashboard?.data?.title}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTimeRangeEnabled && (
|
||||
<div className="public-dashboard-header-right">
|
||||
<div className="datetime-section">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime={publicDashboard?.defaultTimeRange as Time}
|
||||
isModalTimeSelection
|
||||
modalSelectedInterval={selectedTimeRangeLabel as Time}
|
||||
disableUrlSync
|
||||
showRecentlyUsed={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="public-dashboard-content fullscreen-grid-container">
|
||||
<ReactGridLayoutComponent
|
||||
cols={12}
|
||||
rowHeight={45}
|
||||
autoSize
|
||||
width={100}
|
||||
useCSSTransforms
|
||||
isDraggable={false}
|
||||
isDroppable={false}
|
||||
isResizable={false}
|
||||
allowOverlap={false}
|
||||
layout={dashboardLayout}
|
||||
style={{ backgroundColor: isDarkMode ? '' : themeColors.snowWhite }}
|
||||
>
|
||||
{dashboardLayout?.map((layout) => {
|
||||
const { i: id } = layout;
|
||||
const currentWidget = (widgets || [])?.find((e) => e.id === id);
|
||||
const currentWidgetIndex = (widgets || [])?.findIndex((e) => e.id === id);
|
||||
|
||||
if (currentWidget?.panelTypes === PANEL_GROUP_TYPES.ROW) {
|
||||
const rowWidgetProperties = currentPanelMap[id] || {};
|
||||
let { title } = currentWidget;
|
||||
if (rowWidgetProperties.collapsed) {
|
||||
const widgetCount = rowWidgetProperties.widgets?.length || 0;
|
||||
const collapsedText = `(${widgetCount} widget${
|
||||
widgetCount > 1 ? 's' : ''
|
||||
})`;
|
||||
title += ` ${collapsedText}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContainer
|
||||
isDarkMode={isDarkMode}
|
||||
className="row-card"
|
||||
key={id}
|
||||
data-grid={JSON.stringify(currentWidget)}
|
||||
>
|
||||
<div className={cx('row-panel')}>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<Typography.Text className="section-title">{title}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</CardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContainer
|
||||
isDarkMode={isDarkMode}
|
||||
key={id}
|
||||
data-grid={JSON.stringify(currentWidget)}
|
||||
>
|
||||
<Card
|
||||
className="grid-item"
|
||||
isDarkMode={isDarkMode}
|
||||
$panelType={currentWidget?.panelTypes || PANEL_TYPES.TIME_SERIES}
|
||||
>
|
||||
<Panel
|
||||
dashboardId={publicDashboardId}
|
||||
widget={(currentWidget as Widgets) || ({ id, query: {} } as Widgets)}
|
||||
index={currentWidgetIndex}
|
||||
startTime={selectedTimeRange.startTime}
|
||||
endTime={selectedTimeRange.endTime}
|
||||
/>
|
||||
</Card>
|
||||
</CardContainer>
|
||||
);
|
||||
})}
|
||||
</ReactGridLayoutComponent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardContainer;
|
||||
@@ -0,0 +1,802 @@
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import {
|
||||
publicDashboardResponse,
|
||||
publicDashboardWidgetData,
|
||||
} from 'mocks-server/__mockdata__/publicDashboard';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { Layout } from 'react-grid-layout';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PublicDashboardContainer from '../PublicDashboardContainer';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getMinMax', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn((interval: string) => {
|
||||
if (interval === '1h') {
|
||||
return {
|
||||
minTime: 1000000000000,
|
||||
maxTime: 2000000000000,
|
||||
};
|
||||
}
|
||||
return {
|
||||
minTime: 500000000000,
|
||||
maxTime: 1000000000000,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onTimeChange,
|
||||
}: {
|
||||
onTimeChange: (interval: string, dateTimeRange?: [number, number]) => void;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="datetime-selection">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(): void => onTimeChange('1h')}
|
||||
aria-label="Change time to 1 hour"
|
||||
>
|
||||
Change Time
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(): void => onTimeChange('custom', [1000000, 2000000])}
|
||||
aria-label="Set custom time range"
|
||||
>
|
||||
Custom Time
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../Panel', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
widget,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
widget: Widgets;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}): JSX.Element => (
|
||||
<div data-testid={`panel-${widget.id}`}>
|
||||
<span>
|
||||
Panel: {widget.id} ({startTime}-{endTime})
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('react-grid-layout', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
layout,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
layout: Layout[];
|
||||
style?: React.CSSProperties;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
data-testid="grid-layout"
|
||||
data-layout={JSON.stringify(layout)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
WidthProvider: (
|
||||
Component: React.ComponentType<unknown>,
|
||||
): React.ComponentType<unknown> => Component,
|
||||
}));
|
||||
|
||||
// Mock dayjs
|
||||
jest.mock('dayjs', () => {
|
||||
const actualDayjs = jest.requireActual('dayjs');
|
||||
const mockUnix = jest.fn(() => 1000);
|
||||
const mockUtcOffset = jest.fn(() => 0);
|
||||
const mockTzMethod = jest.fn(() => ({
|
||||
utcOffset: mockUtcOffset,
|
||||
}));
|
||||
const mockSubtract = jest.fn(() => ({
|
||||
subtract: jest.fn(),
|
||||
unix: mockUnix,
|
||||
tz: mockTzMethod,
|
||||
}));
|
||||
const mockDayjs = jest.fn(() => ({
|
||||
subtract: mockSubtract,
|
||||
unix: mockUnix,
|
||||
tz: mockTzMethod,
|
||||
}));
|
||||
Object.keys(actualDayjs).forEach((key) => {
|
||||
((mockDayjs as unknown) as Record<string, unknown>)[
|
||||
key
|
||||
] = (actualDayjs as Record<string, unknown>)[key];
|
||||
});
|
||||
((mockDayjs as unknown) as { extend: jest.Mock }).extend = jest.fn();
|
||||
((mockDayjs as unknown) as { tz: { guess: jest.Mock } }).tz = {
|
||||
guess: jest.fn(() => 'UTC'),
|
||||
};
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
const mockUseIsDarkMode = jest.mocked(useIsDarkMode);
|
||||
|
||||
// MSW setup
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Test constants
|
||||
const MOCK_PUBLIC_DASHBOARD_ID = 'test-dashboard-id';
|
||||
const MOCK_PUBLIC_PATH = '/public/dashboard/test';
|
||||
const DEFAULT_TIME_RANGE = '30m';
|
||||
// Use title from mock data
|
||||
const TEST_DASHBOARD_TITLE = publicDashboardResponse.data.dashboard.data.title;
|
||||
// Use widget ID from mock data
|
||||
const WIDGET_1_ID =
|
||||
publicDashboardResponse.data.dashboard.data.widgets?.[0]?.id || 'widget-1';
|
||||
const WIDGET_1_TITLE = 'Widget 1';
|
||||
const ROW_PANEL_ID = 'row-1';
|
||||
const ROW_PANEL_TITLE = 'Row Panel';
|
||||
|
||||
// Type definitions
|
||||
interface MockWidget {
|
||||
id: string;
|
||||
panelTypes: PANEL_TYPES | PANEL_GROUP_TYPES;
|
||||
title: string;
|
||||
query?: Widgets['query'];
|
||||
description?: string;
|
||||
opacity?: string;
|
||||
nullZeroValues?: string;
|
||||
timePreferance?: string;
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
selectedLogFields?: null;
|
||||
selectedTracesFields?: null;
|
||||
}
|
||||
|
||||
interface MockPublicDashboardData {
|
||||
dashboard: {
|
||||
data: {
|
||||
title: string;
|
||||
widgets?: MockWidget[];
|
||||
layout?: Layout[];
|
||||
panelMap?: Record<string, { widgets: Layout[]; collapsed: boolean }>;
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
publicDashboard: {
|
||||
timeRangeEnabled: boolean;
|
||||
defaultTimeRange: string;
|
||||
publicPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create mock query
|
||||
const createMockQuery = (): Widgets['query'] => ({
|
||||
builder: {
|
||||
queryData: [],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
id: 'query-1',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
});
|
||||
|
||||
// Base mock data - transform publicDashboardResponse to match component's expected format
|
||||
const baseMockData: SuccessResponseV2<PublicDashboardDataProps> = {
|
||||
data: (publicDashboardResponse.data as unknown) as PublicDashboardDataProps,
|
||||
httpStatusCode: StatusCodes.OK,
|
||||
};
|
||||
|
||||
// Helper function to create mock data with optional overrides
|
||||
const createMockData = (
|
||||
overrides?: Partial<MockPublicDashboardData>,
|
||||
): SuccessResponseV2<PublicDashboardDataProps> => {
|
||||
if (!overrides) {
|
||||
return baseMockData;
|
||||
}
|
||||
|
||||
const baseData = baseMockData.data;
|
||||
|
||||
// Apply overrides if provided
|
||||
const mergedData: PublicDashboardDataProps = {
|
||||
dashboard:
|
||||
(overrides?.dashboard as PublicDashboardDataProps['dashboard']) ||
|
||||
baseData.dashboard,
|
||||
publicDashboard: overrides?.publicDashboard || baseData.publicDashboard,
|
||||
};
|
||||
|
||||
return {
|
||||
data: mergedData,
|
||||
httpStatusCode: StatusCodes.OK,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Public Dashboard Container', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
|
||||
// Set up default MSW handler for widget query range API
|
||||
server.use(
|
||||
rest.get(
|
||||
'*/public/dashboards/:dashboardId/widgets/:widgetIndex/query_range',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(StatusCodes.OK), ctx.json(publicDashboardWidgetData)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render dashboard with title and brand logo', () => {
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={baseMockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(TEST_DASHBOARD_TITLE)).toBeInTheDocument();
|
||||
expect(screen.getByText('SigNoz')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('SigNoz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render time range selector when timeRangeEnabled is true', () => {
|
||||
const mockData = createMockData({
|
||||
publicDashboard: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
publicPath: MOCK_PUBLIC_PATH,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('datetime-selection')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /change time to 1 hour/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render time range selector when timeRangeEnabled is false', () => {
|
||||
const mockData = createMockData({
|
||||
publicDashboard: {
|
||||
timeRangeEnabled: false,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
publicPath: MOCK_PUBLIC_PATH,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('datetime-selection')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render widgets in grid layout', () => {
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={baseMockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('grid-layout')).toBeInTheDocument();
|
||||
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new RegExp(`Panel: ${WIDGET_1_ID}`)),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty dashboard data gracefully', () => {
|
||||
const mockData = createMockData({
|
||||
dashboard: {
|
||||
data: {
|
||||
title: 'Empty Dashboard',
|
||||
widgets: [],
|
||||
layout: [],
|
||||
panelMap: {},
|
||||
variables: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Empty Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('grid-layout')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Time Range Handling', () => {
|
||||
it('should initialize with default time range from publicDashboard', () => {
|
||||
const mockData = createMockData({
|
||||
publicDashboard: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: '1h',
|
||||
publicPath: MOCK_PUBLIC_PATH,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Panel should receive the initial time range
|
||||
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update time range when time change handler is called with interval', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockData = createMockData({
|
||||
publicDashboard: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
publicPath: MOCK_PUBLIC_PATH,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timeChangeButton = screen.getByRole('button', {
|
||||
name: /change time to 1 hour/i,
|
||||
});
|
||||
await user.click(timeChangeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const panel = screen.getByTestId(`panel-${WIDGET_1_ID}`);
|
||||
expect(panel).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update time range when time change handler is called with custom dateTimeRange', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockData = createMockData({
|
||||
publicDashboard: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
publicPath: MOCK_PUBLIC_PATH,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
const customTimeButton = screen.getByRole('button', {
|
||||
name: /set custom time range/i,
|
||||
});
|
||||
await user.click(customTimeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Panel should receive updated time range
|
||||
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default time range of 30m when defaultTimeRange is not provided', () => {
|
||||
const mockData = createMockData({
|
||||
publicDashboard: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: (undefined as unknown) as string,
|
||||
publicPath: MOCK_PUBLIC_PATH,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Panel Rendering', () => {
|
||||
it('should render row panel when widget panelTypes is ROW', () => {
|
||||
const mockData = createMockData({
|
||||
dashboard: {
|
||||
data: {
|
||||
title: TEST_DASHBOARD_TITLE,
|
||||
widgets: [
|
||||
{
|
||||
id: ROW_PANEL_ID,
|
||||
panelTypes: PANEL_GROUP_TYPES.ROW,
|
||||
title: ROW_PANEL_TITLE,
|
||||
},
|
||||
],
|
||||
layout: [
|
||||
{
|
||||
i: ROW_PANEL_ID,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 12,
|
||||
h: 2,
|
||||
},
|
||||
],
|
||||
panelMap: {},
|
||||
variables: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(ROW_PANEL_TITLE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render collapsed row panel with widget count', () => {
|
||||
const mockData = createMockData({
|
||||
dashboard: {
|
||||
data: {
|
||||
title: TEST_DASHBOARD_TITLE,
|
||||
widgets: [
|
||||
{
|
||||
id: ROW_PANEL_ID,
|
||||
panelTypes: PANEL_GROUP_TYPES.ROW,
|
||||
title: ROW_PANEL_TITLE,
|
||||
},
|
||||
],
|
||||
layout: [
|
||||
{
|
||||
i: ROW_PANEL_ID,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 12,
|
||||
h: 2,
|
||||
},
|
||||
],
|
||||
panelMap: {
|
||||
[ROW_PANEL_ID]: {
|
||||
widgets: [
|
||||
{ i: 'w1', x: 0, y: 0, w: 6, h: 6 },
|
||||
{ i: 'w2', x: 6, y: 0, w: 6, h: 6 },
|
||||
],
|
||||
collapsed: true,
|
||||
},
|
||||
},
|
||||
variables: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Row Panel \(2 widgets\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render collapsed row panel with singular widget count', () => {
|
||||
const mockData = createMockData({
|
||||
dashboard: {
|
||||
data: {
|
||||
title: TEST_DASHBOARD_TITLE,
|
||||
widgets: [
|
||||
{
|
||||
id: ROW_PANEL_ID,
|
||||
panelTypes: PANEL_GROUP_TYPES.ROW,
|
||||
title: ROW_PANEL_TITLE,
|
||||
},
|
||||
],
|
||||
layout: [
|
||||
{
|
||||
i: ROW_PANEL_ID,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 12,
|
||||
h: 2,
|
||||
},
|
||||
],
|
||||
panelMap: {
|
||||
[ROW_PANEL_ID]: {
|
||||
widgets: [{ i: 'w1', x: 0, y: 0, w: 6, h: 6 }],
|
||||
collapsed: true,
|
||||
},
|
||||
},
|
||||
variables: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Row Panel \(1 widget\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render regular panel for non-ROW widget types', () => {
|
||||
const mockData = createMockData({
|
||||
dashboard: {
|
||||
data: {
|
||||
title: TEST_DASHBOARD_TITLE,
|
||||
widgets: [
|
||||
{
|
||||
id: WIDGET_1_ID,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: WIDGET_1_TITLE,
|
||||
query: createMockQuery(),
|
||||
},
|
||||
],
|
||||
layout: [
|
||||
{
|
||||
i: WIDGET_1_ID,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
},
|
||||
],
|
||||
panelMap: {},
|
||||
variables: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new RegExp(`Panel: ${WIDGET_1_ID}`)),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle missing widget in layout gracefully', () => {
|
||||
const mockData = createMockData({
|
||||
dashboard: {
|
||||
data: {
|
||||
title: TEST_DASHBOARD_TITLE,
|
||||
widgets: [
|
||||
{
|
||||
id: WIDGET_1_ID,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: WIDGET_1_TITLE,
|
||||
query: createMockQuery(),
|
||||
},
|
||||
],
|
||||
layout: [
|
||||
{
|
||||
i: 'missing-widget',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
},
|
||||
],
|
||||
panelMap: {},
|
||||
variables: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should render panel with fallback widget data
|
||||
expect(screen.getByTestId('panel-missing-widget')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Panel: missing-widget/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode', () => {
|
||||
it('should apply dark mode styles when isDarkMode is true', () => {
|
||||
mockUseIsDarkMode.mockReturnValue(true);
|
||||
|
||||
const { container } = render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={baseMockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
const gridLayout = container.querySelector('[data-testid="grid-layout"]');
|
||||
expect(gridLayout).toBeInTheDocument();
|
||||
if (gridLayout) {
|
||||
expect(gridLayout).toHaveStyle({ backgroundColor: '' });
|
||||
}
|
||||
});
|
||||
|
||||
it('should apply light mode styles when isDarkMode is false', () => {
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
|
||||
const { container } = render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={baseMockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
const gridLayout = container.querySelector('[data-testid="grid-layout"]');
|
||||
expect(gridLayout).toBeInTheDocument();
|
||||
if (gridLayout) {
|
||||
// themeColors.snowWhite is '#fafafa' which computes to 'rgb(250, 250, 250)'
|
||||
expect(gridLayout).toHaveStyle({
|
||||
backgroundColor: 'rgb(250, 250, 250)',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined dashboard data', () => {
|
||||
const mockData: SuccessResponseV2<PublicDashboardDataProps> = {
|
||||
data: {
|
||||
dashboard: (undefined as unknown) as PublicDashboardDataProps['dashboard'],
|
||||
publicDashboard: {
|
||||
timeRangeEnabled: false,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
publicPath: MOCK_PUBLIC_PATH,
|
||||
},
|
||||
},
|
||||
httpStatusCode: StatusCodes.OK,
|
||||
};
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('SigNoz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle missing layout data', () => {
|
||||
const mockData = createMockData({
|
||||
dashboard: {
|
||||
data: {
|
||||
title: TEST_DASHBOARD_TITLE,
|
||||
widgets: [
|
||||
{
|
||||
id: WIDGET_1_ID,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: WIDGET_1_TITLE,
|
||||
query: createMockQuery(),
|
||||
},
|
||||
],
|
||||
layout: (undefined as unknown) as Layout[],
|
||||
panelMap: {},
|
||||
variables: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Component should render without errors even with missing layout
|
||||
expect(screen.getByText(TEST_DASHBOARD_TITLE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle multiple widgets in layout', () => {
|
||||
const mockData = createMockData({
|
||||
dashboard: {
|
||||
data: {
|
||||
title: TEST_DASHBOARD_TITLE,
|
||||
widgets: [
|
||||
{
|
||||
id: WIDGET_1_ID,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: WIDGET_1_TITLE,
|
||||
query: createMockQuery(),
|
||||
},
|
||||
{
|
||||
id: 'widget-2',
|
||||
panelTypes: PANEL_TYPES.TABLE,
|
||||
title: 'Widget 2',
|
||||
query: createMockQuery(),
|
||||
},
|
||||
],
|
||||
layout: [
|
||||
{
|
||||
i: WIDGET_1_ID,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
},
|
||||
{
|
||||
i: 'widget-2',
|
||||
x: 6,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
},
|
||||
],
|
||||
panelMap: {},
|
||||
variables: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
|
||||
publicDashboardData={mockData}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-widget-2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import PublicDashboardContainer from './PublicDashboardContainer';
|
||||
|
||||
export default PublicDashboardContainer;
|
||||
@@ -1,6 +1,8 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { Button, Flex, Input, Select } from 'antd';
|
||||
|
||||
import { Button, Flex, Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverflowInputToolTip from 'components/OverflowInputToolTip';
|
||||
import {
|
||||
logsQueryFunctionOptions,
|
||||
metricQueryFunctionOptions,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { debounce, isNil } from 'lodash-es';
|
||||
import { X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryFunction } from 'types/api/v5/queryRange';
|
||||
import { DataSource, QueryFunctionsTypes } from 'types/common/queryBuilder';
|
||||
@@ -47,9 +50,13 @@ export default function Function({
|
||||
functionValue = funcData.args?.[0]?.value;
|
||||
}
|
||||
|
||||
const debouncedhandleUpdateFunctionArgs = debounce(
|
||||
handleUpdateFunctionArgs,
|
||||
500,
|
||||
const [value, setValue] = useState<string>(
|
||||
functionValue !== undefined ? String(functionValue) : '',
|
||||
);
|
||||
|
||||
const debouncedhandleUpdateFunctionArgs = useMemo(
|
||||
() => debounce(handleUpdateFunctionArgs, 500),
|
||||
[handleUpdateFunctionArgs],
|
||||
);
|
||||
|
||||
// update the logic when we start supporting functions for traces
|
||||
@@ -89,13 +96,18 @@ export default function Function({
|
||||
/>
|
||||
|
||||
{showInput && (
|
||||
<Input
|
||||
className="query-function-value"
|
||||
<OverflowInputToolTip
|
||||
autoFocus
|
||||
defaultValue={functionValue}
|
||||
value={value}
|
||||
onChange={(event): void => {
|
||||
const newVal = event.target.value;
|
||||
setValue(newVal);
|
||||
debouncedhandleUpdateFunctionArgs(funcData, index, event.target.value);
|
||||
}}
|
||||
tooltipPlacement="top"
|
||||
minAutoWidth={70}
|
||||
maxAutoWidth={150}
|
||||
className="query-function-value"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
}
|
||||
|
||||
.query-function-value {
|
||||
width: 55px;
|
||||
width: 70px;
|
||||
border-left: 0;
|
||||
background: var(--bg-ink-200);
|
||||
border-radius: 0;
|
||||
|
||||
@@ -68,6 +68,7 @@ import { USER_ROLES } from 'types/roles';
|
||||
import { checkVersionState } from 'utils/app';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
import { routeConfig } from './config';
|
||||
import { getQueryString } from './helper';
|
||||
import {
|
||||
@@ -120,6 +121,7 @@ function SortableFilter({ item }: { item: SidebarItem }): JSX.Element {
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const { openCmdK } = useCmdK();
|
||||
const { pathname, search } = useLocation();
|
||||
const { currentVersion, latestVersion, isCurrentVersionError } = useSelector<
|
||||
AppState,
|
||||
@@ -637,6 +639,8 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
} else {
|
||||
history.push(settingsRoute);
|
||||
}
|
||||
} else if (item.key === 'quick-search') {
|
||||
openCmdK();
|
||||
} else if (item) {
|
||||
onClickHandler(item?.key as string, event);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Receipt,
|
||||
Route,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
Slack,
|
||||
Unplug,
|
||||
@@ -188,6 +189,12 @@ export const primaryMenuItems: SidebarItem[] = [
|
||||
icon: <Home size={16} />,
|
||||
itemKey: 'home',
|
||||
},
|
||||
{
|
||||
key: 'quick-search',
|
||||
label: 'Search',
|
||||
icon: <Search size={16} />,
|
||||
itemKey: 'quick-search',
|
||||
},
|
||||
{
|
||||
key: ROUTES.LIST_ALL_ALERT,
|
||||
label: 'Alerts',
|
||||
|
||||
@@ -8,13 +8,4 @@
|
||||
min-height: 350px;
|
||||
padding: 0px 12px;
|
||||
}
|
||||
|
||||
.time-series-view-container {
|
||||
.time-series-view-container-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
|
||||
import { MetricsLoading } from 'container/MetricsExplorer/MetricsLoading/MetricsLoading';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -82,13 +81,6 @@ function TimeSeriesView({
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
|
||||
const [yAxisUnitInternal, setYAxisUnitInternal] = useState<string>(
|
||||
yAxisUnit || '',
|
||||
);
|
||||
|
||||
const onUnitChangeHandler = (value: string): void => {
|
||||
setYAxisUnitInternal(value);
|
||||
};
|
||||
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
@@ -198,7 +190,7 @@ function TimeSeriesView({
|
||||
const chartOptions = getUPlotChartOptions({
|
||||
id: 'time-series-explorer',
|
||||
onDragSelect,
|
||||
yAxisUnit: yAxisUnitInternal || '',
|
||||
yAxisUnit: yAxisUnit || '',
|
||||
apiResponse: data?.payload,
|
||||
dimensions: {
|
||||
width: containerDimensions.width,
|
||||
@@ -270,17 +262,7 @@ function TimeSeriesView({
|
||||
!isError &&
|
||||
chartData &&
|
||||
!isEmpty(chartData?.[0]) &&
|
||||
chartOptions && (
|
||||
<div className="time-series-view-container">
|
||||
<div className="time-series-view-container-header">
|
||||
<BuilderUnitsFilter
|
||||
onChange={onUnitChangeHandler}
|
||||
yAxisUnit={yAxisUnitInternal}
|
||||
/>
|
||||
</div>
|
||||
<Uplot data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
)}
|
||||
chartOptions && <Uplot data={chartData} options={chartOptions} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,6 +65,8 @@ function DateTimeSelection({
|
||||
onGoLive,
|
||||
onExitLiveLogs,
|
||||
showLiveLogs,
|
||||
disableUrlSync = false,
|
||||
showRecentlyUsed = true,
|
||||
}: Props): JSX.Element {
|
||||
const [formSelector] = Form.useForm();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -563,6 +565,11 @@ function DateTimeSelection({
|
||||
|
||||
// this is triggred when we change the routes and based on that we are changing the default options
|
||||
useEffect(() => {
|
||||
// Skip URL sync when disabled (e.g., public dashboards)
|
||||
if (disableUrlSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metricsTimeDuration = getLocalStorageKey(
|
||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||
);
|
||||
@@ -633,7 +640,7 @@ function DateTimeSelection({
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
safeNavigate(generatedUrl);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname, updateTimeInterval, globalTimeLoading]);
|
||||
}, [location.pathname, updateTimeInterval, globalTimeLoading, disableUrlSync]);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
@@ -716,6 +723,7 @@ function DateTimeSelection({
|
||||
customDateTimeVisible={customDateTimeVisible}
|
||||
setCustomDTPickerVisible={setCustomDTPickerVisible}
|
||||
onExitLiveLogs={onExitLiveLogs}
|
||||
showRecentlyUsed={showRecentlyUsed}
|
||||
/>
|
||||
|
||||
{showAutoRefresh && selectedTime !== 'custom' && (
|
||||
@@ -756,6 +764,10 @@ interface DateTimeSelectionV2Props {
|
||||
showLiveLogs?: boolean;
|
||||
onGoLive?: () => void;
|
||||
onExitLiveLogs?: () => void;
|
||||
/** When true, prevents the component from modifying URL parameters (useful for public dashboards or isolated contexts) */
|
||||
disableUrlSync?: boolean;
|
||||
/** When false, hides the "Recently Used" time ranges section in the time picker */
|
||||
showRecentlyUsed?: boolean;
|
||||
}
|
||||
|
||||
DateTimeSelection.defaultProps = {
|
||||
@@ -772,6 +784,8 @@ DateTimeSelection.defaultProps = {
|
||||
onGoLive: (): void => {},
|
||||
onExitLiveLogs: (): void => {},
|
||||
showLiveLogs: false,
|
||||
disableUrlSync: false,
|
||||
showRecentlyUsed: true,
|
||||
};
|
||||
interface DispatchProps {
|
||||
updateTimeInterval: (
|
||||
|
||||
18
frontend/src/hooks/dashboard/useGetPublicDashboardData.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
export const useGetPublicDashboardData = (
|
||||
id: string,
|
||||
): UseQueryResult<SuccessResponseV2<PublicDashboardDataProps>, APIError> =>
|
||||
useQuery<SuccessResponseV2<PublicDashboardDataProps>, APIError>({
|
||||
queryFn: () => getPublicDashboardDataAPI({ id }),
|
||||
onError: (error) => {
|
||||
console.error('Error getting public dashboard data', error);
|
||||
},
|
||||
queryKey: [REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD, id],
|
||||
enabled: !!id,
|
||||
});
|
||||
19
frontend/src/hooks/dashboard/useGetPublicDashboardMeta.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import getPublicDashboardMetaAPI from 'api/dashboard/public/getPublicDashboardMeta';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
export const useGetPublicDashboardMeta = (
|
||||
id: string,
|
||||
): UseQueryResult<SuccessResponseV2<PublicDashboardMetaProps>, APIError> =>
|
||||
useQuery<SuccessResponseV2<PublicDashboardMetaProps>, APIError>({
|
||||
queryFn: () => getPublicDashboardMetaAPI({ id }),
|
||||
onError: (error) => {
|
||||
console.error('Error getting public dashboard', error);
|
||||
},
|
||||
queryKey: [REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_META, id],
|
||||
enabled: !!id,
|
||||
keepPreviousData: false,
|
||||
});
|
||||
@@ -26,6 +26,11 @@ type UseGetQueryRange = (
|
||||
version: string,
|
||||
options?: UseGetQueryRangeOptions,
|
||||
headers?: Record<string, string>,
|
||||
publicQueryMeta?: {
|
||||
isPublic: boolean;
|
||||
widgetIndex: number;
|
||||
publicDashboardId: string;
|
||||
},
|
||||
) => UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps> & { warning?: Warning },
|
||||
Error
|
||||
@@ -36,6 +41,7 @@ export const useGetQueryRange: UseGetQueryRange = (
|
||||
version,
|
||||
options,
|
||||
headers,
|
||||
publicQueryMeta,
|
||||
) => {
|
||||
const { selectedDashboard } = useDashboard();
|
||||
|
||||
@@ -156,6 +162,8 @@ export const useGetQueryRange: UseGetQueryRange = (
|
||||
dynamicVariables,
|
||||
signal,
|
||||
headers,
|
||||
undefined,
|
||||
publicQueryMeta,
|
||||
),
|
||||
...options,
|
||||
retry,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import getPublicDashboardWidgetData from 'api/dashboard/public/getPublicDashboardWidgetData';
|
||||
|
||||
/**
|
||||
* Validates if metric name is available for METRICS data source
|
||||
@@ -200,6 +201,11 @@ export async function GetMetricQueryRange(
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
isInfraMonitoring?: boolean,
|
||||
publicQueryMeta?: {
|
||||
isPublic: boolean;
|
||||
widgetIndex: number;
|
||||
publicDashboardId: string;
|
||||
},
|
||||
): Promise<SuccessResponse<MetricRangePayloadProps> & { warning?: Warning }> {
|
||||
let legendMap: Record<string, string>;
|
||||
let response:
|
||||
@@ -249,7 +255,10 @@ export async function GetMetricQueryRange(
|
||||
legendMap = v5Result.legendMap;
|
||||
|
||||
// atleast one query should be there to make call to v5 api
|
||||
if (v5Result.queryPayload.compositeQuery.queries.length === 0) {
|
||||
if (
|
||||
v5Result.queryPayload.compositeQuery.queries.length === 0 &&
|
||||
!publicQueryMeta?.isPublic
|
||||
) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
@@ -272,24 +281,45 @@ export async function GetMetricQueryRange(
|
||||
};
|
||||
}
|
||||
|
||||
const v5Response = await getQueryRangeV5(
|
||||
v5Result.queryPayload,
|
||||
version,
|
||||
signal,
|
||||
headers,
|
||||
);
|
||||
if (publicQueryMeta?.isPublic) {
|
||||
const publicResponse = await getPublicDashboardWidgetData({
|
||||
id: publicQueryMeta?.publicDashboardId,
|
||||
index: publicQueryMeta?.widgetIndex,
|
||||
startTime: props.start * 1000,
|
||||
endTime: props.end * 1000,
|
||||
});
|
||||
|
||||
// Convert V5 response to legacy format for components
|
||||
response = convertV5ResponseToLegacy(
|
||||
{
|
||||
payload: v5Response.data,
|
||||
params: v5Result.queryPayload,
|
||||
},
|
||||
legendMap,
|
||||
finalFormatForWeb,
|
||||
);
|
||||
// Convert V5 response to legacy format for components
|
||||
response = convertV5ResponseToLegacy(
|
||||
{
|
||||
payload: publicResponse.data,
|
||||
params: v5Result.queryPayload,
|
||||
},
|
||||
legendMap,
|
||||
finalFormatForWeb,
|
||||
);
|
||||
|
||||
warning = response.payload.warning || undefined;
|
||||
warning = response.payload.warning || undefined;
|
||||
} else {
|
||||
const v5Response = await getQueryRangeV5(
|
||||
v5Result.queryPayload,
|
||||
version,
|
||||
signal,
|
||||
headers,
|
||||
);
|
||||
|
||||
// Convert V5 response to legacy format for components
|
||||
response = convertV5ResponseToLegacy(
|
||||
{
|
||||
payload: v5Response.data,
|
||||
params: v5Result.queryPayload,
|
||||
},
|
||||
legendMap,
|
||||
finalFormatForWeb,
|
||||
);
|
||||
|
||||
warning = response.payload.warning || undefined;
|
||||
}
|
||||
} else {
|
||||
const legacyResult = prepareQueryRangePayload(props);
|
||||
legendMap = legacyResult.legendMap;
|
||||
|
||||
328
frontend/src/mocks-server/__mockdata__/publicDashboard.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
export const publishedPublicDashboardMeta = {
|
||||
status: 'success',
|
||||
data: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: '30m',
|
||||
publicPath: '/public/dashboard/019ac98e-383f-7e9f-b716-d15bcb6be4bb',
|
||||
},
|
||||
};
|
||||
|
||||
export const unpublishedPublicDashboardMeta = {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'public_dashboard_not_found',
|
||||
message:
|
||||
"dashboard with id 019ac4e8-1a35-718e-aa4b-0c9781d7a31c isn't public",
|
||||
},
|
||||
};
|
||||
|
||||
export const publicDashboardResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
dashboard: {
|
||||
createdAt: '0001-01-01T00:00:00Z',
|
||||
updatedAt: '0001-01-01T00:00:00Z',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
id: '',
|
||||
data: {
|
||||
description: '',
|
||||
image:
|
||||
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHZpZXdCb3g9IjAgMCAxOCAxOCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0Ljk0OTQgMTMuOTQyQzE2LjIzMTggMTIuNDI1OCAxNy4zMjY4IDkuNzAyMiAxNi4xOTU2IDYuNTc0ODdDMTUuNjQ0MyA1LjA1MjQ1IDE1LjAyMTkgNC4yMDI0OSAxNC4yOTY5IDMuNjYyNTJDMTMuODU1NyAzLjMzMzc5IDEyLjA5MzMgMi41MDYzMyA5Ljc1OTY1IDIuODY3NTZDOC4wNTM0OSAzLjEzMjU1IDUuNzc0ODcgNC4yMDg3NCA0LjI5MzY5IDUuOTU5OUMyLjg1NzUyIDcuNjYxMDYgMS43NDg4MyA5LjAwNDc0IDEuNjk3NTggMTAuMzA5N0MxLjYzMTMzIDExLjk4ODMgMi44OTYyNyAxMy40MzA4IDMuMDUwMDEgMTMuNjY0NUMzLjMyMzc0IDE0LjA3OTUgNS4xOTExNSAxNi40NTE4IDguNjk5NzEgMTYuNTczMUMxMS43OTcgMTYuNjc5MyAxMy44MTQ0IDE1LjI4NDQgMTQuOTQ5NCAxMy45NDJaIiBmaWxsPSIjNDAzRDNFIi8+CjxwYXRoIGQ9Ik00LjU1MzYzIDIuNzM3NDdDMi45Mzc0NiAzLjg5MTE2IDEuMTIxMzEgNi4yNTEwMyAxLjQ0NzU0IDkuNTYwODZDMS42MDYyOCAxMS4xNzIgMi4wMDI1MSAxMi4xNDk1IDIuNTcxMjMgMTIuODUwN0MyLjkxNzQ2IDEzLjI3ODIgNC40MTk4OCAxNC41NDkzIDYuNzczNTEgMTQuNzM2OEM5LjE0NTg4IDE0LjkyNTYgMTAuOTQ5NSAxNC4zOTQ0IDEyLjgzMzIgMTMuMDg0NEMxNi42NjE3IDEwLjQyMDggMTYuMDk4IDYuMzkzNTMgMTUuOTM0MyA1LjkyNDhDMTUuNzcwNSA1LjQ1NjA3IDE0LjU0NDQgMi42OTYyMiAxMS4xNzMzIDEuNzE1MDJDOC4xOTg0NCAwLjg1MDA2OCA1Ljk4MzU1IDEuNzE1MDIgNC41NTM2MyAyLjczNzQ3WiIgZmlsbD0iIzVFNjM2NyIvPgo8cGF0aCBkPSJNNy4zOTM1MyAyLjk2MTA5QzUuNjE3MzcgMi44OTczNCAzLjkxOTk2IDQuMjg4NTIgMy43NTYyMiA2LjAwNTkzQzMuNTkyNDggNy43MjIwOSA0LjY1NDkyIDkuMDI5NTIgNi4zMDk4MyA5LjI5NTc2QzcuOTY0NzUgOS41NjA3NCA5Ljg3ODM5IDguNTU1OCAxMC4yNjM0IDYuNDUwOTFDMTAuNjYwOSA0LjI4MjI3IDkuMDg5NjkgMy4wMjIzNCA3LjM5MzUzIDIuOTYxMDlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNy45NDIxNyA1LjkwMTE1QzcuOTQyMTcgNS45MDExNSA4LjM2OTY1IDUuODEyNCA4LjQ1NDY1IDUuMTgyNDRDOC41MzgzOSA0LjU2MjQ3IDguMjMwOTEgNC4wMzM3NSA3LjUxMzQ1IDMuODQzNzZDNi43MzM0OSAzLjYzNzUyIDYuMjA0NzcgNC4wNjYyNSA2LjA2NzI3IDQuNTE3NDdDNS44NzYwMyA1LjE0NDk0IDYuMTU4NTIgNS40NDM2NyA2LjE1ODUyIDUuNDQzNjdDNi4xNTg1MiA1LjQ0MzY3IDUuMzkzNTYgNS42Mjc0MSA1LjMzMjMxIDYuNTI5ODdDNS4yNzQ4MSA3LjM4MTA3IDUuODU2MDMgNy44Mzg1NSA2LjQzOTc1IDcuOTc4NTRDNy4xNjA5NiA4LjE1MjI4IDcuOTc4NDIgNy45NTQ3OSA4LjE3ODQxIDcuMDM0ODRDOC4zNDQ2NSA2LjI3NzM4IDcuOTQyMTcgNS45MDExNSA3Ljk0MjE3IDUuOTAxMTVaIiBmaWxsPSIjMzAzMDMwIi8+CjxwYXRoIGQ9Ik02LjczOTgzIDQuNzUzNjJDNi42NzEwOSA1LjAxMjM1IDYuODA4NTggNS4yNjIzNCA3LjA3ODU3IDUuMzMxMDlDNy4zNjk4IDUuNDA0ODMgNy42MzQ3OSA1LjMwODU5IDcuNzA2MDMgNS4wMTExQzcuNzY4NTMgNC43NDczNyA3LjY0MzU0IDQuNTE0ODggNy4zMzYwNSA0LjQzOTg4QzcuMDgzNTcgNC4zNzczOSA2LjgxNDgzIDQuNDcxMTMgNi43Mzk4MyA0Ljc1MzYyWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTYuOTU5NzggNi4wMzk3NEM2LjYzMjMgNS45Mzg0OSA2LjE5OTgyIDYuMDY0NzMgNi4xMzEwNyA2LjUwNDcxQzYuMDYyMzMgNi45NDQ2OSA2LjMyNjA2IDcuMTY5NjggNi42NzEwNCA3LjIzMjE3QzcuMDE2MDMgNy4yOTQ2NyA3LjM0MjI2IDcuMTEzNDMgNy40MDYwMSA2Ljc2MDk1QzcuNDY4NSA2LjQwOTcyIDcuMjg2MDEgNi4xMzk3MyA2Ljk1OTc4IDYuMDM5NzRaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K',
|
||||
layout: [
|
||||
{
|
||||
h: 6,
|
||||
i: '86590b60-8232-4b41-9744-32cadf2c95fb',
|
||||
moved: false,
|
||||
static: false,
|
||||
w: 6,
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
],
|
||||
panelMap: {},
|
||||
tags: [],
|
||||
title: 'Public Dashboard 02',
|
||||
uploadedGrafana: false,
|
||||
version: 'v5',
|
||||
widgets: [
|
||||
{
|
||||
bucketCount: 30,
|
||||
bucketWidth: 0,
|
||||
columnUnits: {},
|
||||
contextLinks: {
|
||||
linksData: [],
|
||||
},
|
||||
customLegendColors: {},
|
||||
decimalPrecision: 2,
|
||||
description: '',
|
||||
fillSpans: false,
|
||||
id: '86590b60-8232-4b41-9744-32cadf2c95fb',
|
||||
isLogScale: false,
|
||||
legendPosition: 'bottom',
|
||||
mergeAllActiveQueries: false,
|
||||
nullZeroValues: 'zero',
|
||||
opacity: '1',
|
||||
panelTypes: 'graph',
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'container.cpu.time',
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
temporality: '',
|
||||
timeAggregation: 'rate',
|
||||
},
|
||||
],
|
||||
dataSource: 'metrics',
|
||||
expression: 'A',
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
queryName: 'A',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
legend: '',
|
||||
name: 'A',
|
||||
},
|
||||
],
|
||||
promql: [
|
||||
{
|
||||
legend: '',
|
||||
name: 'A',
|
||||
},
|
||||
],
|
||||
queryType: 'builder',
|
||||
},
|
||||
selectedLogFields: [
|
||||
{
|
||||
dataType: '',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
isIndexed: false,
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
type: 'log',
|
||||
},
|
||||
{
|
||||
dataType: '',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
isIndexed: false,
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
type: 'log',
|
||||
},
|
||||
],
|
||||
selectedTracesFields: [
|
||||
{
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
name: 'service.name',
|
||||
signal: 'traces',
|
||||
},
|
||||
{
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'string',
|
||||
name: 'name',
|
||||
signal: 'traces',
|
||||
},
|
||||
{
|
||||
fieldContext: 'span',
|
||||
fieldDataType: '',
|
||||
name: 'duration_nano',
|
||||
signal: 'traces',
|
||||
},
|
||||
{
|
||||
fieldContext: 'span',
|
||||
fieldDataType: '',
|
||||
name: 'http_method',
|
||||
signal: 'traces',
|
||||
},
|
||||
{
|
||||
fieldContext: 'span',
|
||||
fieldDataType: '',
|
||||
name: 'response_status_code',
|
||||
signal: 'traces',
|
||||
},
|
||||
],
|
||||
softMax: 0,
|
||||
softMin: 0,
|
||||
stackedBarChart: false,
|
||||
thresholds: [],
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
title: '',
|
||||
yAxisUnit: 'none',
|
||||
},
|
||||
],
|
||||
},
|
||||
locked: false,
|
||||
org_id: '00000000-0000-0000-0000-000000000000',
|
||||
},
|
||||
publicDashboard: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: '30m',
|
||||
publicPath: '/public/dashboard/019ad04e-8591-7013-879b-a2af376e4708',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const publicDashboardWidgetData = {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'time_series',
|
||||
meta: {
|
||||
rowsScanned: 367490,
|
||||
bytesScanned: 2977076,
|
||||
durationMs: 330,
|
||||
},
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '__result_0',
|
||||
meta: {},
|
||||
series: [
|
||||
{
|
||||
values: [
|
||||
{
|
||||
timestamp: 1764429600000,
|
||||
partial: true,
|
||||
value: 1.554,
|
||||
},
|
||||
{
|
||||
timestamp: 1764429660000,
|
||||
value: 1.367,
|
||||
},
|
||||
{
|
||||
timestamp: 1764429720000,
|
||||
value: 1.641,
|
||||
},
|
||||
{
|
||||
timestamp: 1764429780000,
|
||||
value: 1.455,
|
||||
},
|
||||
{
|
||||
timestamp: 1764429840000,
|
||||
value: 1.739,
|
||||
},
|
||||
{
|
||||
timestamp: 1764429900000,
|
||||
value: 1.318,
|
||||
},
|
||||
{
|
||||
timestamp: 1764429960000,
|
||||
value: 1.813,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430020000,
|
||||
value: 1.332,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430080000,
|
||||
value: 1.521,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430140000,
|
||||
value: 1.461,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430200000,
|
||||
value: 1.61,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430260000,
|
||||
value: 1.747,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430320000,
|
||||
value: 1.51,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430380000,
|
||||
value: 1.501,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430440000,
|
||||
value: 1.524,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430500000,
|
||||
value: 1.648,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430560000,
|
||||
value: 1.545,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430620000,
|
||||
value: 1.47,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430680000,
|
||||
value: 1.582,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430740000,
|
||||
value: 1.257,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430800000,
|
||||
value: 1.726,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430860000,
|
||||
value: 1.482,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430920000,
|
||||
value: 1.291,
|
||||
},
|
||||
{
|
||||
timestamp: 1764430980000,
|
||||
value: 1.741,
|
||||
},
|
||||
{
|
||||
timestamp: 1764431040000,
|
||||
value: 1.591,
|
||||
},
|
||||
{
|
||||
timestamp: 1764431100000,
|
||||
value: 1.609,
|
||||
},
|
||||
{
|
||||
timestamp: 1764431160000,
|
||||
value: 1.177,
|
||||
},
|
||||
{
|
||||
timestamp: 1764431220000,
|
||||
value: 1.699,
|
||||
},
|
||||
{
|
||||
timestamp: 1764431280000,
|
||||
value: 1.617,
|
||||
},
|
||||
{
|
||||
timestamp: 1764431340000,
|
||||
value: 1.515,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,13 @@
|
||||
import AlertRuleProvider from 'providers/Alert';
|
||||
|
||||
import AlertDetails from './AlertDetails';
|
||||
|
||||
export default AlertDetails;
|
||||
function AlertDetailsPage(): JSX.Element {
|
||||
return (
|
||||
<AlertRuleProvider>
|
||||
<AlertDetails />
|
||||
</AlertRuleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlertDetailsPage;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
.public-dashboard-page {
|
||||
.public-dashboard-error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 64px 0px;
|
||||
|
||||
.public-dashboard-error-content-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
margin-bottom: 12px;
|
||||
|
||||
.brand-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-100, #ffffff); // White text for dark theme
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400, #c0c1c3); // Light gray text for dark theme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.public-dashboard-error-content {
|
||||
margin: 128px 0px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding: 32px 0px;
|
||||
gap: 8px;
|
||||
|
||||
.ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.public-dashboard-error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.public-dashboard-error-message-icon {
|
||||
color: var(--bg-vanilla-400, #c0c1c3); // Light gray text for dark theme
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.public-dashboard-error-container {
|
||||
.public-dashboard-error-content-header {
|
||||
.brand-title {
|
||||
color: var(--bg-ink-400, #121317) !important; // Dark text for light theme
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
80
frontend/src/pages/PublicDashboard/index.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import './PublicDashboard.styles.scss';
|
||||
|
||||
import { Typography } from 'antd';
|
||||
import { useGetPublicDashboardData } from 'hooks/dashboard/useGetPublicDashboardData';
|
||||
import { FrownIcon } from 'lucide-react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import PublicDashboardContainer from '../../container/PublicDashboardContainer';
|
||||
|
||||
function PublicDashboardPage(): JSX.Element {
|
||||
// read the dashboard id from the url
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const {
|
||||
data: publicDashboardData,
|
||||
isLoading: isLoadingPublicDashboardData,
|
||||
isFetching: isFetchingPublicDashboardData,
|
||||
isError: isErrorPublicDashboardData,
|
||||
} = useGetPublicDashboardData(dashboardId || '');
|
||||
|
||||
const isLoading =
|
||||
isLoadingPublicDashboardData || isFetchingPublicDashboardData;
|
||||
|
||||
const isError = isErrorPublicDashboardData;
|
||||
|
||||
return (
|
||||
<div className="public-dashboard-page">
|
||||
{publicDashboardData && (
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={dashboardId}
|
||||
publicDashboardData={publicDashboardData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && (
|
||||
<div className="public-dashboard-error-container">
|
||||
<div className="perilin-bg" />
|
||||
|
||||
<div className="public-dashboard-error-content-header">
|
||||
<div className="brand">
|
||||
<img
|
||||
src="/Logos/signoz-brand-logo.svg"
|
||||
alt="SigNoz"
|
||||
className="brand-logo"
|
||||
/>
|
||||
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
SigNoz
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<div className="brand-tagline">
|
||||
<Typography.Text>
|
||||
OpenTelemetry-Native Logs, Metrics and Traces in a single pane
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="public-dashboard-error-content">
|
||||
<Typography.Title
|
||||
level={4}
|
||||
className="public-dashboard-error-message-icon"
|
||||
>
|
||||
<FrownIcon size={36} />
|
||||
</Typography.Title>
|
||||
<Typography.Title level={4} className="public-dashboard-error-message">
|
||||
The public dashboard you are looking for does not exist or has been
|
||||
unpublished.
|
||||
</Typography.Title>
|
||||
<Typography.Text className="public-dashboard-error-message-description">
|
||||
Please reach out to the owner of the dashboard to get access.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardPage;
|
||||
@@ -0,0 +1,8 @@
|
||||
.trace-explorer-time-series-view-container {
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import './TimeSeriesView.styles.scss';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -19,9 +23,6 @@ import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import TimeSeriesView from './TimeSeriesView';
|
||||
import { convertDataValueToMs } from './utils';
|
||||
|
||||
function TimeSeriesViewContainer({
|
||||
dataSource = DataSource.TRACES,
|
||||
isFilterApplied,
|
||||
@@ -31,11 +32,6 @@ function TimeSeriesViewContainer({
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const { selectedTime: globalSelectedTime, maxTime, minTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
|
||||
@@ -55,6 +51,19 @@ function TimeSeriesViewContainer({
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string>(
|
||||
isValidToConvertToMs ? 'ms' : 'short',
|
||||
);
|
||||
|
||||
const onUnitChangeHandler = (value: string): void => {
|
||||
setYAxisUnit(value);
|
||||
};
|
||||
|
||||
const { selectedTime: globalSelectedTime, maxTime, minTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
@@ -110,16 +119,21 @@ function TimeSeriesViewContainer({
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
return (
|
||||
<TimeSeriesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={responseData}
|
||||
yAxisUnit={isValidToConvertToMs ? 'ms' : 'short'}
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
<div className="trace-explorer-time-series-view-container">
|
||||
<div className="trace-explorer-time-series-view-container-header">
|
||||
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} />
|
||||
</div>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={responseData}
|
||||
yAxisUnit={yAxisUnit}
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapp
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import TimeSeriesView from 'container/TimeSeriesView';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import {
|
||||
getExportQueryData,
|
||||
@@ -51,6 +50,8 @@ import {
|
||||
} from 'utils/explorerUtils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import TimeSeriesView from './TimeSeriesView';
|
||||
|
||||
function TracesExplorer(): JSX.Element {
|
||||
const {
|
||||
panelType,
|
||||
|
||||
@@ -93,11 +93,24 @@
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--bg-cherry-500) !important;
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(255, 184, 0, 0.1);
|
||||
background: rgba(255, 184, 0, 0.1) !important;
|
||||
color: var(--text-vanilla-100) !important;
|
||||
text-align: center;
|
||||
font-variant-numeric: slashed-zero;
|
||||
|
||||
/* Label/Small/500 */
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 100%; /* 11px */
|
||||
background: var(--bg-cherry-500);
|
||||
|
||||
box-shadow: none !important;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--bg-cherry-600) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { AxiosError } from 'axios';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { KBarProvider } from 'kbar';
|
||||
import history from 'lib/history';
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
|
||||
import { useAppContext } from './App/App';
|
||||
|
||||
export function KBarCommandPaletteProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { setAutoSwitch, setTheme } = useThemeMode();
|
||||
|
||||
const handleThemeChange = (value: string): void => {
|
||||
logEvent('Account Settings: Theme Changed', {
|
||||
theme: value,
|
||||
});
|
||||
|
||||
if (value === 'auto') {
|
||||
setAutoSwitch(true);
|
||||
} else {
|
||||
setAutoSwitch(false);
|
||||
setTheme(value);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickHandler = useCallback((key: string): void => {
|
||||
history.push(key);
|
||||
}, []);
|
||||
|
||||
const { updateUserPreferenceInContext } = useAppContext();
|
||||
|
||||
const { mutate: updateUserPreferenceMutation } = useMutation(
|
||||
updateUserPreference,
|
||||
{
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleOpenSidebar = useCallback((): void => {
|
||||
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
|
||||
|
||||
// Update the context immediately
|
||||
const save = {
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: true,
|
||||
};
|
||||
|
||||
updateUserPreferenceInContext(save as UserPreference);
|
||||
|
||||
// Make the API call in the background
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: true,
|
||||
});
|
||||
}, [updateUserPreferenceInContext, updateUserPreferenceMutation]);
|
||||
|
||||
const handleCloseSidebar = useCallback((): void => {
|
||||
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
|
||||
|
||||
// Update the context immediately
|
||||
const save = {
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: false,
|
||||
};
|
||||
|
||||
updateUserPreferenceInContext(save as UserPreference);
|
||||
|
||||
// Make the API call in the background
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: false,
|
||||
});
|
||||
}, [updateUserPreferenceInContext, updateUserPreferenceMutation]);
|
||||
const kbarActions = [
|
||||
{
|
||||
id: 'home',
|
||||
name: 'Go to Home',
|
||||
shortcut: ['shift + h'],
|
||||
keywords: 'home',
|
||||
section: 'Navigation',
|
||||
perform: (): void => {
|
||||
onClickHandler(ROUTES.HOME);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dashboards',
|
||||
name: 'Go to Dashboards',
|
||||
shortcut: ['shift + d'],
|
||||
keywords: 'dashboards',
|
||||
section: 'Navigation',
|
||||
perform: (): void => {
|
||||
onClickHandler(ROUTES.ALL_DASHBOARD);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
name: 'Go to Services',
|
||||
shortcut: ['shift + s'],
|
||||
keywords: 'services monitoring',
|
||||
section: 'Navigation',
|
||||
perform: (): void => {
|
||||
onClickHandler(ROUTES.APPLICATION);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'traces',
|
||||
name: 'Go to Traces',
|
||||
shortcut: ['shift + t'],
|
||||
keywords: 'traces',
|
||||
section: 'Navigation',
|
||||
perform: (): void => {
|
||||
onClickHandler(ROUTES.TRACES_EXPLORER);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs',
|
||||
shortcut: ['shift + l'],
|
||||
keywords: 'logs',
|
||||
section: 'Navigation',
|
||||
perform: (): void => {
|
||||
onClickHandler(ROUTES.LOGS);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'alerts',
|
||||
name: 'Go to Alerts',
|
||||
shortcut: ['shift + a'],
|
||||
keywords: 'alerts',
|
||||
section: 'Navigation',
|
||||
perform: (): void => {
|
||||
onClickHandler(ROUTES.LIST_ALL_ALERT);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'exceptions',
|
||||
name: 'Go to Exceptions',
|
||||
shortcut: ['shift + e'],
|
||||
keywords: 'exceptions errors',
|
||||
section: 'Navigation',
|
||||
perform: (): void => {
|
||||
onClickHandler(ROUTES.ALL_ERROR);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'messaging-queues',
|
||||
name: 'Go to Messaging Queues',
|
||||
shortcut: ['shift + m'],
|
||||
keywords: 'messaging queues mq',
|
||||
section: 'Navigation',
|
||||
perform: (): void => {
|
||||
onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'my-settings',
|
||||
name: 'Go to Account Settings',
|
||||
keywords: 'account settings',
|
||||
section: 'Navigation',
|
||||
perform: (): void => {
|
||||
onClickHandler(ROUTES.MY_SETTINGS);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'open-sidebar',
|
||||
name: 'Open Sidebar',
|
||||
keywords: 'sidebar navigation menu expand',
|
||||
section: 'Settings',
|
||||
perform: (): void => {
|
||||
handleOpenSidebar();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'collapse-sidebar',
|
||||
name: 'Collapse Sidebar',
|
||||
keywords: 'sidebar navigation menu collapse',
|
||||
section: 'Settings',
|
||||
perform: (): void => {
|
||||
handleCloseSidebar();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dark-mode',
|
||||
name: 'Switch to Dark Mode',
|
||||
keywords: 'theme dark mode appearance',
|
||||
section: 'Settings',
|
||||
perform: (): void => {
|
||||
handleThemeChange(THEME_MODE.DARK);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'light-mode',
|
||||
name: 'Switch to Light Mode [Beta]',
|
||||
keywords: 'theme light mode appearance',
|
||||
section: 'Settings',
|
||||
perform: (): void => {
|
||||
handleThemeChange(THEME_MODE.LIGHT);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'system-theme',
|
||||
name: 'Switch to System Theme',
|
||||
keywords: 'system theme appearance',
|
||||
section: 'Settings',
|
||||
perform: (): void => {
|
||||
handleThemeChange(THEME_MODE.SYSTEM);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return <KBarProvider actions={kbarActions}>{children}</KBarProvider>;
|
||||
}
|
||||
@@ -565,8 +565,6 @@ export function QueryBuilderProvider({
|
||||
setCurrentQuery((prevState) => {
|
||||
if (prevState.builder.queryData.length >= MAX_QUERIES) return prevState;
|
||||
|
||||
console.log('prevState', prevState.builder.queryData);
|
||||
|
||||
const newQuery = createNewBuilderQuery(prevState.builder.queryData);
|
||||
|
||||
return {
|
||||
|
||||
50
frontend/src/providers/cmdKProvider.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
type CmdKContextType = {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
openCmdK: () => void;
|
||||
closeCmdK: () => void;
|
||||
};
|
||||
|
||||
const CmdKContext = createContext<CmdKContextType | null>(null);
|
||||
|
||||
export function CmdKProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
function openCmdK(): void {
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function closeCmdK(): void {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const value = useMemo<CmdKContextType>(
|
||||
() => ({
|
||||
open,
|
||||
setOpen,
|
||||
openCmdK,
|
||||
closeCmdK,
|
||||
}),
|
||||
[open],
|
||||
);
|
||||
|
||||
return <CmdKContext.Provider value={value}>{children}</CmdKContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCmdK(): CmdKContextType {
|
||||
const ctx = useContext(CmdKContext);
|
||||
if (!ctx) throw new Error('useCmdK must be used inside CmdKProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -18,6 +18,16 @@ jest.mock('api/browser/localstorage/get', () => ({
|
||||
default: jest.fn((key: string) => mockLocalStorage[key] || null),
|
||||
}));
|
||||
|
||||
const mockLogsColumns = [
|
||||
{ name: 'timestamp', signal: 'logs', fieldContext: 'log' },
|
||||
{ name: 'body', signal: 'logs', fieldContext: 'log' },
|
||||
];
|
||||
|
||||
const mockTracesColumns = [
|
||||
{ name: 'service.name', signal: 'traces', fieldContext: 'resource' },
|
||||
{ name: 'name', signal: 'traces', fieldContext: 'span' },
|
||||
];
|
||||
|
||||
describe('logsLoaderConfig', () => {
|
||||
// Save original location object
|
||||
const originalWindowLocation = window.location;
|
||||
@@ -157,4 +167,83 @@ describe('logsLoaderConfig', () => {
|
||||
} as FormattingOptions,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Column validation - filtering Traces columns', () => {
|
||||
it('should filter out Traces columns (name with traces signal) from URL', async () => {
|
||||
const mixedColumns = [...mockLogsColumns, ...mockTracesColumns];
|
||||
|
||||
mockedLocation.search = `?options=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
selectColumns: mixedColumns,
|
||||
}),
|
||||
)}`;
|
||||
|
||||
const result = await logsLoaderConfig.url();
|
||||
|
||||
// Should only keep logs columns
|
||||
expect(result.columns).toEqual(mockLogsColumns);
|
||||
});
|
||||
|
||||
it('should filter out Traces columns from localStorage', async () => {
|
||||
const tracesColumns = [...mockTracesColumns];
|
||||
|
||||
mockLocalStorage[LOCALSTORAGE.LOGS_LIST_OPTIONS] = JSON.stringify({
|
||||
selectColumns: tracesColumns,
|
||||
});
|
||||
|
||||
const result = await logsLoaderConfig.local();
|
||||
|
||||
// Should filter out all Traces columns
|
||||
expect(result.columns).toEqual([]);
|
||||
});
|
||||
|
||||
it('should accept valid Logs columns from URL', async () => {
|
||||
const logsColumns = [...mockLogsColumns];
|
||||
|
||||
mockedLocation.search = `?options=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
selectColumns: logsColumns,
|
||||
}),
|
||||
)}`;
|
||||
|
||||
const result = await logsLoaderConfig.url();
|
||||
|
||||
expect(result.columns).toEqual(logsColumns);
|
||||
});
|
||||
|
||||
it('should fall back to defaults when all columns are filtered out from URL', async () => {
|
||||
const tracesColumns = [...mockTracesColumns];
|
||||
|
||||
mockedLocation.search = `?options=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
selectColumns: tracesColumns,
|
||||
}),
|
||||
)}`;
|
||||
|
||||
const result = await logsLoaderConfig.url();
|
||||
|
||||
// Should return empty array, which triggers fallback to defaults in preferencesLoader
|
||||
expect(result.columns).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle columns without signal field (legacy data)', async () => {
|
||||
const columnsWithoutSignal = [
|
||||
{ name: 'body', fieldContext: 'log' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
];
|
||||
|
||||
mockedLocation.search = `?options=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
selectColumns: columnsWithoutSignal,
|
||||
}),
|
||||
)}`;
|
||||
|
||||
const result = await logsLoaderConfig.url();
|
||||
|
||||
// Without signal field, columns pass through validation
|
||||
// This matches the current implementation behavior where only columns
|
||||
// with signal !== 'logs' are filtered out
|
||||
expect(result.columns).toEqual(columnsWithoutSignal);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||