Merge branch 'main' into perf/panel-rerender

This commit is contained in:
Srikanth Chekuri
2025-12-16 09:44:30 +05:30
committed by GitHub
191 changed files with 16354 additions and 3725 deletions

40
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
View 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
View 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
}

View File

@@ -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.1
image: signoz/signoz:v0.104.0
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -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.1
image: signoz/signoz:v0.104.0
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -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.1}
image: signoz/signoz:${VERSION:-v0.104.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -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.1}
image: signoz/signoz:${VERSION:-v0.104.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

2293
docs/api/openapi.yml Normal file

File diff suppressed because it is too large Load Diff

View 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)

View File

@@ -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
}

View File

@@ -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",

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

View 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

View 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

View 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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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'
),
);

View File

@@ -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,

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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';

View File

@@ -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,
};

View File

@@ -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>

View File

@@ -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);
}
}
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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:');
});
});

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
import OverflowInputToolTip from './OverflowInputToolTip';
export default OverflowInputToolTip;

View File

@@ -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);
});
});

View 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;
}

View 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>
);
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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);

View File

@@ -266,7 +266,10 @@ export default function CustomDomainSettings(): JSX.Element {
<div className="custom-domain-settings-modal-error">
{updateDomainError.status === 409 ? (
<Alert
message="Youve 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 ||
'Youve 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"
/>

View File

@@ -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>
);
}

View File

@@ -1,3 +1,7 @@
.error-details-container {
padding: 16px;
}
.error-container {
height: 50vh;
}

View File

@@ -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>
</>
)}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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'),
}),
);
});
});
});

View File

@@ -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(),

View File

@@ -209,6 +209,15 @@
}
}
}
.time-series-view-container {
.time-series-view-container-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
}
}
}
}

View File

@@ -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 && (

View File

@@ -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 {

View File

@@ -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"
>

View File

@@ -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 />

View File

@@ -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);
}
}
}
}

View File

@@ -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);
}
}
}
}
}

View File

@@ -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',
);
});
});
});
});

View File

@@ -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&apos;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;

View File

@@ -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" />;

View File

@@ -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 (

View File

@@ -183,6 +183,7 @@ function ExplorerColumnsRenderer({
searchText={searchText}
isAttributeKeySelected={isAttributeKeySelected}
handleCheckboxChange={handleCheckboxChange}
dataSource={initialDataSource}
/>
),
},

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -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',

View File

@@ -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;
});

View 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);

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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();
});
});
});

View File

@@ -0,0 +1,3 @@
import PublicDashboardContainer from './PublicDashboardContainer';
export default PublicDashboardContainer;

View File

@@ -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"
/>
)}

View File

@@ -99,7 +99,7 @@
}
.query-function-value {
width: 55px;
width: 70px;
border-left: 0;
background: var(--bg-ink-200);
border-radius: 0;

View File

@@ -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);
}

View File

@@ -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',

View File

@@ -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;
}
}
}

View File

@@ -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>
);

View File

@@ -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: (

View 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,
});

View 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,
});

View File

@@ -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,

View File

@@ -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;

View 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,
},
],
},
],
},
],
},
],
},
},
};

View File

@@ -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;

View File

@@ -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
}
}
}
}

View 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;

View File

@@ -0,0 +1,8 @@
.trace-explorer-time-series-view-container {
&-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
}
}

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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;
}
}
}

View File

@@ -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>;
}

View File

@@ -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 {

View 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;
}

View File

@@ -0,0 +1,5 @@
export interface CreatePublicDashboardProps {
dashboardId: string;
timeRangeEnabled: boolean;
defaultTimeRange: string;
}

View File

@@ -0,0 +1,8 @@
export interface RevokePublicDashboardAccessProps {
id: string;
}
export interface PayloadProps {
status: string;
data: null;
}

Some files were not shown because too many files have changed in this diff Show More