Merge pull request #5922 from SigNoz/release/v0.54.x

Release/v0.54.x
This commit is contained in:
Prashant Shahi 2024-09-11 15:33:09 +05:30 committed by GitHub
commit 08f3b089f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
266 changed files with 11188 additions and 2196 deletions

View File

@ -8,6 +8,13 @@ on:
- release/v*
jobs:
check-no-ee-references:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run check
run: make check-no-ee-references
build-frontend:
runs-on: ubuntu-latest
steps:

View File

@ -1,7 +0,0 @@
#!/bin/sh
# It Comments out the Line Query-Service & Frontend Section of deploy/docker/clickhouse-setup/docker-compose.yaml
# Update the Line Numbers when deploy/docker/clickhouse-setup/docker-compose.yaml chnages.
# Docs Ref.: https://github.com/SigNoz/signoz/blob/main/CONTRIBUTING.md#contribute-to-frontend-with-docker-installation-of-signoz
sed -i 38,62's/.*/# &/' .././deploy/docker/clickhouse-setup/docker-compose.yaml

View File

@ -178,6 +178,15 @@ clear-swarm-ch:
@docker run --rm -v "$(PWD)/$(SWARM_DIRECTORY)/data:/pwd" busybox \
sh -c "cd /pwd && rm -rf clickhouse*/* zookeeper-*/*"
check-no-ee-references:
@echo "Checking for 'ee' package references in 'pkg' directory..."
@if grep -R --include="*.go" '.*/ee/.*' pkg/; then \
echo "Error: Found references to 'ee' packages in 'pkg' directory"; \
exit 1; \
else \
echo "No references to 'ee' packages found in 'pkg' directory"; \
fi
test:
go test ./pkg/query-service/app/metrics/...
go test ./pkg/query-service/cache/...

View File

@ -146,7 +146,7 @@ services:
condition: on-failure
query-service:
image: signoz/query-service:0.53.0
image: signoz/query-service:0.54.0
command:
[
"-config=/root/config/prometheus.yml",
@ -186,7 +186,7 @@ services:
<<: *db-depend
frontend:
image: signoz/frontend:0.53.0
image: signoz/frontend:0.54.0
deploy:
restart_policy:
condition: on-failure
@ -199,7 +199,7 @@ services:
- ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf
otel-collector:
image: signoz/signoz-otel-collector:0.102.7
image: signoz/signoz-otel-collector:0.102.8
command:
[
"--config=/etc/otel-collector-config.yaml",
@ -238,7 +238,7 @@ services:
- query-service
otel-collector-migrator:
image: signoz/signoz-schema-migrator:0.102.7
image: signoz/signoz-schema-migrator:0.102.8
deploy:
restart_policy:
condition: on-failure

View File

@ -66,7 +66,7 @@ services:
- --storage.path=/data
otel-collector-migrator:
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.7}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.8}
container_name: otel-migrator
command:
- "--dsn=tcp://clickhouse:9000"
@ -81,7 +81,7 @@ services:
# Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md`
otel-collector:
container_name: signoz-otel-collector
image: signoz/signoz-otel-collector:0.102.7
image: signoz/signoz-otel-collector:0.102.8
command:
[
"--config=/etc/otel-collector-config.yaml",

View File

@ -164,7 +164,7 @@ services:
# Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md`
query-service:
image: signoz/query-service:${DOCKER_TAG:-0.53.0}
image: signoz/query-service:${DOCKER_TAG:-0.54.0}
container_name: signoz-query-service
command:
[
@ -204,7 +204,7 @@ services:
<<: *db-depend
frontend:
image: signoz/frontend:${DOCKER_TAG:-0.53.0}
image: signoz/frontend:${DOCKER_TAG:-0.54.0}
container_name: signoz-frontend
restart: on-failure
depends_on:
@ -216,7 +216,7 @@ services:
- ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf
otel-collector-migrator:
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.7}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.8}
container_name: otel-migrator
command:
- "--dsn=tcp://clickhouse:9000"
@ -230,7 +230,7 @@ services:
otel-collector:
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.7}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.8}
container_name: signoz-otel-collector
command:
[

View File

@ -164,7 +164,7 @@ services:
# Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md`
query-service:
image: signoz/query-service:${DOCKER_TAG:-0.53.0}
image: signoz/query-service:${DOCKER_TAG:-0.54.0}
container_name: signoz-query-service
command:
[
@ -203,7 +203,7 @@ services:
<<: *db-depend
frontend:
image: signoz/frontend:${DOCKER_TAG:-0.53.0}
image: signoz/frontend:${DOCKER_TAG:-0.54.0}
container_name: signoz-frontend
restart: on-failure
depends_on:
@ -215,7 +215,7 @@ services:
- ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf
otel-collector-migrator:
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.7}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.8}
container_name: otel-migrator
command:
- "--dsn=tcp://clickhouse:9000"
@ -229,7 +229,7 @@ services:
otel-collector:
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.7}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.8}
container_name: signoz-otel-collector
command:
[

View File

@ -28,6 +28,7 @@ import (
"go.signoz.io/signoz/ee/query-service/dao"
"go.signoz.io/signoz/ee/query-service/integrations/gateway"
"go.signoz.io/signoz/ee/query-service/interfaces"
"go.signoz.io/signoz/ee/query-service/rules"
baseauth "go.signoz.io/signoz/pkg/query-service/auth"
"go.signoz.io/signoz/pkg/query-service/migrate"
"go.signoz.io/signoz/pkg/query-service/model"
@ -52,7 +53,7 @@ import (
baseint "go.signoz.io/signoz/pkg/query-service/interfaces"
basemodel "go.signoz.io/signoz/pkg/query-service/model"
pqle "go.signoz.io/signoz/pkg/query-service/pqlEngine"
rules "go.signoz.io/signoz/pkg/query-service/rules"
baserules "go.signoz.io/signoz/pkg/query-service/rules"
"go.signoz.io/signoz/pkg/query-service/telemetry"
"go.signoz.io/signoz/pkg/query-service/utils"
"go.uber.org/zap"
@ -81,7 +82,7 @@ type ServerOptions struct {
// Server runs HTTP api service
type Server struct {
serverOptions *ServerOptions
ruleManager *rules.Manager
ruleManager *baserules.Manager
// public http router
httpConn net.Listener
@ -727,7 +728,7 @@ func makeRulesManager(
db *sqlx.DB,
ch baseint.Reader,
disableRules bool,
fm baseint.FeatureLookup) (*rules.Manager, error) {
fm baseint.FeatureLookup) (*baserules.Manager, error) {
// create engine
pqle, err := pqle.FromConfigPath(promConfigPath)
@ -743,12 +744,9 @@ func makeRulesManager(
}
// create manager opts
managerOpts := &rules.ManagerOptions{
managerOpts := &baserules.ManagerOptions{
NotifierOpts: notifierOpts,
Queriers: &rules.Queriers{
PqlEngine: pqle,
Ch: ch.GetConn(),
},
PqlEngine: pqle,
RepoURL: ruleRepoURL,
DBConn: db,
Context: context.Background(),
@ -757,10 +755,12 @@ func makeRulesManager(
FeatureFlags: fm,
Reader: ch,
EvalDelay: baseconst.GetEvalDelay(),
PrepareTaskFunc: rules.PrepareTaskFunc,
}
// create Manager
manager, err := rules.NewManager(managerOpts)
manager, err := baserules.NewManager(managerOpts)
if err != nil {
return nil, fmt.Errorf("rule manager error: %v", err)
}

View File

@ -11,8 +11,6 @@ const (
var LicenseSignozIo = "https://license.signoz.io/api/v1"
var LicenseAPIKey = GetOrDefaultEnv("SIGNOZ_LICENSE_API_KEY", "")
var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "")
var SpanRenderLimitStr = GetOrDefaultEnv("SPAN_RENDER_LIMIT", "2500")
var MaxSpansInTraceStr = GetOrDefaultEnv("MAX_SPANS_IN_TRACE", "250000")
var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false")
var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL")

View File

@ -13,6 +13,7 @@ const Onboarding = "ONBOARDING"
const ChatSupport = "CHAT_SUPPORT"
const Gateway = "GATEWAY"
const PremiumSupport = "PREMIUM_SUPPORT"
const QueryBuilderSearchV2 = "QUERY_BUILDER_SEARCH_V2"
var BasicPlan = basemodel.FeatureSet{
basemodel.Feature{
@ -127,6 +128,13 @@ var BasicPlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: QueryBuilderSearchV2,
Active: false,
Usage: 0,
UsageLimit: -1,
Route: "",
},
}
var ProPlan = basemodel.FeatureSet{
@ -235,6 +243,13 @@ var ProPlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: QueryBuilderSearchV2,
Active: false,
Usage: 0,
UsageLimit: -1,
Route: "",
},
}
var EnterprisePlan = basemodel.FeatureSet{
@ -357,4 +372,11 @@ var EnterprisePlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: QueryBuilderSearchV2,
Active: false,
Usage: 0,
UsageLimit: -1,
Route: "",
},
}

View File

@ -0,0 +1,69 @@
package rules
import (
"fmt"
"time"
baserules "go.signoz.io/signoz/pkg/query-service/rules"
)
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
rules := make([]baserules.Rule, 0)
var task baserules.Task
ruleId := baserules.RuleIdFromTaskName(opts.TaskName)
if opts.Rule.RuleType == baserules.RuleTypeThreshold {
// create a threshold rule
tr, err := baserules.NewThresholdRule(
ruleId,
opts.Rule,
opts.FF,
opts.Reader,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
)
if err != nil {
return task, err
}
rules = append(rules, tr)
// create ch rule task for evalution
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB)
} else if opts.Rule.RuleType == baserules.RuleTypeProm {
// create promql rule
pr, err := baserules.NewPromRule(
ruleId,
opts.Rule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.PqlEngine,
)
if err != nil {
return task, err
}
rules = append(rules, pr)
// create promql rule task for evalution
task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB)
} else {
return nil, fmt.Errorf("unsupported rule type. Supported types: %s, %s", baserules.RuleTypeProm, baserules.RuleTypeThreshold)
}
return task, nil
}
// newTask returns an appropriate group for
// rule type
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, ruleDB baserules.RuleDB) baserules.Task {
if taskType == baserules.TaskTypeCh {
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, ruleDB)
}
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify, ruleDB)
}

View File

@ -51,7 +51,7 @@
"ansi-to-html": "0.7.2",
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
"axios": "1.6.4",
"axios": "1.7.4",
"babel-eslint": "^10.1.0",
"babel-jest": "^29.6.4",
"babel-loader": "9.1.3",
@ -88,7 +88,7 @@
"lucide-react": "0.379.0",
"mini-css-extract-plugin": "2.4.5",
"papaparse": "5.4.1",
"posthog-js": "1.142.1",
"posthog-js": "1.160.3",
"rc-tween-one": "3.0.6",
"react": "18.2.0",
"react-addons-update": "15.6.3",

View File

@ -0,0 +1,30 @@
{
"breadcrumb": "Messaging Queues",
"header": "Kafka / Overview",
"overview": {
"title": "Start sending data in as little as 20 minutes",
"subtitle": "Connect and Monitor Your Data Streams"
},
"configureConsumer": {
"title": "Configure Consumer",
"description": "Add consumer data sources to gain insights and enhance monitoring.",
"button": "Get Started"
},
"configureProducer": {
"title": "Configure Producer",
"description": "Add producer data sources to gain insights and enhance monitoring.",
"button": "Get Started"
},
"monitorKafka": {
"title": "Monitor kafka",
"description": "Add your Kafka source to gain insights and enhance activity tracking.",
"button": "Get Started"
},
"summarySection": {
"viewDetailsButton": "View Details"
},
"confirmModal": {
"content": "Before navigating to the details page, please make sure you have configured all the required setup to ensure correct data monitoring.",
"okText": "Proceed"
}
}

View File

@ -38,5 +38,7 @@
"LIST_LICENSES": "SigNoz | List of Licenses",
"WORKSPACE_LOCKED": "SigNoz | Workspace Locked",
"SUPPORT": "SigNoz | Support",
"DEFAULT": "Open source Observability Platform | SigNoz"
"DEFAULT": "Open source Observability Platform | SigNoz",
"ALERT_HISTORY": "SigNoz | Alert Rule History",
"ALERT_OVERVIEW": "SigNoz | Alert Rule Overview"
}

View File

@ -0,0 +1,22 @@
{
"trialPlanExpired": "Trial Plan Expired",
"gotQuestions": "Got Questions?",
"contactUs": "Contact Us",
"upgradeToContinue": "Upgrade to Continue",
"upgradeNow": "Upgrade now to keep enjoying all the great features youve been using.",
"yourDataIsSafe": "Your data is safe with us until",
"actNow": "Act now to avoid any disruptions and continue where you left off.",
"contactAdmin": "Contact your admin to proceed with the upgrade.",
"continueMyJourney": "Continue My Journey",
"needMoreTime": "Need More Time?",
"extendTrial": "Extend Trial",
"extendTrialMsgPart1": "If you have a specific reason why you were not able to finish your PoC in the trial period, please write to us on",
"extendTrialMsgPart2": "with the reason. Sometimes we can extend trial by a few days on a case by case basis",
"whyChooseSignoz": "Why choose Signoz",
"enterpriseGradeObservability": "Enterprise-grade Observability",
"observabilityDescription": "Get access to observability at any scale with advanced security and compliance.",
"continueToUpgrade": "Continue to Upgrade",
"youAreInGoodCompany": "You are in good company",
"faqs": "FAQs",
"somethingWentWrong": "Something went wrong"
}

View File

@ -0,0 +1,30 @@
{
"breadcrumb": "Messaging Queues",
"header": "Kafka / Overview",
"overview": {
"title": "Start sending data in as little as 20 minutes",
"subtitle": "Connect and Monitor Your Data Streams"
},
"configureConsumer": {
"title": "Configure Consumer",
"description": "Add consumer data sources to gain insights and enhance monitoring.",
"button": "Get Started"
},
"configureProducer": {
"title": "Configure Producer",
"description": "Add producer data sources to gain insights and enhance monitoring.",
"button": "Get Started"
},
"monitorKafka": {
"title": "Monitor kafka",
"description": "Add your Kafka source to gain insights and enhance activity tracking.",
"button": "Get Started"
},
"summarySection": {
"viewDetailsButton": "View Details"
},
"confirmModal": {
"content": "Before navigating to the details page, please make sure you have configured all the required setup to ensure correct data monitoring.",
"okText": "Proceed"
}
}

View File

@ -50,5 +50,7 @@
"DEFAULT": "Open source Observability Platform | SigNoz",
"SHORTCUTS": "SigNoz | Shortcuts",
"INTEGRATIONS": "SigNoz | Integrations",
"ALERT_HISTORY": "SigNoz | Alert Rule History",
"ALERT_OVERVIEW": "SigNoz | Alert Rule Overview",
"MESSAGING_QUEUES": "SigNoz | Messaging Queues"
}

View File

@ -0,0 +1,22 @@
{
"trialPlanExpired": "Trial Plan Expired",
"gotQuestions": "Got Questions?",
"contactUs": "Contact Us",
"upgradeToContinue": "Upgrade to Continue",
"upgradeNow": "Upgrade now to keep enjoying all the great features youve been using.",
"yourDataIsSafe": "Your data is safe with us until",
"actNow": "Act now to avoid any disruptions and continue where you left off.",
"contactAdmin": "Contact your admin to proceed with the upgrade.",
"continueMyJourney": "Continue My Journey",
"needMoreTime": "Need More Time?",
"extendTrial": "Extend Trial",
"extendTrialMsgPart1": "If you have a specific reason why you were not able to finish your PoC in the trial period, please write to us on",
"extendTrialMsgPart2": "with the reason. Sometimes we can extend trial by a few days on a case by case basis",
"whyChooseSignoz": "Why choose Signoz",
"enterpriseGradeObservability": "Enterprise-grade Observability",
"observabilityDescription": "Get access to observability at any scale with advanced security and compliance.",
"continueToUpgrade": "Continue to Upgrade",
"youAreInGoodCompany": "You are in good company",
"faqs": "FAQs",
"somethingWentWrong": "Something went wrong"
}

View File

@ -19,6 +19,7 @@ import { ResourceProvider } from 'hooks/useResourceAttribute';
import history from 'lib/history';
import { identity, pick, pickBy } from 'lodash-es';
import posthog from 'posthog-js';
import AlertRuleProvider from 'providers/Alert';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { Suspense, useEffect, useState } from 'react';
@ -236,22 +237,24 @@ function App(): JSX.Element {
<QueryBuilderProvider>
<DashboardProvider>
<KeyboardHotkeysProvider>
<AppLayout>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<AlertRuleProvider>
<AppLayout>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppLayout>
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppLayout>
</AlertRuleProvider>
</KeyboardHotkeysProvider>
</DashboardProvider>
</QueryBuilderProvider>

View File

@ -92,6 +92,14 @@ export const CreateNewAlerts = Loadable(
() => import(/* webpackChunkName: "Create Alerts" */ 'pages/CreateAlert'),
);
export const AlertHistory = Loadable(
() => import(/* webpackChunkName: "Alert History" */ 'pages/AlertList'),
);
export const AlertOverview = Loadable(
() => import(/* webpackChunkName: "Alert Overview" */ 'pages/AlertList'),
);
export const CreateAlertChannelAlerts = Loadable(
() =>
import(/* webpackChunkName: "Create Channels" */ 'pages/AlertChannelCreate'),

View File

@ -2,6 +2,8 @@ import ROUTES from 'constants/routes';
import { RouteProps } from 'react-router-dom';
import {
AlertHistory,
AlertOverview,
AllAlertChannels,
AllErrors,
APIKeys,
@ -171,6 +173,20 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'ALERTS_NEW',
},
{
path: ROUTES.ALERT_HISTORY,
exact: true,
component: AlertHistory,
isPrivate: true,
key: 'ALERT_HISTORY',
},
{
path: ROUTES.ALERT_OVERVIEW,
exact: true,
component: AlertOverview,
isPrivate: true,
key: 'ALERT_OVERVIEW',
},
{
path: ROUTES.TRACE,
exact: true,

View File

@ -1,26 +1,20 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/create';
const create = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post('/rules', {
...props.data,
});
const response = await axios.post('/rules', {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default create;

View File

@ -1,24 +1,18 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/delete';
const deleteAlerts = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.delete(`/rules/${props.id}`);
const response = await axios.delete(`/rules/${props.id}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data.rules,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data.rules,
};
};
export default deleteAlerts;

View File

@ -1,24 +1,16 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/get';
const get = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/rules/${props.id}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
const response = await axios.get(`/rules/${props.id}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
};
export default get;

View File

@ -1,26 +1,20 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/patch';
const patch = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.patch(`/rules/${props.id}`, {
...props.data,
});
const response = await axios.patch(`/rules/${props.id}`, {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default patch;

View File

@ -1,26 +1,20 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/save';
const put = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/rules/${props.id}`, {
...props.data,
});
const response = await axios.put(`/rules/${props.id}`, {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default put;

View File

@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleStatsPayload } from 'types/api/alerts/def';
import { RuleStatsProps } from 'types/api/alerts/ruleStats';
const ruleStats = async (
props: RuleStatsProps,
): Promise<SuccessResponse<AlertRuleStatsPayload> | ErrorResponse> => {
try {
const response = await axios.post(`/rules/${props.id}/history/stats`, {
start: props.start,
end: props.end,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default ruleStats;

View File

@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def';
import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph';
const timelineGraph = async (
props: GetTimelineGraphRequestProps,
): Promise<
SuccessResponse<AlertRuleTimelineGraphResponsePayload> | ErrorResponse
> => {
try {
const response = await axios.post(
`/rules/${props.id}/history/overall_status`,
{
start: props.start,
end: props.end,
},
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default timelineGraph;

View File

@ -0,0 +1,36 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def';
import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable';
const timelineTable = async (
props: GetTimelineTableRequestProps,
): Promise<
SuccessResponse<AlertRuleTimelineTableResponsePayload> | ErrorResponse
> => {
try {
const response = await axios.post(`/rules/${props.id}/history/timeline`, {
start: props.start,
end: props.end,
offset: props.offset,
limit: props.limit,
order: props.order,
state: props.state,
// TODO(shaheer): implement filters
filters: props.filters,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default timelineTable;

View File

@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def';
import { TopContributorsProps } from 'types/api/alerts/topContributors';
const topContributors = async (
props: TopContributorsProps,
): Promise<
SuccessResponse<AlertRuleTopContributorsPayload> | ErrorResponse
> => {
try {
const response = await axios.post(
`/rules/${props.id}/history/top_contributors`,
{
start: props.start,
end: props.end,
},
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default topContributors;

View File

@ -0,0 +1,41 @@
interface ConfigureIconProps {
width?: number;
height?: number;
fill?: string;
}
function ConfigureIcon({
width,
height,
fill,
}: ConfigureIconProps): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
>
<path
stroke="#C0C1C3"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.333"
d="M9.71 4.745a.576.576 0 000 .806l.922.922a.576.576 0 00.806 0l2.171-2.171a3.455 3.455 0 01-4.572 4.572l-3.98 3.98a1.222 1.222 0 11-1.727-1.728l3.98-3.98a3.455 3.455 0 014.572-4.572L9.717 4.739l-.006.006z"
/>
<path
stroke="#C0C1C3"
strokeLinecap="round"
strokeWidth="1.333"
d="M4 7L2.527 5.566a1.333 1.333 0 01-.013-1.898l.81-.81a1.333 1.333 0 011.991.119L5.333 3m5.417 7.988l1.179 1.178m0 0l-.138.138a.833.833 0 00.387 1.397v0a.833.833 0 00.792-.219l.446-.446a.833.833 0 00.176-.917v0a.833.833 0 00-1.355-.261l-.308.308z"
/>
</svg>
);
}
ConfigureIcon.defaultProps = {
width: 16,
height: 16,
fill: 'none',
};
export default ConfigureIcon;

View File

@ -0,0 +1,65 @@
interface LogsIconProps {
width?: number;
height?: number;
fill?: string;
strokeColor?: string;
strokeWidth?: number;
}
function LogsIcon({
width,
height,
fill,
strokeColor,
strokeWidth,
}: LogsIconProps): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
>
<path
stroke={strokeColor}
strokeWidth={strokeWidth}
d="M2.917 3.208v7.875"
/>
<ellipse
cx="6.417"
cy="3.208"
stroke={strokeColor}
strokeWidth={strokeWidth}
rx="3.5"
ry="1.458"
/>
<ellipse cx="6.417" cy="3.165" fill={strokeColor} rx="0.875" ry="0.365" />
<path
stroke={strokeColor}
strokeWidth={strokeWidth}
d="M9.917 11.083c0 .645-1.567 1.167-3.5 1.167s-3.5-.522-3.5-1.167"
/>
<path
stroke={strokeColor}
strokeLinecap="round"
strokeWidth={strokeWidth}
d="M5.25 6.417v1.117c0 .028.02.053.049.057l1.652.276A.058.058 0 017 7.924v1.993"
/>
<path
stroke={strokeColor}
strokeWidth={strokeWidth}
d="M9.917 3.208v3.103c0 .046.05.074.089.05L12.182 5a.058.058 0 01.088.035l.264 1.059a.058.058 0 01-.013.053l-2.59 2.877a.058.058 0 00-.014.04v2.018"
/>
</svg>
);
}
LogsIcon.defaultProps = {
width: 14,
height: 14,
fill: 'none',
strokeColor: '#C0C1C3',
strokeWidth: 1.167,
};
export default LogsIcon;

View File

@ -0,0 +1,39 @@
interface SeverityCriticalIconProps {
width?: number;
height?: number;
fill?: string;
stroke?: string;
}
function SeverityCriticalIcon({
width,
height,
fill,
stroke,
}: SeverityCriticalIconProps): JSX.Element {
return (
<svg
width={width}
height={height}
fill={fill}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.99707.666056.99707 2.99939M.99707 5.33337H.991237M3.00293.666056 3.00293 2.99939M3.00293 5.33337H2.9971M5.00879.666056V2.99939M5.00879 5.33337H5.00296"
stroke={stroke}
strokeWidth="1.16667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
SeverityCriticalIcon.defaultProps = {
width: 6,
height: 6,
fill: 'none',
stroke: '#F56C87',
};
export default SeverityCriticalIcon;

View File

@ -0,0 +1,42 @@
interface SeverityErrorIconProps {
width?: number;
height?: number;
fill?: string;
stroke?: string;
strokeWidth?: string;
}
function SeverityErrorIcon({
width,
height,
fill,
stroke,
strokeWidth,
}: SeverityErrorIconProps): JSX.Element {
return (
<svg
width={width}
height={height}
fill={fill}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.00781.957845 1.00781 2.99951M1.00781 5.04175H1.00228"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
SeverityErrorIcon.defaultProps = {
width: 2,
height: 6,
fill: 'none',
stroke: '#F56C87',
strokeWidth: '1.02083',
};
export default SeverityErrorIcon;

View File

@ -0,0 +1,46 @@
interface SeverityInfoIconProps {
width?: number;
height?: number;
fill?: string;
stroke?: string;
}
function SeverityInfoIcon({
width,
height,
fill,
stroke,
}: SeverityInfoIconProps): JSX.Element {
return (
<svg
width={width}
height={height}
fill={fill}
xmlns="http://www.w3.org/2000/svg"
>
<rect
width={width}
height={height}
rx="3.5"
fill={stroke}
fillOpacity=".2"
/>
<path
d="M7 9.33346V7.00012M7 4.66675H7.00583"
stroke={stroke}
strokeWidth="1.16667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
SeverityInfoIcon.defaultProps = {
width: 14,
height: 14,
fill: 'none',
stroke: '#7190F9',
};
export default SeverityInfoIcon;

View File

@ -0,0 +1,42 @@
interface SeverityWarningIconProps {
width?: number;
height?: number;
fill?: string;
stroke?: string;
strokeWidth?: string;
}
function SeverityWarningIcon({
width,
height,
fill,
stroke,
strokeWidth,
}: SeverityWarningIconProps): JSX.Element {
return (
<svg
width={width}
height={height}
fill={fill}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.00732.957845 1.00732 2.99951M1.00732 5.04175H1.00179"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
SeverityWarningIcon.defaultProps = {
width: 2,
height: 6,
fill: 'none',
stroke: '#FFD778',
strokeWidth: '0.978299',
};
export default SeverityWarningIcon;

View File

@ -0,0 +1,14 @@
.reset-button {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
}
.lightMode {
.reset-button {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
}

View File

@ -0,0 +1,11 @@
import './Filters.styles.scss';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
export function Filters(): JSX.Element {
return (
<div className="filters">
<DateTimeSelector showAutoRefresh={false} hideShareModal showResetButton />
</div>
);
}

View File

@ -0,0 +1,145 @@
.checkbox-filter {
display: flex;
flex-direction: column;
padding: 12px;
gap: 12px;
border-bottom: 1px solid var(--bg-slate-400);
.filter-header-checkbox {
display: flex;
align-items: center;
justify-content: space-between;
.left-action {
display: flex;
align-items: center;
gap: 6px;
.title {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
text-transform: capitalize;
}
}
.right-action {
display: flex;
align-items: center;
.clear-all {
font-size: 12px;
color: var(--bg-robin-500);
cursor: pointer;
}
}
}
.values {
display: flex;
flex-direction: column;
gap: 8px;
.value {
display: flex;
align-items: center;
gap: 8px;
.checkbox-value-section {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
cursor: pointer;
&.filter-disabled {
cursor: not-allowed;
.value-string {
color: var(--bg-slate-200);
}
.only-btn {
cursor: not-allowed;
color: var(--bg-slate-200);
}
.toggle-btn {
cursor: not-allowed;
color: var(--bg-slate-200);
}
}
.value-string {
}
.only-btn {
display: none;
}
.toggle-btn {
display: none;
}
.toggle-btn:hover {
background-color: unset;
}
.only-btn:hover {
background-color: unset;
}
}
.checkbox-value-section:hover {
.toggle-btn {
display: none;
}
.only-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
}
}
.value:hover {
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
}
}
.no-data {
align-self: center;
}
.show-more {
display: flex;
align-items: center;
justify-content: center;
.show-more-text {
color: var(--bg-robin-500);
cursor: pointer;
}
}
}
.lightMode {
.checkbox-filter {
border-bottom: 1px solid var(--bg-vanilla-300);
.filter-header-checkbox {
.left-action {
.title {
color: var(--bg-ink-400);
}
}
}
}
}

View File

@ -0,0 +1,510 @@
/* eslint-disable no-nested-ternary */
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import './Checkbox.styles.scss';
import { Button, Checkbox, Input, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters';
import { OPERATORS } from 'constants/queryBuilder';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep, isArray, isEmpty, isEqual } from 'lodash-es';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useMemo, useState } from 'react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'nin'];
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
): Record<string, boolean> {
const defaultState: Record<string, boolean> = {};
values.forEach((val) => {
defaultState[val] = trueOrFalse;
});
return defaultState;
}
interface ICheckboxProps {
filter: IQuickFiltersConfig;
}
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const { filter } = props;
const [searchText, setSearchText] = useState<string>('');
const [isOpen, setIsOpen] = useState<boolean>(filter.defaultOpen);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
const {
lastUsedQuery,
currentQuery,
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: 'noop',
dataSource: DataSource.LOGS,
aggregateAttribute: '',
attributeKey: filter.attributeKey.key,
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
tagType: filter.attributeKey.type || '',
searchText: searchText ?? '',
},
{
enabled: isOpen,
keepPreviousData: true,
},
);
const attributeValues: string[] = useMemo(
() =>
((Object.values(data?.payload || {}).find((el) => !!el) ||
[]) as string[]).filter((val) => !isEmpty(val)),
[data?.payload],
);
const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount);
// derive the state of each filter key here in the renderer itself and keep it in sync with staged query
// also we need to keep a note of last focussed query.
// eslint-disable-next-line sonarjs/cognitive-complexity
const currentFilterState = useMemo(() => {
let filterState: Record<string, boolean> = setDefaultValues(
attributeValues,
false,
);
const filterSync = currentQuery?.builder.queryData?.[
lastUsedQuery || 0
]?.filters?.items.find((item) =>
isEqual(item.key?.key, filter.attributeKey.key),
);
if (filterSync) {
if (SELECTED_OPERATORS.includes(filterSync.op)) {
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[val] = true;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = true;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = true;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = true;
}
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
filterState = setDefaultValues(attributeValues, true);
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[val] = false;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = false;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = false;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = false;
}
}
} else {
filterState = setDefaultValues(attributeValues, true);
}
return filterState;
}, [
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
lastUsedQuery,
]);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(currentQuery?.builder?.queryData?.[
lastUsedQuery || 0
]?.filters?.items?.filter((item) =>
isEqual(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1,
[currentQuery?.builder?.queryData, lastUsedQuery, filter.attributeKey],
);
// variable to check if the current filter has multiple values to its name in the key op value section
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
const handleClearFilterAttribute = (): void => {
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filters: {
...item.filters,
items:
idx === lastUsedQuery
? item.filters.items.filter(
(fil) => !isEqual(fil.key?.key, filter.attributeKey.key),
)
: [...item.filters.items],
},
})),
},
};
redirectWithQueryBuilderData(preparedQuery);
};
const isSomeFilterPresentForCurrentAttribute = currentQuery.builder.queryData?.[
lastUsedQuery || 0
]?.filters?.items?.some((item) =>
isEqual(item.key?.key, filter.attributeKey.key),
);
const onChange = (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
const query = cloneDeep(currentQuery.builder.queryData?.[lastUsedQuery || 0]);
// if only or all are clicked we do not need to worry about anything just override whatever we have
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
if (isOnlyOrAllClicked && query?.filters?.items) {
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only';
query.filters.items = query.filters.items.filter(
(q) => !isEqual(q.key?.key, filter.attributeKey.key),
);
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
} else if (query?.filters?.items) {
if (
query.filters?.items?.some((item) =>
isEqual(item.key?.key, filter.attributeKey.key),
)
) {
// if there is already a running filter for the current attribute key then
// we split the cases by which particular operator is present right now!
const currentFilter = query.filters?.items?.find((q) =>
isEqual(q.key?.key, filter.attributeKey.key),
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
// if it's an IN operator then if we are checking another value it get's added to the
// filter clause. example - key IN [value1, currentSelectedValue]
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// if the current state wasn't an array we make it one and add our value
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
// if not an array remove the whole thing altogether!
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case 'nin':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (checked) {
// opposite of above!
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case '=':
if (checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.IN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (!checked) {
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key),
);
}
break;
case '!=':
if (!checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.NIN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (checked) {
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key),
);
}
break;
default:
break;
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.NIN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
}
const finalQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === lastUsedQuery) {
return query;
}
return q;
}),
],
},
};
redirectWithQueryBuilderData(finalQuery);
};
return (
<div className="checkbox-filter">
<section className="filter-header-checkbox">
<section className="left-action">
{isOpen ? (
<ChevronDown
size={13}
cursor="pointer"
onClick={(): void => {
setIsOpen(false);
setVisibleItemsCount(10);
}}
/>
) : (
<ChevronRight
size={13}
onClick={(): void => setIsOpen(true)}
cursor="pointer"
/>
)}
<Typography.Text className="title">{filter.title}</Typography.Text>
</section>
<section className="right-action">
{isOpen && (
<Typography.Text
className="clear-all"
onClick={handleClearFilterAttribute}
>
Clear All
</Typography.Text>
)}
</section>
</section>
{isOpen && isLoading && !attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
</section>
)}
{isOpen && !isLoading && (
<>
<section className="search">
<Input
placeholder="Filter values"
onChange={(e): void => setSearchText(e.target.value)}
disabled={isFilterDisabled}
/>
</section>
{attributeValues.length > 0 ? (
<section className="values">
{currentAttributeKeys.map((value: string) => (
<div key={value} className="value">
<Checkbox
onChange={(e): void => onChange(value, e.target.checked, false)}
checked={currentFilterState[value]}
disabled={isFilterDisabled}
rootClassName="check-box"
/>
<div
className={cx(
'checkbox-value-section',
isFilterDisabled ? 'filter-disabled' : '',
)}
onClick={(): void => {
if (isFilterDisabled) {
return;
}
onChange(value, currentFilterState[value], true);
}}
>
{filter.customRendererForValue ? (
filter.customRendererForValue(value)
) : (
<Typography.Text
className="value-string"
ellipsis={{ tooltip: { placement: 'right' } }}
>
{value}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
))}
</section>
) : (
<section className="no-data">
<Typography.Text>No values found</Typography.Text>{' '}
</section>
)}
{visibleItemsCount < attributeValues?.length && (
<section className="show-more">
<Typography.Text
className="show-more-text"
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
>
Show More...
</Typography.Text>
</section>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,14 @@
import './Slider.styles.scss';
import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters';
interface ISliderProps {
filter: IQuickFiltersConfig;
}
// not needed for now build when required
export default function Slider(props: ISliderProps): JSX.Element {
const { filter } = props;
console.log(filter);
return <div>Slider</div>;
}

View File

@ -0,0 +1,93 @@
.quick-filters {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid var(--bg-slate-400);
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10.5px;
border-bottom: 1px solid var(--bg-slate-400);
.left-actions {
display: flex;
align-items: center;
gap: 6px;
.text {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.sync-tag {
display: flex;
padding: 5px 9px;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 2px;
border: 1px solid rgba(78, 116, 248, 0.2);
background: rgba(78, 116, 248, 0.1);
color: var(--bg-robin-500);
font-family: 'Geist Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
text-transform: uppercase;
}
}
.right-actions {
display: flex;
align-items: center;
gap: 12px;
.divider-filter {
width: 1px;
height: 14px;
background: #161922;
}
.sync-icon {
background-color: var(--bg-ink-500);
border: 0;
box-shadow: none;
}
}
}
}
.lightMode {
.quick-filters {
background-color: var(--bg-vanilla-100);
border-right: 1px solid var(--bg-vanilla-300);
.header {
border-bottom: 1px solid var(--bg-vanilla-300);
.left-actions {
.text {
color: var(--bg-ink-400);
}
.sync-icon {
background-color: var(--bg-vanilla-100);
}
}
.right-actions {
.sync-icon {
background-color: var(--bg-vanilla-100);
}
}
}
}
}

View File

@ -0,0 +1,124 @@
import './QuickFilters.styles.scss';
import {
FilterOutlined,
SyncOutlined,
VerticalAlignTopOutlined,
} from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import Slider from './FilterRenderers/Slider/Slider';
export enum FiltersType {
SLIDER = 'SLIDER',
CHECKBOX = 'CHECKBOX',
}
export enum MinMax {
MIN = 'MIN',
MAX = 'MAX',
}
export enum SpecficFilterOperations {
ALL = 'ALL',
ONLY = 'ONLY',
}
export interface IQuickFiltersConfig {
type: FiltersType;
title: string;
attributeKey: BaseAutocompleteData;
customRendererForValue?: (value: string) => JSX.Element;
defaultOpen: boolean;
}
interface IQuickFiltersProps {
config: IQuickFiltersConfig[];
handleFilterVisibilityChange: () => void;
}
export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const { config, handleFilterVisibilityChange } = props;
const {
currentQuery,
lastUsedQuery,
redirectWithQueryBuilderData,
} = useQueryBuilder();
// clear all the filters for the query which is in sync with filters
const handleReset = (): void => {
const updatedQuery = cloneDeep(
currentQuery?.builder.queryData?.[lastUsedQuery || 0],
);
if (!updatedQuery) {
return;
}
if (updatedQuery?.filters?.items) {
updatedQuery.filters.items = [];
}
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filters: {
...item.filters,
items: idx === lastUsedQuery ? [] : [...item.filters.items],
},
})),
},
};
redirectWithQueryBuilderData(preparedQuery);
};
const lastQueryName =
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
return (
<div className="quick-filters">
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">Filters for</Typography.Text>
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
</Tooltip>
</section>
<section className="right-actions">
<Tooltip title="Reset All">
<SyncOutlined className="sync-icon" onClick={handleReset} />
</Tooltip>
<div className="divider-filter" />
<Tooltip title="Collapse Filters">
<VerticalAlignTopOutlined
rotate={270}
onClick={handleFilterVisibilityChange}
/>
</Tooltip>
</section>
</section>
<section className="filters">
{config.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return <Checkbox filter={filter} />;
case FiltersType.SLIDER:
return <Slider filter={filter} />;
default:
return <Checkbox filter={filter} />;
}
})}
</section>
</div>
);
}

View File

@ -0,0 +1,5 @@
.tab-title {
display: flex;
gap: 4px;
align-items: center;
}

View File

@ -0,0 +1,41 @@
import './Tabs.styles.scss';
import { Radio } from 'antd';
import { RadioChangeEvent } from 'antd/lib';
import { History, Table } from 'lucide-react';
import { useState } from 'react';
import { ALERT_TABS } from '../constants';
export function Tabs(): JSX.Element {
const [selectedTab, setSelectedTab] = useState('overview');
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedTab(e.target.value);
};
return (
<Radio.Group className="tabs" onChange={handleTabChange} value={selectedTab}>
<Radio.Button
className={
selectedTab === ALERT_TABS.OVERVIEW ? 'selected_view tab' : 'tab'
}
value={ALERT_TABS.OVERVIEW}
>
<div className="tab-title">
<Table size={14} />
Overview
</div>
</Radio.Button>
<Radio.Button
className={selectedTab === ALERT_TABS.HISTORY ? 'selected_view tab' : 'tab'}
value={ALERT_TABS.HISTORY}
>
<div className="tab-title">
<History size={14} />
History
</div>
</Radio.Button>
</Radio.Group>
);
}

View File

@ -0,0 +1,18 @@
@mixin flex-center {
display: flex;
justify-content: space-between;
align-items: center;
}
.tabs-and-filters {
@include flex-center;
margin-top: 1rem;
margin-bottom: 1rem;
.filters {
@include flex-center;
gap: 16px;
.reset-button {
@include flex-center;
}
}
}

View File

@ -0,0 +1,16 @@
import './TabsAndFilters.styles.scss';
import { Filters } from 'components/AlertDetailsFilters/Filters';
import { Tabs } from './Tabs/Tabs';
function TabsAndFilters(): JSX.Element {
return (
<div className="tabs-and-filters">
<Tabs />
<Filters />
</div>
);
}
export default TabsAndFilters;

View File

@ -0,0 +1,5 @@
export const ALERT_TABS = {
OVERVIEW: 'OVERVIEW',
HISTORY: 'HISTORY',
ACTIVITY: 'ACTIVITY',
} as const;

View File

@ -21,4 +21,5 @@ export enum FeatureKeys {
CHAT_SUPPORT = 'CHAT_SUPPORT',
GATEWAY = 'GATEWAY',
PREMIUM_SUPPORT = 'PREMIUM_SUPPORT',
QUERY_BUILDER_SEARCH_V2 = 'QUERY_BUILDER_SEARCH_V2',
}

View File

@ -1,4 +1,17 @@
import { ManipulateType } from 'dayjs';
const MAX_RPS_LIMIT = 100;
export { MAX_RPS_LIMIT };
export const LEGEND = 'legend';
export const DAYJS_MANIPULATE_TYPES: { [key: string]: ManipulateType } = {
DAY: 'day',
WEEK: 'week',
MONTH: 'month',
YEAR: 'year',
HOUR: 'hour',
MINUTE: 'minute',
SECOND: 'second',
MILLISECOND: 'millisecond',
};

View File

@ -19,4 +19,6 @@ export enum LOCALSTORAGE {
SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR',
PINNED_ATTRIBUTES = 'PINNED_ATTRIBUTES',
THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1',
LAST_USED_SAVED_VIEWS = 'LAST_USED_SAVED_VIEWS',
SHOW_LOGS_QUICK_FILTERS = 'SHOW_LOGS_QUICK_FILTERS',
}

View File

@ -8,5 +8,14 @@ export const REACT_QUERY_KEY = {
GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS',
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS',
ALERT_RULE_STATS: 'ALERT_RULE_STATS',
ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS',
ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE',
ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH',
GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS',
TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE',
GET_ALL_ALLERTS: 'GET_ALL_ALLERTS',
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
};

View File

@ -22,6 +22,8 @@ const ROUTES = {
EDIT_ALERTS: '/alerts/edit',
LIST_ALL_ALERT: '/alerts',
ALERTS_NEW: '/alerts/new',
ALERT_HISTORY: '/alerts/history',
ALERT_OVERVIEW: '/alerts/overview',
ALL_CHANNELS: '/settings/channels',
CHANNELS_NEW: '/settings/channels/new',
CHANNELS_EDIT: '/settings/channels/:id',

View File

@ -0,0 +1,5 @@
.alert-history {
display: flex;
flex-direction: column;
gap: 24px;
}

View File

@ -0,0 +1,22 @@
import './AlertHistory.styles.scss';
import { useState } from 'react';
import Statistics from './Statistics/Statistics';
import Timeline from './Timeline/Timeline';
function AlertHistory(): JSX.Element {
const [totalCurrentTriggers, setTotalCurrentTriggers] = useState(0);
return (
<div className="alert-history">
<Statistics
totalCurrentTriggers={totalCurrentTriggers}
setTotalCurrentTriggers={setTotalCurrentTriggers}
/>
<Timeline totalCurrentTriggers={totalCurrentTriggers} />
</div>
);
}
export default AlertHistory;

View File

@ -0,0 +1,21 @@
.alert-popover-trigger-action {
cursor: pointer;
}
.alert-history-popover {
.ant-popover-inner {
border: 1px solid var(--bg-slate-400);
.lightMode & {
background: var(--bg-vanilla-100) !important;
border: 1px solid var(--bg-vanilla-300);
}
}
.ant-popover-arrow {
&::before {
.lightMode & {
background: var(--bg-vanilla-100);
}
}
}
}

View File

@ -0,0 +1,115 @@
import './AlertPopover.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Popover } from 'antd';
import LogsIcon from 'assets/AlertHistory/LogsIcon';
import ROUTES from 'constants/routes';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { DraftingCompass } from 'lucide-react';
import React from 'react';
import { Link } from 'react-router-dom';
type Props = {
children: React.ReactNode;
relatedTracesLink?: string;
relatedLogsLink?: string;
};
function PopoverContent({
relatedTracesLink,
relatedLogsLink,
}: {
relatedTracesLink?: Props['relatedTracesLink'];
relatedLogsLink?: Props['relatedLogsLink'];
}): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<div className="contributor-row-popover-buttons">
{!!relatedLogsLink && (
<Link
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
className="contributor-row-popover-buttons__button"
>
<div className="icon">
<LogsIcon />
</div>
<div className="text">View Logs</div>
</Link>
)}
{!!relatedTracesLink && (
<Link
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
className="contributor-row-popover-buttons__button"
>
<div className="icon">
<DraftingCompass
size={14}
color={isDarkMode ? Color.BG_VANILLA_400 : Color.TEXT_INK_400}
/>
</div>
<div className="text">View Traces</div>
</Link>
)}
</div>
);
}
PopoverContent.defaultProps = {
relatedTracesLink: '',
relatedLogsLink: '',
};
function AlertPopover({
children,
relatedTracesLink,
relatedLogsLink,
}: Props): JSX.Element {
return (
<div className="alert-popover-trigger-action">
<Popover
showArrow={false}
placement="bottom"
color="linear-gradient(139deg, rgba(18, 19, 23, 1) 0%, rgba(18, 19, 23, 1) 98.68%)"
destroyTooltipOnHide
rootClassName="alert-history-popover"
content={
<PopoverContent
relatedTracesLink={relatedTracesLink}
relatedLogsLink={relatedLogsLink}
/>
}
trigger="click"
>
{children}
</Popover>
</div>
);
}
AlertPopover.defaultProps = {
relatedTracesLink: '',
relatedLogsLink: '',
};
type ConditionalAlertPopoverProps = {
relatedTracesLink: string;
relatedLogsLink: string;
children: React.ReactNode;
};
export function ConditionalAlertPopover({
children,
relatedTracesLink,
relatedLogsLink,
}: ConditionalAlertPopoverProps): JSX.Element {
if (relatedTracesLink || relatedLogsLink) {
return (
<AlertPopover
relatedTracesLink={relatedTracesLink}
relatedLogsLink={relatedLogsLink}
>
{children}
</AlertPopover>
);
}
return <div>{children}</div>;
}
export default AlertPopover;

View File

@ -0,0 +1,28 @@
import { AlertRuleStats } from 'types/api/alerts/def';
import { formatTime } from 'utils/timeUtils';
import StatsCard from '../StatsCard/StatsCard';
type TotalTriggeredCardProps = {
currentAvgResolutionTime: AlertRuleStats['currentAvgResolutionTime'];
pastAvgResolutionTime: AlertRuleStats['pastAvgResolutionTime'];
timeSeries: AlertRuleStats['currentAvgResolutionTimeSeries']['values'];
};
function AverageResolutionCard({
currentAvgResolutionTime,
pastAvgResolutionTime,
timeSeries,
}: TotalTriggeredCardProps): JSX.Element {
return (
<StatsCard
displayValue={formatTime(currentAvgResolutionTime)}
totalCurrentCount={currentAvgResolutionTime}
totalPastCount={pastAvgResolutionTime}
title="Avg. Resolution Time"
timeSeries={timeSeries}
/>
);
}
export default AverageResolutionCard;

View File

@ -0,0 +1,14 @@
.statistics {
display: flex;
justify-content: space-between;
height: 280px;
border: 1px solid var(--bg-slate-500);
border-radius: 4px;
margin: 0 16px;
}
.lightMode {
.statistics {
border: 1px solid var(--bg-vanilla-300);
}
}

View File

@ -0,0 +1,23 @@
import './Statistics.styles.scss';
import { AlertRuleStats } from 'types/api/alerts/def';
import StatsCardsRenderer from './StatsCardsRenderer/StatsCardsRenderer';
import TopContributorsRenderer from './TopContributorsRenderer/TopContributorsRenderer';
function Statistics({
setTotalCurrentTriggers,
totalCurrentTriggers,
}: {
setTotalCurrentTriggers: (value: number) => void;
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
}): JSX.Element {
return (
<div className="statistics">
<StatsCardsRenderer setTotalCurrentTriggers={setTotalCurrentTriggers} />
<TopContributorsRenderer totalCurrentTriggers={totalCurrentTriggers} />
</div>
);
}
export default Statistics;

View File

@ -0,0 +1,112 @@
.stats-card {
width: 21.7%;
border-right: 1px solid var(--bg-slate-500);
padding: 9px 12px 13px;
&--empty {
justify-content: normal;
}
&__title-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
.title {
text-transform: uppercase;
font-size: 13px;
line-height: 22px;
color: var(--bg-vanilla-400);
font-weight: 500;
}
.duration-indicator {
display: flex;
align-items: center;
gap: 4px;
.icon {
display: flex;
align-self: center;
}
.text {
text-transform: uppercase;
color: var(--text-slate-200);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.48px;
}
}
}
&__stats {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 4px;
.count-label {
color: var(--text-vanilla-100);
font-family: 'Geist Mono';
font-size: 24px;
line-height: 36px;
}
}
&__graph {
margin-top: 80px;
.graph {
width: 100%;
height: 72px;
}
}
}
.change-percentage {
width: max-content;
display: flex;
padding: 4px 8px;
border-radius: 20px;
align-items: center;
gap: 4px;
&--success {
background: rgba(37, 225, 146, 0.1);
color: var(--bg-forest-500);
}
&--error {
background: rgba(229, 72, 77, 0.1);
color: var(--bg-cherry-500);
}
&--no-previous-data {
color: var(--text-robin-500);
background: rgba(78, 116, 248, 0.1);
padding: 4px 16px;
}
&__icon {
display: flex;
align-self: center;
}
&__label {
font-size: 12px;
font-weight: 500;
line-height: 16px;
}
}
.lightMode {
.stats-card {
border-color: var(--bg-vanilla-300);
&__title-wrapper {
.title {
color: var(--text-ink-400);
}
.duration-indicator {
.text {
color: var(--text-ink-200);
}
}
}
&__stats {
.count-label {
color: var(--text-ink-100);
}
}
}
}

View File

@ -0,0 +1,158 @@
import './StatsCard.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { ArrowDownLeft, ArrowUpRight, Calendar } from 'lucide-react';
import { AlertRuleStats } from 'types/api/alerts/def';
import { calculateChange } from 'utils/calculateChange';
import StatsGraph from './StatsGraph/StatsGraph';
import {
convertTimestampToLocaleDateString,
extractDayFromTimestamp,
} from './utils';
type ChangePercentageProps = {
percentage: number;
direction: number;
duration: string | null;
};
function ChangePercentage({
percentage,
direction,
duration,
}: ChangePercentageProps): JSX.Element {
if (direction > 0) {
return (
<div className="change-percentage change-percentage--success">
<div className="change-percentage__icon">
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
</div>
<div className="change-percentage__label">
{percentage}% vs Last {duration}
</div>
</div>
);
}
if (direction < 0) {
return (
<div className="change-percentage change-percentage--error">
<div className="change-percentage__icon">
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
</div>
<div className="change-percentage__label">
{percentage}% vs Last {duration}
</div>
</div>
);
}
return (
<div className="change-percentage change-percentage--no-previous-data">
<div className="change-percentage__label">no previous data</div>
</div>
);
}
type StatsCardProps = {
totalCurrentCount?: number;
totalPastCount?: number;
title: string;
isEmpty?: boolean;
emptyMessage?: string;
displayValue?: string | number;
timeSeries?: AlertRuleStats['currentTriggersSeries']['values'];
};
function StatsCard({
displayValue,
totalCurrentCount,
totalPastCount,
title,
isEmpty,
emptyMessage,
timeSeries = [],
}: StatsCardProps): JSX.Element {
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const { changePercentage, changeDirection } = calculateChange(
totalCurrentCount,
totalPastCount,
);
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
let displayTime = relativeTime;
if (!displayTime && startTime && endTime) {
const formattedStartDate = extractDayFromTimestamp(startTime);
const formattedEndDate = extractDayFromTimestamp(endTime);
displayTime = `${formattedStartDate} to ${formattedEndDate}`;
}
if (!displayTime) {
displayTime = '';
}
const formattedStartTimeForTooltip = convertTimestampToLocaleDateString(
startTime,
);
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
return (
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
<div className="stats-card__title-wrapper">
<div className="title">{title}</div>
<div className="duration-indicator">
<div className="icon">
<Calendar size={14} color={Color.BG_SLATE_200} />
</div>
{relativeTime ? (
<div className="text">{displayTime}</div>
) : (
<Tooltip
title={`From ${formattedStartTimeForTooltip} to ${formattedEndTimeForTooltip}`}
>
<div className="text">{displayTime}</div>
</Tooltip>
)}
</div>
</div>
<div className="stats-card__stats">
<div className="count-label">
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
</div>
<ChangePercentage
direction={changeDirection}
percentage={changePercentage}
duration={relativeTime}
/>
</div>
<div className="stats-card__graph">
<div className="graph">
{!isEmpty && timeSeries.length > 1 && (
<StatsGraph timeSeries={timeSeries} changeDirection={changeDirection} />
)}
</div>
</div>
</div>
);
}
StatsCard.defaultProps = {
totalCurrentCount: 0,
totalPastCount: 0,
isEmpty: false,
emptyMessage: 'No Data',
displayValue: '',
timeSeries: [],
};
export default StatsCard;

View File

@ -0,0 +1,90 @@
import { Color } from '@signozhq/design-tokens';
import Uplot from 'components/Uplot';
import { useResizeObserver } from 'hooks/useDimensions';
import { useMemo, useRef } from 'react';
import { AlertRuleStats } from 'types/api/alerts/def';
type Props = {
timeSeries: AlertRuleStats['currentTriggersSeries']['values'];
changeDirection: number;
};
const getStyle = (
changeDirection: number,
): { stroke: string; fill: string } => {
if (changeDirection === 0) {
return {
stroke: Color.BG_ROBIN_500,
fill: 'rgba(78, 116, 248, 0.20)',
};
}
if (changeDirection > 0) {
return {
stroke: Color.BG_FOREST_500,
fill: 'rgba(37, 225, 146, 0.20)',
};
}
return {
stroke: Color.BG_CHERRY_500,
fill: ' rgba(229, 72, 77, 0.20)',
};
};
function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
const { xData, yData } = useMemo(
() => ({
xData: timeSeries.map((item) => item.timestamp),
yData: timeSeries.map((item) => Number(item.value)),
}),
[timeSeries],
);
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const options: uPlot.Options = useMemo(
() => ({
width: containerDimensions.width,
height: containerDimensions.height,
legend: {
show: false,
},
cursor: {
x: false,
y: false,
drag: {
x: false,
y: false,
},
},
padding: [0, 0, 2, 0],
series: [
{},
{
...getStyle(changeDirection),
points: {
show: false,
},
width: 1.4,
},
],
axes: [
{ show: false },
{
show: false,
},
],
}),
[changeDirection, containerDimensions.height, containerDimensions.width],
);
return (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
<Uplot data={[xData, yData]} options={options} />
</div>
);
}
export default StatsGraph;

View File

@ -0,0 +1,12 @@
export const extractDayFromTimestamp = (timestamp: string | null): string => {
if (!timestamp) return '';
const date = new Date(parseInt(timestamp, 10));
return date.getDate().toString();
};
export const convertTimestampToLocaleDateString = (
timestamp: string | null,
): string => {
if (!timestamp) return '';
return new Date(parseInt(timestamp, 10)).toLocaleString();
};

View File

@ -0,0 +1,102 @@
import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { useEffect } from 'react';
import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard';
import StatsCard from '../StatsCard/StatsCard';
import TotalTriggeredCard from '../TotalTriggeredCard/TotalTriggeredCard';
const hasTotalTriggeredStats = (
totalCurrentTriggers: number | string,
totalPastTriggers: number | string,
): boolean =>
(Number(totalCurrentTriggers) > 0 && Number(totalPastTriggers) > 0) ||
Number(totalCurrentTriggers) > 0;
const hasAvgResolutionTimeStats = (
currentAvgResolutionTime: number | string,
pastAvgResolutionTime: number | string,
): boolean =>
(Number(currentAvgResolutionTime) > 0 && Number(pastAvgResolutionTime) > 0) ||
Number(currentAvgResolutionTime) > 0;
type StatsCardsRendererProps = {
setTotalCurrentTriggers: (value: number) => void;
};
// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title
function StatsCardsRenderer({
setTotalCurrentTriggers,
}: StatsCardsRendererProps): JSX.Element {
const {
isLoading,
isRefetching,
isError,
data,
isValidRuleId,
ruleId,
} = useGetAlertRuleDetailsStats();
useEffect(() => {
if (data?.payload?.data?.totalCurrentTriggers !== undefined) {
setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers);
}
}, [data, setTotalCurrentTriggers]);
return (
<DataStateRenderer
isLoading={isLoading}
isRefetching={isRefetching}
isError={isError || !isValidRuleId || !ruleId}
data={data?.payload?.data || null}
>
{(data): JSX.Element => {
const {
currentAvgResolutionTime,
pastAvgResolutionTime,
totalCurrentTriggers,
totalPastTriggers,
currentAvgResolutionTimeSeries,
currentTriggersSeries,
} = data;
return (
<>
{hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) ? (
<TotalTriggeredCard
totalCurrentTriggers={totalCurrentTriggers}
totalPastTriggers={totalPastTriggers}
timeSeries={currentTriggersSeries?.values}
/>
) : (
<StatsCard
title="Total Triggered"
isEmpty
emptyMessage="None Triggered."
/>
)}
{hasAvgResolutionTimeStats(
currentAvgResolutionTime,
pastAvgResolutionTime,
) ? (
<AverageResolutionCard
currentAvgResolutionTime={currentAvgResolutionTime}
pastAvgResolutionTime={pastAvgResolutionTime}
timeSeries={currentAvgResolutionTimeSeries?.values}
/>
) : (
<StatsCard
title="Avg. Resolution Time"
isEmpty
emptyMessage="No Resolutions."
/>
)}
</>
);
}}
</DataStateRenderer>
);
}
export default StatsCardsRenderer;

View File

@ -0,0 +1,213 @@
.top-contributors-card {
width: 56.6%;
overflow: hidden;
&--view-all {
width: auto;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-slate-500);
.title {
color: var(--text-vanilla-400);
font-size: 13px;
font-weight: 500;
line-height: 22px;
letter-spacing: 0.52px;
text-transform: uppercase;
}
.view-all {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 0;
height: 20px;
&:hover {
background-color: transparent !important;
}
.label {
color: var(--text-vanilla-400);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.icon {
display: flex;
}
}
}
.contributors-row {
height: 80px;
}
&__content {
.ant-table {
&-cell {
padding: 12px !important;
}
}
.contributors-row {
background: var(--bg-ink-500);
td {
border: none !important;
}
&:not(:last-of-type) td {
border-bottom: 1px solid var(--bg-slate-500) !important;
}
}
.total-contribution {
color: var(--text-robin-500);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 500;
letter-spacing: -0.06px;
padding: 4px 8px;
background: rgba(78, 116, 248, 0.1);
border-radius: 50px;
width: max-content;
}
}
.empty-content {
margin: 16px 12px;
padding: 40px 45px;
display: flex;
flex-direction: column;
gap: 12px;
border: 1px dashed var(--bg-slate-500);
border-radius: 6px;
&__icon {
font-family: Inter;
font-size: 20px;
line-height: 26px;
letter-spacing: -0.103px;
}
&__text {
color: var(--text-vanilla-400);
line-height: 18px;
.bold-text {
color: var(--text-vanilla-100);
font-weight: 500;
}
}
&__button-wrapper {
margin-top: 12px;
.configure-alert-rule-button {
padding: 8px 16px;
border-radius: 2px;
background: var(--bg-slate-400);
border-width: 0;
color: var(--text-vanilla-100);
line-height: 24px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
}
}
}
}
.ant-popover-inner:has(.contributor-row-popover-buttons) {
padding: 0 !important;
}
.contributor-row-popover-buttons {
display: flex;
flex-direction: column;
&__button {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 15px;
color: var(--text-vanilla-400);
font-size: 14px;
letter-spacing: 0.14px;
width: 160px;
cursor: pointer;
.text,
.icon {
color: var(--text-vanilla-100);
.lightMode & {
color: var(--text-ink-500);
}
}
&:hover {
background: var(--bg-slate-400);
.text,
.icon {
color: var(--text-vanilla-100);
.lightMode & {
color: var(--text-ink-500);
}
}
}
.icon {
display: flex;
}
.lightMode & {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-400);
}
}
}
.view-all-drawer {
border-radius: 4px;
}
.lightMode {
.ant-table {
background: inherit;
}
.top-contributors-card {
&__header {
border-color: var(--bg-vanilla-300);
.title {
color: var(--text-ink-400);
}
.view-all {
.label {
color: var(--text-ink-400);
}
}
}
&__content {
.contributors-row {
background: inherit;
&:not(:last-of-type) td {
border-bottom: 1px solid var(--bg-vanilla-300) !important;
}
}
}
.empty-content {
border-color: var(--bg-vanilla-300);
&__text {
color: var(--text-ink-400);
.bold-text {
color: var(--text-ink-500);
}
}
&__button-wrapper {
.configure-alert-rule-button {
background: var(--bg-vanilla-300);
color: var(--text-ink-500);
}
}
}
}
}

View File

@ -0,0 +1,84 @@
import './TopContributorsCard.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import history from 'lib/history';
import { ArrowRight } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import TopContributorsContent from './TopContributorsContent';
import { TopContributorsCardProps } from './types';
import ViewAllDrawer from './ViewAllDrawer';
function TopContributorsCard({
topContributorsData,
totalCurrentTriggers,
}: TopContributorsCardProps): JSX.Element {
const { search } = useLocation();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
const viewAllTopContributorsParam = searchParams.get('viewAllTopContributors');
const [isViewAllVisible, setIsViewAllVisible] = useState(
!!viewAllTopContributorsParam ?? false,
);
const isDarkMode = useIsDarkMode();
const toggleViewAllParam = (isOpen: boolean): void => {
if (isOpen) {
searchParams.set('viewAllTopContributors', 'true');
} else {
searchParams.delete('viewAllTopContributors');
}
};
const toggleViewAllDrawer = (): void => {
setIsViewAllVisible((prev) => {
const newState = !prev;
toggleViewAllParam(newState);
return newState;
});
history.push({ search: searchParams.toString() });
};
return (
<>
<div className="top-contributors-card">
<div className="top-contributors-card__header">
<div className="title">top contributors</div>
{topContributorsData.length > 3 && (
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
<div className="label">View all</div>
<div className="icon">
<ArrowRight
size={14}
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
/>
</div>
</Button>
)}
</div>
<TopContributorsContent
topContributorsData={topContributorsData}
totalCurrentTriggers={totalCurrentTriggers}
/>
</div>
{isViewAllVisible && (
<ViewAllDrawer
isViewAllVisible={isViewAllVisible}
toggleViewAllDrawer={toggleViewAllDrawer}
totalCurrentTriggers={totalCurrentTriggers}
topContributorsData={topContributorsData}
/>
)}
</>
);
}
export default TopContributorsCard;

View File

@ -0,0 +1,32 @@
import TopContributorsRows from './TopContributorsRows';
import { TopContributorsCardProps } from './types';
function TopContributorsContent({
topContributorsData,
totalCurrentTriggers,
}: TopContributorsCardProps): JSX.Element {
const isEmpty = !topContributorsData.length;
if (isEmpty) {
return (
<div className="empty-content">
<div className="empty-content__icon"></div>
<div className="empty-content__text">
Top contributors highlight the most frequently triggering group-by
attributes in multi-dimensional alerts
</div>
</div>
);
}
return (
<div className="top-contributors-card__content">
<TopContributorsRows
topContributors={topContributorsData.slice(0, 3)}
totalCurrentTriggers={totalCurrentTriggers}
/>
</div>
);
}
export default TopContributorsContent;

View File

@ -0,0 +1,87 @@
import { Color } from '@signozhq/design-tokens';
import { Progress, Table } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText';
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
function TopContributorsRows({
topContributors,
totalCurrentTriggers,
}: {
topContributors: AlertRuleTopContributors[];
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
}): JSX.Element {
const columns: ColumnsType<AlertRuleTopContributors> = [
{
title: 'labels',
dataIndex: 'labels',
key: 'labels',
width: '51%',
render: (
labels: AlertRuleTopContributors['labels'],
record,
): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div>
<AlertLabels labels={labels} />
</div>
</ConditionalAlertPopover>
),
},
{
title: 'progressBar',
dataIndex: 'count',
key: 'progressBar',
width: '39%',
render: (count: AlertRuleTopContributors['count'], record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<Progress
percent={(count / totalCurrentTriggers) * 100}
showInfo={false}
trailColor="rgba(255, 255, 255, 0)"
strokeColor={Color.BG_ROBIN_500}
/>
</ConditionalAlertPopover>
),
},
{
title: 'count',
dataIndex: 'count',
key: 'count',
width: '10%',
render: (count: AlertRuleTopContributors['count'], record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="total-contribution">
{count}/{totalCurrentTriggers}
</div>
</ConditionalAlertPopover>
),
},
];
return (
<Table
rowClassName="contributors-row"
rowKey={(row): string => `top-contributor-${row.fingerprint}`}
columns={columns}
showHeader={false}
dataSource={topContributors}
pagination={
topContributors.length > 10 ? { showTotal: PaginationInfoText } : false
}
/>
);
}
export default TopContributorsRows;

View File

@ -0,0 +1,46 @@
import { Color } from '@signozhq/design-tokens';
import { Drawer } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
import TopContributorsRows from './TopContributorsRows';
function ViewAllDrawer({
isViewAllVisible,
toggleViewAllDrawer,
totalCurrentTriggers,
topContributorsData,
}: {
isViewAllVisible: boolean;
toggleViewAllDrawer: () => void;
topContributorsData: AlertRuleTopContributors[];
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
}): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<Drawer
open={isViewAllVisible}
destroyOnClose
onClose={toggleViewAllDrawer}
placement="right"
width="50%"
className="view-all-drawer"
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
title="Viewing All Contributors"
>
<div className="top-contributors-card--view-all">
<div className="top-contributors-card__content">
<TopContributorsRows
topContributors={topContributorsData}
totalCurrentTriggers={totalCurrentTriggers}
/>
</div>
</div>
</Drawer>
);
}
export default ViewAllDrawer;

View File

@ -0,0 +1,6 @@
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
export type TopContributorsCardProps = {
topContributorsData: AlertRuleTopContributors[];
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
};

View File

@ -0,0 +1,42 @@
import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { AlertRuleStats } from 'types/api/alerts/def';
import TopContributorsCard from '../TopContributorsCard/TopContributorsCard';
type TopContributorsRendererProps = {
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
};
function TopContributorsRenderer({
totalCurrentTriggers,
}: TopContributorsRendererProps): JSX.Element {
const {
isLoading,
isRefetching,
isError,
data,
isValidRuleId,
ruleId,
} = useGetAlertRuleDetailsTopContributors();
const response = data?.payload?.data;
// TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all
return (
<DataStateRenderer
isLoading={isLoading}
isRefetching={isRefetching}
isError={isError || !isValidRuleId || !ruleId}
data={response || null}
>
{(topContributorsData): JSX.Element => (
<TopContributorsCard
topContributorsData={topContributorsData}
totalCurrentTriggers={totalCurrentTriggers}
/>
)}
</DataStateRenderer>
);
}
export default TopContributorsRenderer;

View File

@ -0,0 +1,26 @@
import { AlertRuleStats } from 'types/api/alerts/def';
import StatsCard from '../StatsCard/StatsCard';
type TotalTriggeredCardProps = {
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
totalPastTriggers: AlertRuleStats['totalPastTriggers'];
timeSeries: AlertRuleStats['currentTriggersSeries']['values'];
};
function TotalTriggeredCard({
totalCurrentTriggers,
totalPastTriggers,
timeSeries,
}: TotalTriggeredCardProps): JSX.Element {
return (
<StatsCard
totalCurrentCount={totalCurrentTriggers}
totalPastCount={totalPastTriggers}
title="Total Triggered"
timeSeries={timeSeries}
/>
);
}
export default TotalTriggeredCard;

View File

@ -0,0 +1,52 @@
.timeline-graph {
display: flex;
flex-direction: column;
gap: 24px;
background: var(--bg-ink-400);
padding: 12px;
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
height: 150px;
&__title {
width: max-content;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid #1d212d;
background: rgba(29, 33, 45, 0.5);
color: #ebebeb;
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
}
&__chart {
.chart-placeholder {
width: 100%;
height: 52px;
background: rgba(255, 255, 255, 0.1215686275);
display: flex;
align-items: center;
justify-content: center;
.chart-icon {
font-size: 2rem;
}
}
}
}
.lightMode {
.timeline-graph {
background: var(--bg-vanilla-200);
border-color: var(--bg-vanilla-300);
&__title {
background: var(--bg-vanilla-100);
color: var(--text-ink-400);
border-color: var(--bg-vanilla-300);
}
&__chart {
.chart-placeholder {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@ -0,0 +1,184 @@
import { Color } from '@signozhq/design-tokens';
import Uplot from 'components/Uplot';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import heatmapPlugin from 'lib/uPlotLib/plugins/heatmapPlugin';
import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin';
import { useMemo, useRef } from 'react';
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
import uPlot, { AlignedData } from 'uplot';
import { ALERT_STATUS, TIMELINE_OPTIONS } from './constants';
type Props = { type: string; data: AlertRuleTimelineGraphResponse[] };
function HorizontalTimelineGraph({
width,
isDarkMode,
data,
}: {
width: number;
isDarkMode: boolean;
data: AlertRuleTimelineGraphResponse[];
}): JSX.Element {
const transformedData: AlignedData = useMemo(() => {
if (!data?.length) {
return [[], []];
}
// add a first and last entry to make sure the graph displays all the data
const FIVE_MINUTES_IN_SECONDS = 300;
const timestamps = [
data[0].start / 1000 - FIVE_MINUTES_IN_SECONDS, // 5 minutes before the first entry
...data.map((item) => item.start / 1000),
data[data.length - 1].end / 1000, // end value of last entry
];
const states = [
ALERT_STATUS[data[0].state], // Same state as the first entry
...data.map((item) => ALERT_STATUS[item.state]),
ALERT_STATUS[data[data.length - 1].state], // Same state as the last entry
];
return [timestamps, states];
}, [data]);
const options: uPlot.Options = useMemo(
() => ({
width,
height: 85,
cursor: { show: false },
axes: [
{
gap: 10,
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
},
{ show: false },
],
legend: {
show: false,
},
padding: [null, 0, null, 0],
series: [
{
label: 'Time',
},
{
label: 'States',
},
],
plugins:
transformedData?.length > 1
? [
timelinePlugin({
count: transformedData.length - 1,
...TIMELINE_OPTIONS,
}),
]
: [],
}),
[width, isDarkMode, transformedData],
);
return <Uplot data={transformedData} options={options} />;
}
const transformVerticalTimelineGraph = (data: any[]): any => [
data.map((item: { timestamp: any }) => item.timestamp),
Array(data.length).fill(0),
Array(data.length).fill(10),
Array(data.length).fill([0, 1, 2, 3, 4, 5]),
data.map((item: { value: number }) => {
const count = Math.floor(item.value / 10);
return [...Array(count).fill(1), 2];
}),
];
const datatest: any[] = [];
const now = Math.floor(Date.now() / 1000); // current timestamp in seconds
const oneDay = 24 * 60 * 60; // one day in seconds
for (let i = 0; i < 90; i++) {
const timestamp = now - i * oneDay;
const startOfDay = timestamp - (timestamp % oneDay);
datatest.push({
timestamp: startOfDay,
value: Math.floor(Math.random() * 30) + 1,
});
}
function VerticalTimelineGraph({
isDarkMode,
width,
}: {
width: number;
isDarkMode: boolean;
}): JSX.Element {
const transformedData = useMemo(
() => transformVerticalTimelineGraph(datatest),
[],
);
const options: uPlot.Options = useMemo(
() => ({
width,
height: 90,
plugins: [heatmapPlugin()],
cursor: { show: false },
legend: {
show: false,
},
axes: [
{
gap: 10,
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
},
{ show: false },
],
series: [
{},
{
paths: (): null => null,
points: { show: false },
},
{
paths: (): null => null,
points: { show: false },
},
],
}),
[isDarkMode, width],
);
return <Uplot data={transformedData} options={options} />;
}
function Graph({ type, data }: Props): JSX.Element | null {
const graphRef = useRef<HTMLDivElement>(null);
const isDarkMode = useIsDarkMode();
const containerDimensions = useResizeObserver(graphRef);
if (type === 'horizontal') {
return (
<div ref={graphRef}>
<HorizontalTimelineGraph
isDarkMode={isDarkMode}
width={containerDimensions.width}
data={data}
/>
</div>
);
}
return (
<div ref={graphRef}>
<VerticalTimelineGraph
isDarkMode={isDarkMode}
width={containerDimensions.width}
/>
</div>
);
}
export default Graph;

View File

@ -0,0 +1,33 @@
import { Color } from '@signozhq/design-tokens';
export const ALERT_STATUS: { [key: string]: number } = {
firing: 0,
inactive: 1,
normal: 1,
'no-data': 2,
disabled: 3,
muted: 4,
};
export const STATE_VS_COLOR: {
[key: string]: { stroke: string; fill: string };
}[] = [
{},
{
0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 },
1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 },
2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
},
];
export const TIMELINE_OPTIONS = {
mode: 1,
fill: (seriesIdx: any, _: any, value: any): any =>
STATE_VS_COLOR[seriesIdx][value].fill,
stroke: (seriesIdx: any, _: any, value: any): any =>
STATE_VS_COLOR[seriesIdx][value].stroke,
laneWidthOption: 0.3,
showGrid: false,
};

View File

@ -0,0 +1,67 @@
import '../Graph/Graph.styles.scss';
import useUrlQuery from 'hooks/useUrlQuery';
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import Graph from '../Graph/Graph';
function GraphWrapper({
totalCurrentTriggers,
}: {
totalCurrentTriggers: number;
}): JSX.Element {
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const {
isLoading,
isRefetching,
isError,
data,
isValidRuleId,
ruleId,
} = useGetAlertRuleDetailsTimelineGraphData();
// TODO(shaheer): uncomment when the API is ready for
// const { startTime } = useAlertHistoryQueryParams();
// const [isVerticalGraph, setIsVerticalGraph] = useState(false);
// useEffect(() => {
// const checkVerticalGraph = (): void => {
// if (startTime) {
// const startTimeDate = dayjs(Number(startTime));
// const twentyFourHoursAgo = dayjs().subtract(
// HORIZONTAL_GRAPH_HOURS_THRESHOLD,
// DAYJS_MANIPULATE_TYPES.HOUR,
// );
// setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo));
// }
// };
// checkVerticalGraph();
// }, [startTime]);
return (
<div className="timeline-graph">
<div className="timeline-graph__title">
{totalCurrentTriggers} triggers in {relativeTime}
</div>
<div className="timeline-graph__chart">
<DataStateRenderer
isLoading={isLoading}
isError={isError || !isValidRuleId || !ruleId}
isRefetching={isRefetching}
data={data?.payload?.data || null}
>
{(data): JSX.Element => <Graph type="horizontal" data={data} />}
</DataStateRenderer>
</div>
</div>
);
}
export default GraphWrapper;

View File

@ -0,0 +1,123 @@
.timeline-table {
border-top: 1px solid var(--text-slate-500);
border-radius: 6px;
overflow: hidden;
margin-top: 4px;
min-height: 600px;
.ant-table {
background: var(--bg-ink-500);
&-cell {
padding: 12px 16px !important;
vertical-align: baseline;
&::before {
display: none;
}
}
&-thead > tr > th {
border-color: var(--bg-slate-500);
background: var(--bg-ink-500);
font-size: 12px;
font-weight: 500;
padding: 12px 16px 8px !important;
}
&-tbody > tr > td {
border: none;
}
}
.label-filter {
padding: 6px 8px;
border-radius: 4px;
background: var(--text-ink-400);
border-width: 0;
line-height: 18px;
& ::placeholder {
color: var(--text-vanilla-400);
font-size: 12px;
letter-spacing: 0.6px;
text-transform: uppercase;
font-weight: 500;
}
}
.alert-rule {
&-value,
&__created-at {
font-size: 14px;
color: var(--text-vanilla-400);
}
&-value {
font-weight: 500;
line-height: 20px;
}
&__created-at {
line-height: 18px;
letter-spacing: -0.07px;
}
}
.ant-table.ant-table-middle {
border-bottom: 1px solid var(--bg-slate-500);
border-left: 1px solid var(--bg-slate-500);
border-right: 1px solid var(--bg-slate-500);
border-radius: 6px;
}
.ant-pagination-item {
&-active {
display: flex;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 1px 8px;
border-radius: 2px;
background: var(--bg-robin-500);
& > a {
color: var(--text-ink-500);
line-height: 20px;
font-weight: 500;
}
}
}
.alert-history-label-search {
.ant-select-selector {
border: none;
}
}
}
.lightMode {
.timeline-table {
border-color: var(--bg-vanilla-300);
.ant-table {
background: var(--bg-vanilla-100);
&-thead {
& > tr > th {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
}
&.ant-table-middle {
border-color: var(--bg-vanilla-300);
}
}
.alert-history-label-search {
.ant-select-selector {
background: var(--bg-vanilla-200);
}
}
.alert-rule {
&-value,
&-created-at {
color: var(--text-ink-400);
}
}
.ant-pagination-item {
&-active > a {
color: var(--text-vanilla-100);
}
}
}
}

View File

@ -0,0 +1,56 @@
import './Table.styles.scss';
import { Table } from 'antd';
import {
useGetAlertRuleDetailsTimelineTable,
useTimelineTable,
} from 'pages/AlertDetails/hooks';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { timelineTableColumns } from './useTimelineTable';
function TimelineTable(): JSX.Element {
const {
isLoading,
isRefetching,
isError,
data,
isValidRuleId,
ruleId,
} = useGetAlertRuleDetailsTimelineTable();
const { timelineData, totalItems } = useMemo(() => {
const response = data?.payload?.data;
return {
timelineData: response?.items,
totalItems: response?.total,
};
}, [data?.payload?.data]);
const { paginationConfig, onChangeHandler } = useTimelineTable({
totalItems: totalItems ?? 0,
});
const { t } = useTranslation('common');
if (isError || !isValidRuleId || !ruleId) {
return <div>{t('something_went_wrong')}</div>;
}
return (
<div className="timeline-table">
<Table
rowKey={(row): string => `${row.fingerprint}-${row.value}-${row.unixMilli}`}
columns={timelineTableColumns()}
dataSource={timelineData}
pagination={paginationConfig}
size="middle"
onChange={onChangeHandler}
loading={isLoading || isRefetching}
/>
</div>
);
}
export default TimelineTable;

View File

@ -0,0 +1,9 @@
import {
AlertRuleTimelineTableResponse,
AlertRuleTimelineTableResponsePayload,
} from 'types/api/alerts/def';
export type TimelineTableProps = {
timelineData: AlertRuleTimelineTableResponse[];
totalItems: AlertRuleTimelineTableResponsePayload['data']['total'];
};

View File

@ -0,0 +1,54 @@
import { EllipsisOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState';
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import { formatEpochTimestamp } from 'utils/timeUtils';
export const timelineTableColumns = (): ColumnsType<AlertRuleTimelineTableResponse> => [
{
title: 'STATE',
dataIndex: 'state',
sorter: true,
width: 140,
render: (value): JSX.Element => (
<div className="alert-rule-state">
<AlertState state={value} showLabel />
</div>
),
},
{
title: 'LABELS',
dataIndex: 'labels',
render: (labels): JSX.Element => (
<div className="alert-rule-labels">
<AlertLabels labels={labels} />
</div>
),
},
{
title: 'CREATED AT',
dataIndex: 'unixMilli',
width: 200,
render: (value): JSX.Element => (
<div className="alert-rule__created-at">{formatEpochTimestamp(value)}</div>
),
},
{
title: 'ACTIONS',
width: 140,
align: 'right',
render: (record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<Button type="text" ghost>
<EllipsisOutlined className="dropdown-icon" />
</Button>
</ConditionalAlertPopover>
),
},
];

View File

@ -0,0 +1,32 @@
.timeline-tabs-and-filters {
display: flex;
justify-content: space-between;
align-items: center;
.reset-button,
.top-5-contributors {
display: flex;
align-items: center;
gap: 10px;
}
.coming-soon {
display: inline-flex;
padding: 4px 8px;
border-radius: 20px;
border: 1px solid rgba(173, 127, 88, 0.2);
background: rgba(173, 127, 88, 0.1);
justify-content: center;
align-items: center;
gap: 5px;
&__text {
color: var(--text-sienna-400);
font-size: 10px;
font-weight: 500;
letter-spacing: -0.05px;
line-height: normal;
}
&__icon {
display: flex;
}
}
}

View File

@ -0,0 +1,90 @@
import './TabsAndFilters.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { TimelineFilter, TimelineTab } from 'container/AlertHistory/types';
import history from 'lib/history';
import { Info } from 'lucide-react';
import Tabs2 from 'periscope/components/Tabs2';
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
function ComingSoon(): JSX.Element {
return (
<div className="coming-soon">
<div className="coming-soon__text">Coming Soon</div>
<div className="coming-soon__icon">
<Info size={10} color={Color.BG_SIENNA_400} />
</div>
</div>
);
}
function TimelineTabs(): JSX.Element {
const tabs = [
{
value: TimelineTab.OVERALL_STATUS,
label: 'Overall Status',
},
{
value: TimelineTab.TOP_5_CONTRIBUTORS,
label: (
<div className="top-5-contributors">
Top 5 Contributors
<ComingSoon />
</div>
),
disabled: true,
},
];
return <Tabs2 tabs={tabs} initialSelectedTab={TimelineTab.OVERALL_STATUS} />;
}
function TimelineFilters(): JSX.Element {
const { search } = useLocation();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
const initialSelectedTab = useMemo(
() => searchParams.get('timelineFilter') ?? TimelineFilter.ALL,
[searchParams],
);
const handleFilter = (value: TimelineFilter): void => {
searchParams.set('timelineFilter', value);
history.push({ search: searchParams.toString() });
};
const tabs = [
{
value: TimelineFilter.ALL,
label: 'All',
},
{
value: TimelineFilter.FIRED,
label: 'Fired',
},
{
value: TimelineFilter.RESOLVED,
label: 'Resolved',
},
];
return (
<Tabs2
tabs={tabs}
initialSelectedTab={initialSelectedTab}
onSelectTab={handleFilter}
hasResetButton
/>
);
}
function TabsAndFilters(): JSX.Element {
return (
<div className="timeline-tabs-and-filters">
<TimelineTabs />
<TimelineFilters />
</div>
);
}
export default TabsAndFilters;

View File

@ -0,0 +1,22 @@
.timeline {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0 16px;
&__title {
color: var(--text-vanilla-100);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
}
.lightMode {
.timeline {
&__title {
color: var(--text-ink-400);
}
}
}

View File

@ -0,0 +1,32 @@
import './Timeline.styles.scss';
import GraphWrapper from './GraphWrapper/GraphWrapper';
import TimelineTable from './Table/Table';
import TabsAndFilters from './TabsAndFilters/TabsAndFilters';
function TimelineTableRenderer(): JSX.Element {
return <TimelineTable />;
}
function Timeline({
totalCurrentTriggers,
}: {
totalCurrentTriggers: number;
}): JSX.Element {
return (
<div className="timeline">
<div className="timeline__title">Timeline</div>
<div className="timeline__tabs-and-filters">
<TabsAndFilters />
</div>
<div className="timeline__graph">
<GraphWrapper totalCurrentTriggers={totalCurrentTriggers} />
</div>
<div className="timeline__table">
<TimelineTableRenderer />
</div>
</div>
);
}
export default Timeline;

View File

@ -0,0 +1,2 @@
// setting to 25 hours because we want to display the horizontal graph when the user selects 'Last 1 day' from date and time selector
export const HORIZONTAL_GRAPH_HOURS_THRESHOLD = 25;

View File

@ -0,0 +1 @@
export const TIMELINE_TABLE_PAGE_SIZE = 20;

View File

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

View File

@ -0,0 +1,15 @@
export enum AlertDetailsTab {
OVERVIEW = 'OVERVIEW',
HISTORY = 'HISTORY',
}
export enum TimelineTab {
OVERALL_STATUS = 'OVERALL_STATUS',
TOP_5_CONTRIBUTORS = 'TOP_5_CONTRIBUTORS',
}
export enum TimelineFilter {
ALL = 'ALL',
FIRED = 'FIRED',
RESOLVED = 'RESOLVED',
}

View File

@ -78,6 +78,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isCloudUserVal = isCloudUser();
const showAddCreditCardModal =
isLoggedIn &&
isChatSupportEnabled &&
isCloudUserVal &&
!isPremiumChatSupportEnabled &&
@ -213,7 +214,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const pageTitle = t(routeKey);
const renderFullScreen =
pathname === ROUTES.GET_STARTED ||
pathname === ROUTES.WORKSPACE_LOCKED ||
pathname === ROUTES.GET_STARTED_APPLICATION_MONITORING ||
pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING ||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
@ -253,6 +253,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
routeKey === 'MESSAGING_QUEUES' || routeKey === 'MESSAGING_QUEUES_DETAIL';
const isDashboardListView = (): boolean => routeKey === 'ALL_DASHBOARD';
const isAlertHistory = (): boolean => routeKey === 'ALERT_HISTORY';
const isAlertOverview = (): boolean => routeKey === 'ALERT_OVERVIEW';
const isDashboardView = (): boolean => {
/**
* need to match using regex here as the getRoute function will not work for
@ -279,6 +281,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isSideNavCollapsed = getLocalStorageKey(IS_SIDEBAR_COLLAPSED);
/**
* Note: Right now we don't have a page-level method to pass the sidebar collapse state.
* Since the use case for overriding is not widely needed, we are setting it here
* so that the workspace locked page will have an expanded sidebar regardless of how users
* have set it or what is stored in localStorage. This will not affect the localStorage config.
*/
const isWorkspaceLocked = pathname === ROUTES.WORKSPACE_LOCKED;
return (
<Layout
className={cx(
@ -323,7 +333,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
licenseData={licenseData}
isFetching={isFetching}
onCollapse={onCollapse}
collapsed={collapsed}
collapsed={isWorkspaceLocked ? false : collapsed}
/>
)}
<div
@ -341,6 +351,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isDashboardView() ||
isDashboardWidgetView() ||
isDashboardListView() ||
isAlertHistory() ||
isAlertOverview() ||
isMessagingQueues()
? 0
: '0 1rem',

View File

@ -19,6 +19,7 @@ import axios from 'axios';
import cx from 'classnames';
import { getViewDetailsUsingViewKey } from 'components/ExplorerCard/utils';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
@ -48,6 +49,7 @@ import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
@ -61,7 +63,9 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import { PreservedViewsTypes } from './constants';
import ExplorerOptionsHideArea from './ExplorerOptionsHideArea';
import { PreservedViewsInLocalStorage } from './types';
import {
DATASOURCE_VS_ROUTES,
generateRGBAFromHex,
@ -90,6 +94,12 @@ function ExplorerOptions({
const history = useHistory();
const ref = useRef<RefSelectProps>(null);
const isDarkMode = useIsDarkMode();
const isLogsExplorer = sourcepage === DataSource.LOGS;
const PRESERVED_VIEW_LOCAL_STORAGE_KEY = LOCALSTORAGE.LAST_USED_SAVED_VIEWS;
const PRESERVED_VIEW_TYPE = isLogsExplorer
? PreservedViewsTypes.LOGS
: PreservedViewsTypes.TRACES;
const onModalToggle = useCallback((value: boolean) => {
setIsExport(value);
@ -107,7 +117,7 @@ function ExplorerOptions({
logEvent('Traces Explorer: Save view clicked', {
panelType,
});
} else if (sourcepage === DataSource.LOGS) {
} else if (isLogsExplorer) {
logEvent('Logs Explorer: Save view clicked', {
panelType,
});
@ -141,7 +151,7 @@ function ExplorerOptions({
logEvent('Traces Explorer: Create alert', {
panelType,
});
} else if (sourcepage === DataSource.LOGS) {
} else if (isLogsExplorer) {
logEvent('Logs Explorer: Create alert', {
panelType,
});
@ -166,7 +176,7 @@ function ExplorerOptions({
logEvent('Traces Explorer: Add to dashboard clicked', {
panelType,
});
} else if (sourcepage === DataSource.LOGS) {
} else if (isLogsExplorer) {
logEvent('Logs Explorer: Add to dashboard clicked', {
panelType,
});
@ -265,6 +275,31 @@ function ExplorerOptions({
[viewsData, handleExplorerTabChange],
);
const updatePreservedViewInLocalStorage = (option: {
key: string;
value: string;
}): void => {
// Retrieve stored views from local storage
const storedViews = localStorage.getItem(PRESERVED_VIEW_LOCAL_STORAGE_KEY);
// Initialize or parse the stored views
const updatedViews: PreservedViewsInLocalStorage = storedViews
? JSON.parse(storedViews)
: {};
// Update the views with the new selection
updatedViews[PRESERVED_VIEW_TYPE] = {
key: option.key,
value: option.value,
};
// Save the updated views back to local storage
localStorage.setItem(
PRESERVED_VIEW_LOCAL_STORAGE_KEY,
JSON.stringify(updatedViews),
);
};
const handleSelect = (
value: string,
option: { key: string; value: string },
@ -277,18 +312,42 @@ function ExplorerOptions({
panelType,
viewName: option?.value,
});
} else if (sourcepage === DataSource.LOGS) {
} else if (isLogsExplorer) {
logEvent('Logs Explorer: Select view', {
panelType,
viewName: option?.value,
});
}
updatePreservedViewInLocalStorage(option);
if (ref.current) {
ref.current.blur();
}
};
const removeCurrentViewFromLocalStorage = (): void => {
// Retrieve stored views from local storage
const storedViews = localStorage.getItem(PRESERVED_VIEW_LOCAL_STORAGE_KEY);
if (storedViews) {
// Parse the stored views
const parsedViews = JSON.parse(storedViews);
// Remove the current view type from the parsed views
delete parsedViews[PRESERVED_VIEW_TYPE];
// Update local storage with the modified views
localStorage.setItem(
PRESERVED_VIEW_LOCAL_STORAGE_KEY,
JSON.stringify(parsedViews),
);
}
};
const handleClearSelect = (): void => {
removeCurrentViewFromLocalStorage();
history.replace(DATASOURCE_VS_ROUTES[sourcepage]);
};
@ -323,7 +382,7 @@ function ExplorerOptions({
panelType,
viewName: newViewName,
});
} else if (sourcepage === DataSource.LOGS) {
} else if (isLogsExplorer) {
logEvent('Logs Explorer: Save view successful', {
panelType,
viewName: newViewName,
@ -358,6 +417,44 @@ function ExplorerOptions({
const isEditDeleteSupported = allowedRoles.includes(role as string);
const [
isRecentlyUsedSavedViewSelected,
setIsRecentlyUsedSavedViewSelected,
] = useState(false);
useEffect(() => {
const parsedPreservedView = JSON.parse(
localStorage.getItem(PRESERVED_VIEW_LOCAL_STORAGE_KEY) || '{}',
);
const preservedView = parsedPreservedView[PRESERVED_VIEW_TYPE] || {};
let timeoutId: string | number | NodeJS.Timeout | undefined;
if (
!!preservedView?.key &&
viewsData?.data?.data &&
!(!!viewName || !!viewKey) &&
!isRecentlyUsedSavedViewSelected
) {
// prevent the race condition with useShareBuilderUrl
timeoutId = setTimeout(() => {
onMenuItemSelectHandler({ key: preservedView.key });
}, 0);
setIsRecentlyUsedSavedViewSelected(false);
}
return (): void => clearTimeout(timeoutId);
}, [
PRESERVED_VIEW_LOCAL_STORAGE_KEY,
PRESERVED_VIEW_TYPE,
isRecentlyUsedSavedViewSelected,
onMenuItemSelectHandler,
viewKey,
viewName,
viewsData?.data?.data,
]);
return (
<div className="explorer-options-container">
{isQueryUpdated && !isExplorerOptionHidden && (
@ -476,12 +573,12 @@ function ExplorerOptions({
<Tooltip
title={
<div>
{sourcepage === DataSource.LOGS
{isLogsExplorer
? 'Learn more about Logs explorer '
: 'Learn more about Traces explorer '}
<Typography.Link
href={
sourcepage === DataSource.LOGS
isLogsExplorer
? 'https://signoz.io/docs/product-features/logs-explorer/?utm_source=product&utm_medium=logs-explorer-toolbar'
: 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=trace-explorer-toolbar'
}

View File

@ -0,0 +1,4 @@
export enum PreservedViewsTypes {
LOGS = 'logs',
TRACES = 'traces',
}

View File

@ -8,6 +8,8 @@ import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import { PreservedViewsTypes } from './constants';
export interface SaveNewViewHandlerProps {
viewName: string;
compositeQuery: ICompositeMetricQuery;
@ -26,3 +28,11 @@ export interface SaveNewViewHandlerProps {
redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData'];
setNewViewName: Dispatch<SetStateAction<string>>;
}
export type PreservedViewType =
| PreservedViewsTypes.LOGS
| PreservedViewsTypes.TRACES;
export type PreservedViewsInLocalStorage = Partial<
Record<PreservedViewType, { key: string; value: string }>
>;

View File

@ -42,6 +42,10 @@
display: flex;
align-items: center;
}
.ant-tabs-tab-btn {
padding: 0 !important;
}
}
.lightMode {

View File

@ -19,6 +19,7 @@ import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
@ -369,7 +370,7 @@ function FormAlertRules({
});
// invalidate rule in cache
ruleCache.invalidateQueries(['ruleId', ruleId]);
ruleCache.invalidateQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
// eslint-disable-next-line sonarjs/no-identical-functions
setTimeout(() => {

View File

@ -22,6 +22,7 @@ import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import EmptyWidget from '../EmptyWidget';
import { MenuItemKeys } from '../WidgetHeader/contants';
import { GridCardGraphProps } from './types';
import { isDataAvailableByPanelType } from './utils';
import WidgetGraphComponent from './WidgetGraphComponent';
function GridCardGraph({
@ -34,6 +35,7 @@ function GridCardGraph({
onClickHandler,
onDragSelect,
customTooltipElement,
dataAvailable,
}: GridCardGraphProps): JSX.Element {
const dispatch = useDispatch();
const [errorMessage, setErrorMessage] = useState<string>();
@ -180,6 +182,11 @@ function GridCardGraph({
onError: (error) => {
setErrorMessage(error.message);
},
onSettled: (data) => {
dataAvailable?.(
isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes),
);
},
},
);

View File

@ -44,6 +44,7 @@ export interface GridCardGraphProps {
version?: string;
onDragSelect: (start: number, end: number) => void;
customTooltipElement?: HTMLDivElement;
dataAvailable?: (isDataAvailable: boolean) => void;
}
export interface GetGraphVisibilityStateOnLegendClickProps {

View File

@ -1,6 +1,8 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import getLabelName from 'lib/getLabelName';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { LegendEntryProps } from './FullView/types';
@ -131,3 +133,21 @@ export const toggleGraphsVisibilityInChart = ({
lineChartRef?.current?.toggleGraph(index, showLegendData);
});
};
export const isDataAvailableByPanelType = (
data?: MetricRangePayloadProps['data'],
panelType?: string,
): boolean => {
const getPanelData = (): any[] | undefined => {
switch (panelType) {
case PANEL_TYPES.TABLE:
return (data?.result?.[0] as any)?.table?.rows;
case PANEL_TYPES.LIST:
return data?.newResult?.data?.result?.[0]?.list as any[];
default:
return data?.result;
}
};
return Boolean(getPanelData()?.length);
};

View File

@ -438,6 +438,10 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
: true,
[selectedDashboard],
);
let isDataAvailableInAnyWidget = false;
const isLogEventCalled = useRef<boolean>(false);
return isDashboardEmpty ? (
<DashboardEmptyState />
) : (
@ -468,6 +472,15 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
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
className="row-card"
@ -485,9 +498,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
cursor="move"
/>
)}
<Typography.Text className="section-title">
{currentWidget.title}
</Typography.Text>
<Typography.Text className="section-title">{title}</Typography.Text>
{rowWidgetProperties.collapsed ? (
<ChevronDown
size={14}
@ -516,6 +527,18 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
);
}
const checkIfDataExists = (isDataAvailable: boolean): void => {
if (!isDataAvailableInAnyWidget && isDataAvailable) {
isDataAvailableInAnyWidget = true;
}
if (!isLogEventCalled.current && isDataAvailableInAnyWidget) {
isLogEventCalled.current = true;
logEvent('Dashboard Detail: Panel data fetched', {
isDataAvailableInAnyWidget,
});
}
};
return (
<CardContainer
className={isDashboardLocked ? '' : 'enable-resize'}
@ -534,6 +557,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
variables={variables}
version={selectedDashboard?.data?.version}
onDragSelect={onDragSelect}
dataAvailable={checkIfDataExists}
/>
</Card>
</CardContainer>

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