diff --git a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml index ae0fbbd357..604c2d3f67 100644 --- a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml @@ -133,7 +133,7 @@ services: # - ./data/clickhouse-3/:/var/lib/clickhouse/ alertmanager: - image: signoz/alertmanager:0.23.4 + image: signoz/alertmanager:0.23.5 volumes: - ./data/alertmanager:/data command: @@ -146,7 +146,7 @@ services: condition: on-failure query-service: - image: signoz/query-service:0.40.0 + image: signoz/query-service:0.41.0 command: [ "-config=/root/config/prometheus.yml", @@ -186,7 +186,7 @@ services: <<: *db-depend frontend: - image: signoz/frontend:0.40.0 + image: signoz/frontend:0.41.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.88.14 + image: signoz/signoz-otel-collector:0.88.15 command: [ "--config=/etc/otel-collector-config.yaml", @@ -237,7 +237,7 @@ services: - query-service otel-collector-migrator: - image: signoz/signoz-schema-migrator:0.88.14 + image: signoz/signoz-schema-migrator:0.88.15 deploy: restart_policy: condition: on-failure diff --git a/deploy/docker/clickhouse-setup/docker-compose-core.yaml b/deploy/docker/clickhouse-setup/docker-compose-core.yaml index 303016b38a..f595b86e64 100644 --- a/deploy/docker/clickhouse-setup/docker-compose-core.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose-core.yaml @@ -54,7 +54,7 @@ services: alertmanager: container_name: signoz-alertmanager - image: signoz/alertmanager:0.23.4 + image: signoz/alertmanager:0.23.5 volumes: - ./data/alertmanager:/data depends_on: @@ -66,7 +66,7 @@ services: - --storage.path=/data otel-collector-migrator: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.88.14} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.88.15} 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.88.14 + image: signoz/signoz-otel-collector:0.88.15 command: [ "--config=/etc/otel-collector-config.yaml", diff --git a/deploy/docker/clickhouse-setup/docker-compose.yaml b/deploy/docker/clickhouse-setup/docker-compose.yaml index a0cc5c4f6b..217135c72b 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.yaml @@ -149,7 +149,7 @@ services: # - ./user_scripts:/var/lib/clickhouse/user_scripts/ alertmanager: - image: signoz/alertmanager:${ALERTMANAGER_TAG:-0.23.4} + image: signoz/alertmanager:${ALERTMANAGER_TAG:-0.23.5} container_name: signoz-alertmanager volumes: - ./data/alertmanager:/data @@ -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.40.0} + image: signoz/query-service:${DOCKER_TAG:-0.41.0} container_name: signoz-query-service command: [ @@ -203,7 +203,7 @@ services: <<: *db-depend frontend: - image: signoz/frontend:${DOCKER_TAG:-0.40.0} + image: signoz/frontend:${DOCKER_TAG:-0.41.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.88.14} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.88.15} container_name: otel-migrator command: - "--dsn=tcp://clickhouse:9000" @@ -229,7 +229,7 @@ services: otel-collector: - image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.88.14} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.88.15} container_name: signoz-otel-collector command: [ diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 32bb22435f..6defd85201 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -10,6 +10,7 @@ import ( "go.signoz.io/signoz/ee/query-service/license" "go.signoz.io/signoz/ee/query-service/usage" baseapp "go.signoz.io/signoz/pkg/query-service/app" + "go.signoz.io/signoz/pkg/query-service/app/integrations" "go.signoz.io/signoz/pkg/query-service/app/logparsingpipeline" "go.signoz.io/signoz/pkg/query-service/cache" baseint "go.signoz.io/signoz/pkg/query-service/interfaces" @@ -31,6 +32,7 @@ type APIHandlerOptions struct { UsageManager *usage.Manager FeatureFlags baseint.FeatureLookup LicenseManager *license.Manager + IntegrationsController *integrations.Controller LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController Cache cache.Cache // Querier Influx Interval @@ -56,6 +58,7 @@ func NewAPIHandler(opts APIHandlerOptions) (*APIHandler, error) { AppDao: opts.AppDao, RuleManager: opts.RulesManager, FeatureFlags: opts.FeatureFlags, + IntegrationsController: opts.IntegrationsController, LogsParsingPipelineController: opts.LogsParsingPipelineController, Cache: opts.Cache, FluxInterval: opts.FluxInterval, diff --git a/ee/query-service/app/api/license.go b/ee/query-service/app/api/license.go index c6fe43a6bb..5c397020b1 100644 --- a/ee/query-service/app/api/license.go +++ b/ee/query-service/app/api/license.go @@ -12,6 +12,20 @@ import ( "go.uber.org/zap" ) +type DayWiseBreakdown struct { + Type string `json:"type"` + Breakdown []DayWiseData `json:"breakdown"` +} + +type DayWiseData struct { + Timestamp int64 `json:"timestamp"` + Count float64 `json:"count"` + Size float64 `json:"size"` + UnitPrice float64 `json:"unitPrice"` + Quantity float64 `json:"quantity"` + Total float64 `json:"total"` +} + type tierBreakdown struct { UnitPrice float64 `json:"unitPrice"` Quantity float64 `json:"quantity"` @@ -21,9 +35,10 @@ type tierBreakdown struct { } type usageResponse struct { - Type string `json:"type"` - Unit string `json:"unit"` - Tiers []tierBreakdown `json:"tiers"` + Type string `json:"type"` + Unit string `json:"unit"` + Tiers []tierBreakdown `json:"tiers"` + DayWiseBreakdown DayWiseBreakdown `json:"dayWiseBreakdown"` } type details struct { diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index f8c7633417..469632ac7f 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -35,6 +35,7 @@ import ( baseapp "go.signoz.io/signoz/pkg/query-service/app" "go.signoz.io/signoz/pkg/query-service/app/dashboards" baseexplorer "go.signoz.io/signoz/pkg/query-service/app/explorer" + "go.signoz.io/signoz/pkg/query-service/app/integrations" "go.signoz.io/signoz/pkg/query-service/app/logparsingpipeline" "go.signoz.io/signoz/pkg/query-service/app/opamp" opAmpModel "go.signoz.io/signoz/pkg/query-service/app/opamp/model" @@ -171,13 +172,22 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { } // initiate opamp - _, err = opAmpModel.InitDB(baseconst.RELATIONAL_DATASOURCE_PATH) + _, err = opAmpModel.InitDB(localDB) if err != nil { return nil, err } + integrationsController, err := integrations.NewController(localDB) + if err != nil { + return nil, fmt.Errorf( + "couldn't create integrations controller: %w", err, + ) + } + // ingestion pipelines manager - logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(localDB, "sqlite") + logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController( + localDB, "sqlite", integrationsController.GetPipelinesForInstalledIntegrations, + ) if err != nil { return nil, err } @@ -233,6 +243,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { UsageManager: usageManager, FeatureFlags: lm, LicenseManager: lm, + IntegrationsController: integrationsController, LogsParsingPipelineController: logParsingPipelineController, Cache: c, FluxInterval: fluxInterval, @@ -278,6 +289,7 @@ func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server, r := mux.NewRouter() + r.Use(baseapp.LogCommentEnricher) r.Use(setTimeoutMiddleware) r.Use(s.analyticsMiddleware) r.Use(loggingMiddlewarePrivate) @@ -310,6 +322,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler) (*http.Server, e } am := baseapp.NewAuthMiddleware(getUserFromRequest) + r.Use(baseapp.LogCommentEnricher) r.Use(setTimeoutMiddleware) r.Use(s.analyticsMiddleware) r.Use(loggingMiddleware) @@ -317,6 +330,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler) (*http.Server, e apiHandler.RegisterRoutes(r, am) apiHandler.RegisterMetricsRoutes(r, am) apiHandler.RegisterLogsRoutes(r, am) + apiHandler.RegisterIntegrationRoutes(r, am) apiHandler.RegisterQueryRangeV3Routes(r, am) apiHandler.RegisterQueryRangeV4Routes(r, am) @@ -412,7 +426,7 @@ func extractQueryRangeV3Data(path string, r *http.Request) (map[string]interface data["queryType"] = postData.CompositeQuery.QueryType data["panelType"] = postData.CompositeQuery.PanelType - signozLogsUsed, signozMetricsUsed = telemetry.GetInstance().CheckSigNozSignals(postData) + signozLogsUsed, signozMetricsUsed, _ = telemetry.GetInstance().CheckSigNozSignals(postData) } } diff --git a/ee/query-service/model/plans.go b/ee/query-service/model/plans.go index 5b6f230550..09a88bbf9f 100644 --- a/ee/query-service/model/plans.go +++ b/ee/query-service/model/plans.go @@ -90,6 +90,13 @@ var BasicPlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: basemodel.AlertChannelEmail, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, basemodel.Feature{ Name: basemodel.AlertChannelMsTeams, Active: false, @@ -177,6 +184,13 @@ var ProPlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: basemodel.AlertChannelEmail, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, basemodel.Feature{ Name: basemodel.AlertChannelMsTeams, Active: true, @@ -264,6 +278,13 @@ var EnterprisePlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: basemodel.AlertChannelEmail, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, basemodel.Feature{ Name: basemodel.AlertChannelMsTeams, Active: true, @@ -279,17 +300,17 @@ var EnterprisePlan = basemodel.FeatureSet{ Route: "", }, basemodel.Feature{ - Name: Onboarding, - Active: true, - Usage: 0, + Name: Onboarding, + Active: true, + Usage: 0, UsageLimit: -1, - Route: "", + Route: "", }, basemodel.Feature{ - Name: ChatSupport, - Active: true, - Usage: 0, + Name: ChatSupport, + Active: true, + Usage: 0, UsageLimit: -1, - Route: "", + Route: "", }, } diff --git a/frontend/package.json b/frontend/package.json index 293b4903fb..e7d1861cd4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -107,6 +107,7 @@ "react-virtuoso": "4.0.3", "redux": "^4.0.5", "redux-thunk": "^2.3.0", + "rehype-raw": "7.0.0", "stream": "^0.0.2", "style-loader": "1.3.0", "styled-components": "^5.3.11", @@ -203,6 +204,7 @@ "jest-styled-components": "^7.0.8", "lint-staged": "^12.5.0", "msw": "1.3.2", + "npm-run-all": "latest", "portfinder-sync": "^0.0.2", "prettier": "2.2.1", "raw-loader": "4.0.2", @@ -216,8 +218,7 @@ "ts-node": "^10.2.1", "typescript-plugin-css-modules": "5.0.1", "webpack-bundle-analyzer": "^4.5.0", - "webpack-cli": "^4.9.2", - "npm-run-all": "latest" + "webpack-cli": "^4.9.2" }, "lint-staged": { "*.(js|jsx|ts|tsx)": [ diff --git a/frontend/public/Icons/redis-logo.svg b/frontend/public/Icons/redis-logo.svg new file mode 100644 index 0000000000..424f1e575f --- /dev/null +++ b/frontend/public/Icons/redis-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/locales/en-GB/alerts.json b/frontend/public/locales/en-GB/alerts.json index 5b102e147d..fb360e579b 100644 --- a/frontend/public/locales/en-GB/alerts.json +++ b/frontend/public/locales/en-GB/alerts.json @@ -111,5 +111,7 @@ "exceptions_based_alert": "Exceptions-based Alert", "exceptions_based_alert_desc": "Send a notification when a condition occurs in the exceptions data.", "field_unit": "Threshold unit", + "text_alert_on_absent": "Send a notification if data is missing for", + "text_for": "minutes", "selected_query_placeholder": "Select query" } diff --git a/frontend/public/locales/en-GB/dashboard.json b/frontend/public/locales/en-GB/dashboard.json index 6179004aff..bc7969d053 100644 --- a/frontend/public/locales/en-GB/dashboard.json +++ b/frontend/public/locales/en-GB/dashboard.json @@ -25,5 +25,5 @@ "dashboard_unsave_changes": "There are unsaved changes in the Query builder, please stage and run the query or the changes will be lost. Press OK to discard.", "dashboard_save_changes": "Your graph built with {{queryTag}} query will be saved. Press OK to confirm.", "your_graph_build_with": "Your graph built with", - "dashboar_ok_confirm": "query will be saved. Press OK to confirm." + "dashboard_ok_confirm": "query will be saved. Press OK to confirm." } diff --git a/frontend/public/locales/en/alerts.json b/frontend/public/locales/en/alerts.json index 455ade61e3..0349568c70 100644 --- a/frontend/public/locales/en/alerts.json +++ b/frontend/public/locales/en/alerts.json @@ -111,5 +111,7 @@ "exceptions_based_alert": "Exceptions-based Alert", "exceptions_based_alert_desc": "Send a notification when a condition occurs in the exceptions data.", "field_unit": "Threshold unit", + "text_alert_on_absent": "Send a notification if data is missing for", + "text_for": "minutes", "selected_query_placeholder": "Select query" } diff --git a/frontend/public/locales/en/channels.json b/frontend/public/locales/en/channels.json index 63094aa911..9ab31d697c 100644 --- a/frontend/public/locales/en/channels.json +++ b/frontend/public/locales/en/channels.json @@ -23,6 +23,12 @@ "field_opsgenie_api_key": "API Key", "field_opsgenie_description": "Description", "placeholder_opsgenie_description": "Description", + "help_email_to": "Email address(es) to send alerts to (comma separated)", + "field_email_to": "To", + "placeholder_email_to": "To", + "help_email_html": "Send email in html format", + "field_email_html": "Email body template", + "placeholder_email_html": "Email body template", "field_webhook_username": "User Name (optional)", "field_webhook_password": "Password (optional)", "field_pager_routing_key": "Routing Key", diff --git a/frontend/public/locales/en/dashboard.json b/frontend/public/locales/en/dashboard.json index a74f23d228..9c0529cd73 100644 --- a/frontend/public/locales/en/dashboard.json +++ b/frontend/public/locales/en/dashboard.json @@ -28,5 +28,5 @@ "dashboard_unsave_changes": "There are unsaved changes in the Query builder, please stage and run the query or the changes will be lost. Press OK to discard.", "dashboard_save_changes": "Your graph built with {{queryTag}} query will be saved. Press OK to confirm.", "your_graph_build_with": "Your graph built with", - "dashboar_ok_confirm": "query will be saved. Press OK to confirm." + "dashboard_ok_confirm": "query will be saved. Press OK to confirm." } diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 85da13a12a..e707c998f7 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -4,6 +4,10 @@ "SERVICE_METRICS": "SigNoz | Service Metrics", "SERVICE_MAP": "SigNoz | Service Map", "GET_STARTED": "SigNoz | Get Started", + "GET_STARTED_APPLICATION_MONITORING": "SigNoz | Get Started | APM", + "GET_STARTED_LOGS_MANAGEMENT": "SigNoz | Get Started | Logs", + "GET_STARTED_INFRASTRUCTURE_MONITORING": "SigNoz | Get Started | Infrastructure", + "GET_STARTED_AWS_MONITORING": "SigNoz | Get Started | AWS", "TRACE": "SigNoz | Trace", "TRACE_DETAIL": "SigNoz | Trace Detail", "TRACES_EXPLORER": "SigNoz | Traces Explorer", @@ -40,8 +44,9 @@ "LIST_LICENSES": "SigNoz | List of Licenses", "WORKSPACE_LOCKED": "SigNoz | Workspace Locked", "SUPPORT": "SigNoz | Support", - "LOGS_SAVE_VIEWS": "SigNoz | Logs Save Views", - "TRACES_SAVE_VIEWS": "SigNoz | Traces Save Views", + "LOGS_SAVE_VIEWS": "SigNoz | Logs Saved Views", + "TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views", "DEFAULT": "Open source Observability Platform | SigNoz", - "SHORTCUTS": "SigNoz | Shortcuts" + "SHORTCUTS": "SigNoz | Shortcuts", + "INTEGRATIONS_INSTALLED": "SigNoz | Integrations" } diff --git a/frontend/scripts/typecheck-staged.sh b/frontend/scripts/typecheck-staged.sh index 7da93c088e..0990e81ba4 100644 --- a/frontend/scripts/typecheck-staged.sh +++ b/frontend/scripts/typecheck-staged.sh @@ -9,7 +9,7 @@ done # create temporary tsconfig which includes only passed files str="{ \"extends\": \"./tsconfig.json\", - \"include\": [\"src/types/global.d.ts\",\"src/typings/window.ts\", \"src/typings/chartjs-adapter-date-fns.d.ts\", \"src/typings/environment.ts\" ,\"src/container/OnboardingContainer/typings.d.ts\",$files] + \"include\": [ \"src/typings/**/*.ts\",\"src/**/*.d.ts\", \"./babel.config.js\", \"./jest.config.ts\", \"./.eslintrc.js\",\"./__mocks__\",\"./conf/default.conf\",\"./public\",\"./tests\",\"./playwright.config.ts\",\"./commitlint.config.ts\",\"./webpack.config.js\",\"./webpack.config.prod.js\",\"./jest.setup.ts\",\"./**/*.d.ts\",$files] }" echo $str > tsconfig.tmp diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index 6d26f9b55a..bea07a7e51 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -190,3 +190,18 @@ export const WorkspaceBlocked = Loadable( export const ShortcutsPage = Loadable( () => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Shortcuts'), ); + +export const InstalledIntegrations = Loadable( + () => + import( + /* webpackChunkName: "InstalledIntegrations" */ 'pages/IntegrationsModulePage' + ), +); + +export const IntegrationsMarketPlace = Loadable( + // eslint-disable-next-line sonarjs/no-identical-functions + () => + import( + /* webpackChunkName: "IntegrationsMarketPlace" */ 'pages/IntegrationsModulePage' + ), +); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index c0332448e7..360c74d8da 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -1,6 +1,4 @@ import ROUTES from 'constants/routes'; -import Shortcuts from 'pages/Shortcuts/Shortcuts'; -import WorkspaceBlocked from 'pages/WorkspaceLocked'; import { RouteProps } from 'react-router-dom'; import { @@ -16,6 +14,8 @@ import { EditRulesPage, ErrorDetails, IngestionSettings, + InstalledIntegrations, + IntegrationsMarketPlace, LicensePage, ListAllALertsPage, LiveLogs, @@ -35,6 +35,7 @@ import { ServiceMetricsPage, ServicesTablePage, SettingsPage, + ShortcutsPage, SignupPage, SomethingWentWrong, StatusPage, @@ -45,6 +46,7 @@ import { TracesSaveViews, UnAuthorized, UsageExplorerPage, + WorkspaceBlocked, } from './pageComponents'; const routes: AppRoutes[] = [ @@ -57,7 +59,7 @@ const routes: AppRoutes[] = [ }, { path: ROUTES.GET_STARTED, - exact: true, + exact: false, component: Onboarding, isPrivate: true, key: 'GET_STARTED', @@ -331,10 +333,24 @@ const routes: AppRoutes[] = [ { path: ROUTES.SHORTCUTS, exact: true, - component: Shortcuts, + component: ShortcutsPage, isPrivate: true, key: 'SHORTCUTS', }, + { + path: ROUTES.INTEGRATIONS_INSTALLED, + exact: true, + component: InstalledIntegrations, + isPrivate: true, + key: 'INTEGRATIONS_INSTALLED', + }, + { + path: ROUTES.INTEGRATIONS_MARKETPLACE, + exact: true, + component: IntegrationsMarketPlace, + isPrivate: true, + key: 'INTEGRATIONS_MARKETPLACE', + }, ]; export const SUPPORT_ROUTE: AppRoutes = { @@ -358,6 +374,8 @@ export const oldRoutes = [ '/logs/old-logs-explorer', '/logs-explorer', '/logs-explorer/live', + '/logs-save-views', + '/traces-save-views', '/settings/api-keys', ]; @@ -366,6 +384,8 @@ export const oldNewRoutesMapping: Record = { '/logs/old-logs-explorer': '/logs/old-logs-explorer', '/logs-explorer': '/logs/logs-explorer', '/logs-explorer/live': '/logs/logs-explorer/live', + '/logs-save-views': '/logs/saved-views', + '/traces-save-views': '/traces/saved-views', '/settings/api-keys': '/settings/access-tokens', }; diff --git a/frontend/src/api/Integrations/getAllIntegrations.ts b/frontend/src/api/Integrations/getAllIntegrations.ts new file mode 100644 index 0000000000..8aec6ef9cc --- /dev/null +++ b/frontend/src/api/Integrations/getAllIntegrations.ts @@ -0,0 +1,7 @@ +import axios from 'api'; +import { AxiosResponse } from 'axios'; +import { AllIntegrationsProps } from 'types/api/integrations/types'; + +export const getAllIntegrations = (): Promise< + AxiosResponse +> => axios.get(`/integrations`); diff --git a/frontend/src/api/Integrations/getIntegration.ts b/frontend/src/api/Integrations/getIntegration.ts new file mode 100644 index 0000000000..84fb696343 --- /dev/null +++ b/frontend/src/api/Integrations/getIntegration.ts @@ -0,0 +1,11 @@ +import axios from 'api'; +import { AxiosResponse } from 'axios'; +import { + GetIntegrationPayloadProps, + GetIntegrationProps, +} from 'types/api/integrations/types'; + +export const getIntegration = ( + props: GetIntegrationPayloadProps, +): Promise> => + axios.get(`/integrations/${props.integrationId}`); diff --git a/frontend/src/api/Integrations/getIntegrationStatus.ts b/frontend/src/api/Integrations/getIntegrationStatus.ts new file mode 100644 index 0000000000..fbfbca2782 --- /dev/null +++ b/frontend/src/api/Integrations/getIntegrationStatus.ts @@ -0,0 +1,11 @@ +import axios from 'api'; +import { AxiosResponse } from 'axios'; +import { + GetIntegrationPayloadProps, + GetIntegrationStatusProps, +} from 'types/api/integrations/types'; + +export const getIntegrationStatus = ( + props: GetIntegrationPayloadProps, +): Promise> => + axios.get(`/integrations/${props.integrationId}/connection_status`); diff --git a/frontend/src/api/Integrations/installIntegration.ts b/frontend/src/api/Integrations/installIntegration.ts new file mode 100644 index 0000000000..609ec00545 --- /dev/null +++ b/frontend/src/api/Integrations/installIntegration.ts @@ -0,0 +1,31 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + InstalledIntegrationsSuccessResponse, + InstallIntegrationKeyProps, +} from 'types/api/integrations/types'; + +const installIntegration = async ( + props: InstallIntegrationKeyProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post('/integrations/install', { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default installIntegration; diff --git a/frontend/src/api/Integrations/uninstallIntegration.ts b/frontend/src/api/Integrations/uninstallIntegration.ts new file mode 100644 index 0000000000..f2a9760bfc --- /dev/null +++ b/frontend/src/api/Integrations/uninstallIntegration.ts @@ -0,0 +1,31 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + UninstallIntegrationProps, + UninstallIntegrationSuccessResponse, +} from 'types/api/integrations/types'; + +const unInstallIntegration = async ( + props: UninstallIntegrationProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post('/integrations/uninstall', { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default unInstallIntegration; diff --git a/frontend/src/api/apiV1.ts b/frontend/src/api/apiV1.ts index 2e7df02395..4fba137e18 100644 --- a/frontend/src/api/apiV1.ts +++ b/frontend/src/api/apiV1.ts @@ -2,6 +2,7 @@ const apiV1 = '/api/v1/'; export const apiV2 = '/api/v2/'; export const apiV3 = '/api/v3/'; +export const apiV4 = '/api/v4/'; export const apiAlertManager = '/api/alertmanager'; export default apiV1; diff --git a/frontend/src/api/channels/createEmail.ts b/frontend/src/api/channels/createEmail.ts new file mode 100644 index 0000000000..cde74b9c6d --- /dev/null +++ b/frontend/src/api/channels/createEmail.ts @@ -0,0 +1,34 @@ +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/channels/createEmail'; + +const create = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/channels', { + name: props.name, + email_configs: [ + { + send_resolved: true, + to: props.to, + html: props.html, + headers: props.headers, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default create; diff --git a/frontend/src/api/channels/editEmail.ts b/frontend/src/api/channels/editEmail.ts new file mode 100644 index 0000000000..f20e5eb8f9 --- /dev/null +++ b/frontend/src/api/channels/editEmail.ts @@ -0,0 +1,34 @@ +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/channels/editEmail'; + +const editEmail = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/channels/${props.id}`, { + name: props.name, + email_configs: [ + { + send_resolved: true, + to: props.to, + html: props.html, + headers: props.headers, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default editEmail; diff --git a/frontend/src/api/channels/testEmail.ts b/frontend/src/api/channels/testEmail.ts new file mode 100644 index 0000000000..825836abea --- /dev/null +++ b/frontend/src/api/channels/testEmail.ts @@ -0,0 +1,34 @@ +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/channels/createEmail'; + +const testEmail = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/testChannel', { + name: props.name, + email_configs: [ + { + send_resolved: true, + to: props.to, + html: props.html, + headers: props.headers, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default testEmail; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index bde915f201..92a06363a1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -9,7 +9,7 @@ import { ENVIRONMENT } from 'constants/env'; import { LOCALSTORAGE } from 'constants/localStorage'; import store from 'store'; -import apiV1, { apiAlertManager, apiV2, apiV3 } from './apiV1'; +import apiV1, { apiAlertManager, apiV2, apiV3, apiV4 } from './apiV1'; import { Logout } from './utils'; const interceptorsResponse = ( @@ -114,6 +114,7 @@ ApiV2Instance.interceptors.request.use(interceptorsRequestResponse); export const ApiV3Instance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${apiV3}`, }); + ApiV3Instance.interceptors.response.use( interceptorsResponse, interceptorRejected, @@ -121,6 +122,18 @@ ApiV3Instance.interceptors.response.use( ApiV3Instance.interceptors.request.use(interceptorsRequestResponse); // +// axios V4 +export const ApiV4Instance = axios.create({ + baseURL: `${ENVIRONMENT.baseURL}${apiV4}`, +}); + +ApiV4Instance.interceptors.response.use( + interceptorsResponse, + interceptorRejected, +); +ApiV4Instance.interceptors.request.use(interceptorsRequestResponse); +// + AxiosAlertManagerInstance.interceptors.response.use( interceptorsResponse, interceptorRejected, diff --git a/frontend/src/api/metrics/getQueryRange.ts b/frontend/src/api/metrics/getQueryRange.ts index 984d381e10..40deb021bc 100644 --- a/frontend/src/api/metrics/getQueryRange.ts +++ b/frontend/src/api/metrics/getQueryRange.ts @@ -1,6 +1,7 @@ -import { ApiV3Instance as axios } from 'api'; +import { ApiV3Instance, ApiV4Instance } from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; +import { ENTITY_VERSION_V4 } from 'constants/app'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { MetricRangePayloadV3, @@ -9,10 +10,23 @@ import { export const getMetricsQueryRange = async ( props: QueryRangePayload, + version: string, signal: AbortSignal, ): Promise | ErrorResponse> => { try { - const response = await axios.post('/query_range', props, { signal }); + if (version && version === ENTITY_VERSION_V4) { + const response = await ApiV4Instance.post('/query_range', props, { signal }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + params: props, + }; + } + + const response = await ApiV3Instance.post('/query_range', props, { signal }); return { statusCode: 200, diff --git a/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx b/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx index 8be31b78e9..a29f0180b4 100644 --- a/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx +++ b/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx @@ -115,6 +115,9 @@ function CustomTimePicker({ const handleOpenChange = (newOpen: boolean): void => { setOpen(newOpen); + if (!newOpen) { + setCustomDTPickerVisible?.(false); + } }; const debouncedHandleInputChange = debounce((inputValue): void => { diff --git a/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx b/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx index 3141158f7f..4a41bec4f5 100644 --- a/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx +++ b/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx @@ -1,6 +1,6 @@ import './CustomTimePicker.styles.scss'; -import { Button, DatePicker } from 'antd'; +import { Button } from 'antd'; import cx from 'classnames'; import ROUTES from 'constants/routes'; import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal'; @@ -9,12 +9,10 @@ import { Option, RelativeDurationSuggestionOptions, } from 'container/TopNav/DateTimeSelectionV2/config'; -import dayjs, { Dayjs } from 'dayjs'; import { Dispatch, SetStateAction, useMemo } from 'react'; -import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; -import { AppState } from 'store/reducers'; -import { GlobalReducer } from 'types/reducer/globalTime'; + +import RangePickerModal from './RangePickerModal'; interface CustomTimePickerPopoverContentProps { options: any[]; @@ -40,35 +38,12 @@ function CustomTimePickerPopoverContent({ handleGoLive, selectedTime, }: CustomTimePickerPopoverContentProps): JSX.Element { - const { RangePicker } = DatePicker; const { pathname } = useLocation(); - const { maxTime, minTime } = useSelector( - (state) => state.globalTime, - ); - const isLogsExplorerPage = useMemo(() => pathname === ROUTES.LOGS_EXPLORER, [ pathname, ]); - const disabledDate = (current: Dayjs): boolean => { - const currentDay = dayjs(current); - return currentDay.isAfter(dayjs()); - }; - - const onPopoverClose = (visible: boolean): void => { - if (!visible) { - setCustomDTPickerVisible(false); - } - setIsOpen(visible); - }; - - const onModalOkHandler = (date_time: any): void => { - if (date_time?.[1]) { - onPopoverClose(false); - } - onCustomDateHandler(date_time, LexicalContext.CUSTOM_DATE_PICKER); - }; function getTimeChips(options: Option[]): JSX.Element { return (
@@ -105,26 +80,32 @@ function CustomTimePickerPopoverContent({ }} className={cx( 'date-time-options-btn', - selectedTime === option.value && 'active', + customDateTimeVisible + ? option.value === 'custom' && 'active' + : selectedTime === option.value && 'active', )} > {option.label} ))}
-
+
{selectedTime === 'custom' || customDateTimeVisible ? ( - ) : ( -
+
RELATIVE TIMES
{getTimeChips(RelativeDurationSuggestionOptions)}
diff --git a/frontend/src/components/CustomTimePicker/RangePickerModal.styles.scss b/frontend/src/components/CustomTimePicker/RangePickerModal.styles.scss new file mode 100644 index 0000000000..58ebe060d4 --- /dev/null +++ b/frontend/src/components/CustomTimePicker/RangePickerModal.styles.scss @@ -0,0 +1,4 @@ +.custom-date-picker { + display: flex; + flex-direction: column; +} diff --git a/frontend/src/components/CustomTimePicker/RangePickerModal.tsx b/frontend/src/components/CustomTimePicker/RangePickerModal.tsx new file mode 100644 index 0000000000..24ba0e2b01 --- /dev/null +++ b/frontend/src/components/CustomTimePicker/RangePickerModal.tsx @@ -0,0 +1,68 @@ +import './RangePickerModal.styles.scss'; + +import { DatePicker } from 'antd'; +import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal'; +import { LexicalContext } from 'container/TopNav/DateTimeSelectionV2/config'; +import dayjs, { Dayjs } from 'dayjs'; +import { Dispatch, SetStateAction } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +interface RangePickerModalProps { + setCustomDTPickerVisible: Dispatch>; + setIsOpen: Dispatch>; + onCustomDateHandler: ( + dateTimeRange: DateTimeRangeType, + lexicalContext?: LexicalContext | undefined, + ) => void; + selectedTime: string; +} + +function RangePickerModal(props: RangePickerModalProps): JSX.Element { + const { + setCustomDTPickerVisible, + setIsOpen, + onCustomDateHandler, + selectedTime, + } = props; + const { RangePicker } = DatePicker; + const { maxTime, minTime } = useSelector( + (state) => state.globalTime, + ); + + const disabledDate = (current: Dayjs): boolean => { + const currentDay = dayjs(current); + return currentDay.isAfter(dayjs()); + }; + + const onPopoverClose = (visible: boolean): void => { + if (!visible) { + setCustomDTPickerVisible(false); + } + setIsOpen(visible); + }; + + const onModalOkHandler = (date_time: any): void => { + if (date_time?.[1]) { + onPopoverClose(false); + } + onCustomDateHandler(date_time, LexicalContext.CUSTOM_DATE_PICKER); + }; + return ( +
+ +
+ ); +} + +export default RangePickerModal; diff --git a/frontend/src/components/LogDetail/LogDetails.styles.scss b/frontend/src/components/LogDetail/LogDetails.styles.scss index 0dcdc1e5c1..c8ac0be91f 100644 --- a/frontend/src/components/LogDetail/LogDetails.styles.scss +++ b/frontend/src/components/LogDetail/LogDetails.styles.scss @@ -18,6 +18,8 @@ } .ant-drawer-body { + display: flex; + flex-direction: column; padding: 16px; } diff --git a/frontend/src/components/Logs/ListLogView/index.tsx b/frontend/src/components/Logs/ListLogView/index.tsx index 74deef6cdf..5452db0f16 100644 --- a/frontend/src/components/Logs/ListLogView/index.tsx +++ b/frontend/src/components/Logs/ListLogView/index.tsx @@ -9,6 +9,7 @@ import dayjs from 'dayjs'; import dompurify from 'dompurify'; import { useActiveLog } from 'hooks/logs/useActiveLog'; import { useCopyLogLink } from 'hooks/logs/useCopyLogLink'; +import { useIsDarkMode } from 'hooks/useDarkMode'; // utils import { FlatLogData } from 'lib/logs/flatLogData'; import { useCallback, useMemo, useState } from 'react'; @@ -19,9 +20,8 @@ import { ILog } from 'types/api/logs/log'; // components import AddToQueryHOC, { AddToQueryHOCProps } from '../AddToQueryHOC'; import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButtons'; -import LogStateIndicator, { - LogType, -} from '../LogStateIndicator/LogStateIndicator'; +import LogStateIndicator from '../LogStateIndicator/LogStateIndicator'; +import { getLogIndicatorType } from '../LogStateIndicator/utils'; // styles import { Container, @@ -114,6 +114,8 @@ function ListLogView({ onClearActiveLog: handleClearActiveContextLog, } = useActiveLog(); + const isDarkMode = useIsDarkMode(); + const handlerClearActiveContextLog = useCallback( (event: React.MouseEvent | React.KeyboardEvent) => { event.preventDefault(); @@ -149,7 +151,7 @@ function ListLogView({ [flattenLogData.timestamp], ); - const logType = logData?.attributes_string?.log_level || LogType.INFO; + const logType = getLogIndicatorType(logData); const handleMouseEnter = (): void => { setHasActionButtons(true); @@ -163,6 +165,7 @@ function ListLogView({ <> ` width: 100% !important; margin-bottom: 0.3rem; cursor: pointer; .ant-card-body { padding: 0.3rem 0.6rem; - } - ${({ $isActiveLog }): string => getActiveLogBackground($isActiveLog)} + ${({ $isActiveLog, $isDarkMode }): string => + $isActiveLog + ? `background-color: ${ + $isDarkMode ? Color.BG_SLATE_500 : Color.BG_VANILLA_300 + } !important` + : ''} + } `; export const Text = styled(Typography.Text)` diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss index 6d2429b592..a00c7f6761 100644 --- a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss @@ -10,15 +10,27 @@ background-color: transparent; &.INFO { - background-color: #1d212d; + background-color: var(--bg-slate-400); } - &.WARNING { - background-color: #ffcd56; + &.WARNING, &.WARN { + background-color: var(--bg-amber-500); } &.ERROR { - background-color: #e5484d; + background-color: var(--bg-cherry-500); + } + + &.TRACE { + background-color: var(--bg-robin-300); + } + + &.DEBUG { + background-color: var(--bg-forest-500); + } + + &.FATAL { + background-color: var(--bg-sakura-500); } } diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx new file mode 100644 index 0000000000..d924c27426 --- /dev/null +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx @@ -0,0 +1,45 @@ +import { render } from '@testing-library/react'; + +import LogStateIndicator from './LogStateIndicator'; + +describe('LogStateIndicator', () => { + it('renders correctly with default props', () => { + const { container } = render(); + const indicator = container.firstChild as HTMLElement; + expect(indicator.classList.contains('log-state-indicator')).toBe(true); + expect(indicator.classList.contains('isActive')).toBe(false); + expect(container.querySelector('.line')).toBeTruthy(); + expect(container.querySelector('.line')?.classList.contains('INFO')).toBe( + true, + ); + }); + + it('renders correctly when isActive is true', () => { + const { container } = render(); + const indicator = container.firstChild as HTMLElement; + expect(indicator.classList.contains('isActive')).toBe(true); + }); + + it('renders correctly with different types', () => { + const { container: containerInfo } = render( + , + ); + expect(containerInfo.querySelector('.line')?.classList.contains('INFO')).toBe( + true, + ); + + const { container: containerWarning } = render( + , + ); + expect( + containerWarning.querySelector('.line')?.classList.contains('WARNING'), + ).toBe(true); + + const { container: containerError } = render( + , + ); + expect( + containerError.querySelector('.line')?.classList.contains('ERROR'), + ).toBe(true); + }); +}); diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx index 4c9b7de903..5355e38017 100644 --- a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx @@ -2,11 +2,40 @@ import './LogStateIndicator.styles.scss'; import cx from 'classnames'; +export const SEVERITY_TEXT_TYPE = { + TRACE: 'TRACE', + TRACE2: 'TRACE2', + TRACE3: 'TRACE3', + TRACE4: 'TRACE4', + DEBUG: 'DEBUG', + DEBUG2: 'DEBUG2', + DEBUG3: 'DEBUG3', + DEBUG4: 'DEBUG4', + INFO: 'INFO', + INFO2: 'INFO2', + INFO3: 'INFO3', + INFO4: 'INFO4', + WARN: 'WARN', + WARN2: 'WARN2', + WARN3: 'WARN3', + WARN4: 'WARN4', + WARNING: 'WARNING', + ERROR: 'ERROR', + ERROR2: 'ERROR2', + ERROR3: 'ERROR3', + ERROR4: 'ERROR4', + FATAL: 'FATAL', + FATAL2: 'FATAL2', + FATAL3: 'FATAL3', + FATAL4: 'FATAL4', +} as const; + export const LogType = { INFO: 'INFO', WARNING: 'WARNING', ERROR: 'ERROR', -}; +} as const; + function LogStateIndicator({ type, isActive, diff --git a/frontend/src/components/Logs/LogStateIndicator/utils.test.ts b/frontend/src/components/Logs/LogStateIndicator/utils.test.ts new file mode 100644 index 0000000000..65f6b9664d --- /dev/null +++ b/frontend/src/components/Logs/LogStateIndicator/utils.test.ts @@ -0,0 +1,89 @@ +import { ILog } from 'types/api/logs/log'; + +import { getLogIndicatorType, getLogIndicatorTypeForTable } from './utils'; + +describe('getLogIndicatorType', () => { + it('should return severity type for valid log with severityText', () => { + const log = { + date: '2024-02-29T12:34:46Z', + timestamp: 1646115296, + id: '123456', + traceId: '987654', + spanId: '54321', + traceFlags: 0, + severityText: 'INFO', + severityNumber: 2, + body: 'Sample log Message', + resources_string: {}, + attributesString: {}, + attributes_string: {}, + attributesInt: {}, + attributesFloat: {}, + severity_text: 'INFO', + }; + expect(getLogIndicatorType(log)).toBe('INFO'); + }); + + it('should return log level if severityText is missing', () => { + const log: ILog = { + date: '2024-02-29T12:34:58Z', + timestamp: 1646115296, + id: '123456', + traceId: '987654', + spanId: '54321', + traceFlags: 0, + severityNumber: 2, + body: 'Sample log', + resources_string: {}, + attributesString: {}, + attributes_string: {}, + attributesInt: {}, + attributesFloat: {}, + severity_text: 'FATAL', + severityText: '', + }; + expect(getLogIndicatorType(log)).toBe('FATAL'); + }); +}); + +describe('getLogIndicatorTypeForTable', () => { + it('should return severity type for valid log with severityText', () => { + const log = { + date: '2024-02-29T12:34:56Z', + timestamp: 1646115296, + id: '123456', + traceId: '987654', + spanId: '54321', + traceFlags: 0, + severity_number: 2, + body: 'Sample log message', + resources_string: {}, + attributesString: {}, + attributes_string: {}, + attributesInt: {}, + attributesFloat: {}, + severity_text: 'WARN', + }; + expect(getLogIndicatorTypeForTable(log)).toBe('WARN'); + }); + + it('should return log level if severityText is missing', () => { + const log = { + date: '2024-02-29T12:34:56Z', + timestamp: 1646115296, + id: '123456', + traceId: '987654', + spanId: '54321', + traceFlags: 0, + severityNumber: 2, + body: 'Sample log message', + resources_string: {}, + attributesString: {}, + attributes_string: {}, + attributesInt: {}, + attributesFloat: {}, + log_level: 'INFO', + }; + expect(getLogIndicatorTypeForTable(log)).toBe('INFO'); + }); +}); diff --git a/frontend/src/components/Logs/LogStateIndicator/utils.ts b/frontend/src/components/Logs/LogStateIndicator/utils.ts new file mode 100644 index 0000000000..7bfe7a430a --- /dev/null +++ b/frontend/src/components/Logs/LogStateIndicator/utils.ts @@ -0,0 +1,57 @@ +import { ILog } from 'types/api/logs/log'; + +import { LogType, SEVERITY_TEXT_TYPE } from './LogStateIndicator'; + +const getSeverityType = (severityText: string): string => { + switch (severityText) { + case SEVERITY_TEXT_TYPE.TRACE: + case SEVERITY_TEXT_TYPE.TRACE2: + case SEVERITY_TEXT_TYPE.TRACE3: + case SEVERITY_TEXT_TYPE.TRACE4: + return SEVERITY_TEXT_TYPE.TRACE; + case SEVERITY_TEXT_TYPE.DEBUG: + case SEVERITY_TEXT_TYPE.DEBUG2: + case SEVERITY_TEXT_TYPE.DEBUG3: + case SEVERITY_TEXT_TYPE.DEBUG4: + return SEVERITY_TEXT_TYPE.DEBUG; + case SEVERITY_TEXT_TYPE.INFO: + case SEVERITY_TEXT_TYPE.INFO2: + case SEVERITY_TEXT_TYPE.INFO3: + case SEVERITY_TEXT_TYPE.INFO4: + return SEVERITY_TEXT_TYPE.INFO; + case SEVERITY_TEXT_TYPE.WARN: + case SEVERITY_TEXT_TYPE.WARN2: + case SEVERITY_TEXT_TYPE.WARN3: + case SEVERITY_TEXT_TYPE.WARN4: + case SEVERITY_TEXT_TYPE.WARNING: + return SEVERITY_TEXT_TYPE.WARN; + case SEVERITY_TEXT_TYPE.ERROR: + case SEVERITY_TEXT_TYPE.ERROR2: + case SEVERITY_TEXT_TYPE.ERROR3: + case SEVERITY_TEXT_TYPE.ERROR4: + return SEVERITY_TEXT_TYPE.ERROR; + case SEVERITY_TEXT_TYPE.FATAL: + case SEVERITY_TEXT_TYPE.FATAL2: + case SEVERITY_TEXT_TYPE.FATAL3: + case SEVERITY_TEXT_TYPE.FATAL4: + return SEVERITY_TEXT_TYPE.FATAL; + default: + return SEVERITY_TEXT_TYPE.INFO; + } +}; + +export const getLogIndicatorType = (logData: ILog): string => { + if (logData.severity_text) { + return getSeverityType(logData.severity_text); + } + return logData.attributes_string?.log_level || LogType.INFO; +}; + +export const getLogIndicatorTypeForTable = ( + log: Record, +): string => { + if (log.severity_text) { + return getSeverityType(log.severity_text as string); + } + return (log.log_level as string) || LogType.INFO; +}; diff --git a/frontend/src/components/Logs/RawLogView/index.tsx b/frontend/src/components/Logs/RawLogView/index.tsx index 94c9dbe1bb..099e0fcc25 100644 --- a/frontend/src/components/Logs/RawLogView/index.tsx +++ b/frontend/src/components/Logs/RawLogView/index.tsx @@ -23,9 +23,8 @@ import { } from 'react'; import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButtons'; -import LogStateIndicator, { - LogType, -} from '../LogStateIndicator/LogStateIndicator'; +import LogStateIndicator from '../LogStateIndicator/LogStateIndicator'; +import { getLogIndicatorType } from '../LogStateIndicator/utils'; // styles import { RawLogContent, RawLogViewContainer } from './styles'; import { RawLogViewProps } from './types'; @@ -64,7 +63,7 @@ function RawLogView({ const severityText = data.severity_text ? `${data.severity_text} |` : ''; - const logType = data?.attributes_string?.log_level || LogType.INFO; + const logType = getLogIndicatorType(data); const updatedSelecedFields = useMemo( () => selectedFields.filter((e) => e.name !== 'id'), @@ -164,7 +163,11 @@ function RawLogView({ > + $isHightlightedLog + ? `background-color: ${ + $isDarkMode ? Color.BG_SLATE_500 : Color.BG_VANILLA_300 + }; + transition: background-color 2s ease-in;` + : ''} `; export const ExpandIconWrapper = styled(Col)` diff --git a/frontend/src/components/Logs/TableView/config.ts b/frontend/src/components/Logs/TableView/config.ts index 73b5f9a4c3..7a267dc624 100644 --- a/frontend/src/components/Logs/TableView/config.ts +++ b/frontend/src/components/Logs/TableView/config.ts @@ -14,12 +14,12 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties { lineHeight: '18px', letterSpacing: '-0.07px', marginBottom: '0px', + minWidth: '10rem', }; } export const defaultTableStyle: CSSProperties = { minWidth: '40rem', - maxWidth: '40rem', }; export const defaultListViewPanelStyle: CSSProperties = { diff --git a/frontend/src/components/Logs/TableView/types.ts b/frontend/src/components/Logs/TableView/types.ts index 3176101d9d..36a796ac0f 100644 --- a/frontend/src/components/Logs/TableView/types.ts +++ b/frontend/src/components/Logs/TableView/types.ts @@ -23,6 +23,7 @@ export type UseTableViewProps = { onOpenLogsContext?: (log: ILog) => void; onClickExpand?: (log: ILog) => void; activeLog?: ILog | null; + activeLogIndex?: number; activeContextLog?: ILog | null; isListViewPanel?: boolean; } & LogsTableViewProps; diff --git a/frontend/src/components/Logs/TableView/useTableView.tsx b/frontend/src/components/Logs/TableView/useTableView.tsx index 9db2332635..259e046370 100644 --- a/frontend/src/components/Logs/TableView/useTableView.tsx +++ b/frontend/src/components/Logs/TableView/useTableView.tsx @@ -7,12 +7,10 @@ import dayjs from 'dayjs'; import dompurify from 'dompurify'; import { useIsDarkMode } from 'hooks/useDarkMode'; import { FlatLogData } from 'lib/logs/flatLogData'; -import { defaultTo } from 'lodash-es'; import { useMemo } from 'react'; -import LogStateIndicator, { - LogType, -} from '../LogStateIndicator/LogStateIndicator'; +import LogStateIndicator from '../LogStateIndicator/LogStateIndicator'; +import { getLogIndicatorTypeForTable } from '../LogStateIndicator/utils'; import { defaultListViewPanelStyle, defaultTableStyle, @@ -84,7 +82,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { children: (
{ if ( maxLinesPerRow && @@ -221,8 +219,6 @@ export default function LogsFormatOptionsMenu({ className="column-name" key={value} onClick={(eve): void => { - console.log('coluimn name', label, value); - eve.stopPropagation(); if (addColumn && addColumn?.onSelect) { diff --git a/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx b/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx index cd6a5fdc33..20be0677bd 100644 --- a/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx +++ b/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx @@ -1,10 +1,12 @@ /* eslint-disable no-restricted-syntax */ /* eslint-disable react/jsx-props-no-spreading */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ + import ReactMarkdown from 'react-markdown'; import { CodeProps } from 'react-markdown/lib/ast-to-react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { a11yDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'; +import rehypeRaw from 'rehype-raw'; import CodeCopyBtn from './CodeCopyBtn/CodeCopyBtn'; @@ -74,6 +76,10 @@ const interpolateMarkdown = ( return interpolatedContent; }; +function CustomTag({ color }: { color: string }): JSX.Element { + return

This is custom element

; +} + function MarkdownRenderer({ markdownContent, variables, @@ -85,12 +91,14 @@ function MarkdownRenderer({ return ( {interpolatedMarkdown} diff --git a/frontend/src/constants/app.ts b/frontend/src/constants/app.ts index 8529db4e4d..d260806856 100644 --- a/frontend/src/constants/app.ts +++ b/frontend/src/constants/app.ts @@ -13,3 +13,6 @@ export const SIGNOZ_UPGRADE_PLAN_URL = 'https://upgrade.signoz.io/upgrade-from-app'; export const DASHBOARD_TIME_IN_DURATION = 'refreshInterval'; + +export const DEFAULT_ENTITY_VERSION = 'v3'; +export const ENTITY_VERSION_V4 = 'v4'; diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index 296735b286..0ba6cac302 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -16,4 +16,5 @@ export enum LOCALSTORAGE { CHAT_SUPPORT = 'CHAT_SUPPORT', IS_IDENTIFIED_USER = 'IS_IDENTIFIED_USER', DASHBOARD_VARIABLES = 'DASHBOARD_VARIABLES', + SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR', } diff --git a/frontend/src/constants/queryBuilder.ts b/frontend/src/constants/queryBuilder.ts index 936bfccdde..0999b634ba 100644 --- a/frontend/src/constants/queryBuilder.ts +++ b/frontend/src/constants/queryBuilder.ts @@ -36,6 +36,11 @@ import { v4 as uuid } from 'uuid'; import { logsAggregateOperatorOptions, metricAggregateOperatorOptions, + metricsGaugeAggregateOperatorOptions, + metricsGaugeSpaceAggregateOperatorOptions, + metricsHistogramSpaceAggregateOperatorOptions, + metricsSumAggregateOperatorOptions, + metricsSumSpaceAggregateOperatorOptions, tracesAggregateOperatorOptions, } from './queryBuilderOperators'; @@ -74,6 +79,18 @@ export const mapOfOperators = { traces: tracesAggregateOperatorOptions, }; +export const metricsOperatorsByType = { + Sum: metricsSumAggregateOperatorOptions, + Gauge: metricsGaugeAggregateOperatorOptions, +}; + +export const metricsSpaceAggregationOperatorsByType = { + Sum: metricsSumSpaceAggregateOperatorOptions, + Gauge: metricsGaugeSpaceAggregateOperatorOptions, + Histogram: metricsHistogramSpaceAggregateOperatorOptions, + ExponentialHistogram: metricsHistogramSpaceAggregateOperatorOptions, +}; + export const mapOfQueryFilters: Record = { metrics: [ // eslint-disable-next-line sonarjs/no-duplicate-string @@ -148,6 +165,9 @@ export const initialQueryBuilderFormValues: IBuilderQuery = { queryName: createNewBuilderItemName({ existNames: [], sourceNames: alphabet }), aggregateOperator: MetricAggregateOperator.COUNT, aggregateAttribute: initialAutocompleteData, + timeAggregation: MetricAggregateOperator.RATE, + spaceAggregation: MetricAggregateOperator.SUM, + functions: [], filters: { items: [], op: 'AND' }, expression: createNewBuilderItemName({ existNames: [], @@ -160,7 +180,7 @@ export const initialQueryBuilderFormValues: IBuilderQuery = { orderBy: [], groupBy: [], legend: '', - reduceTo: 'sum', + reduceTo: 'avg', }; const initialQueryBuilderFormLogsValues: IBuilderQuery = { @@ -268,6 +288,14 @@ export enum PANEL_TYPES { EMPTY_WIDGET = 'EMPTY_WIDGET', } +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum ATTRIBUTE_TYPES { + SUM = 'Sum', + GAUGE = 'Gauge', + HISTOGRAM = 'Histogram', + EXPONENTIAL_HISTOGRAM = 'ExponentialHistogram', +} + export type IQueryBuilderState = 'search'; export const QUERY_BUILDER_SEARCH_VALUES = { diff --git a/frontend/src/constants/queryBuilderOperators.ts b/frontend/src/constants/queryBuilderOperators.ts index 7c5cff2b69..581d517875 100644 --- a/frontend/src/constants/queryBuilderOperators.ts +++ b/frontend/src/constants/queryBuilderOperators.ts @@ -302,3 +302,126 @@ export const logsAggregateOperatorOptions: SelectOption[] = [ label: 'Rate_max', }, ]; + +export const metricsSumAggregateOperatorOptions: SelectOption< + string, + string +>[] = [ + { + value: MetricAggregateOperator.RATE, + label: 'Rate', + }, + { + value: MetricAggregateOperator.INCREASE, + label: 'Increase', + }, +]; + +export const metricsGaugeAggregateOperatorOptions: SelectOption< + string, + string +>[] = [ + { + value: MetricAggregateOperator.LATEST, + label: 'Latest', + }, + { + value: MetricAggregateOperator.SUM, + label: 'Sum', + }, + { + value: MetricAggregateOperator.AVG, + label: 'Avg', + }, + { + value: MetricAggregateOperator.MIN, + label: 'Min', + }, + { + value: MetricAggregateOperator.MAX, + label: 'Max', + }, + { + value: MetricAggregateOperator.COUNT, + label: 'Count', + }, + { + value: MetricAggregateOperator.COUNT_DISTINCT, + label: 'Count Distinct', + }, +]; + +export const metricsSumSpaceAggregateOperatorOptions: SelectOption< + string, + string +>[] = [ + { + value: MetricAggregateOperator.SUM, + label: 'Sum', + }, + { + value: MetricAggregateOperator.AVG, + label: 'Avg', + }, + { + value: MetricAggregateOperator.MIN, + label: 'Min', + }, + { + value: MetricAggregateOperator.MAX, + label: 'Max', + }, +]; + +export const metricsGaugeSpaceAggregateOperatorOptions: SelectOption< + string, + string +>[] = [ + { + value: MetricAggregateOperator.SUM, + label: 'Sum', + }, + { + value: MetricAggregateOperator.AVG, + label: 'Avg', + }, + { + value: MetricAggregateOperator.MIN, + label: 'Min', + }, + { + value: MetricAggregateOperator.MAX, + label: 'Max', + }, +]; + +export const metricsHistogramSpaceAggregateOperatorOptions: SelectOption< + string, + string +>[] = [ + { + value: MetricAggregateOperator.P50, + label: 'P50', + }, + { + value: MetricAggregateOperator.P75, + label: 'P75', + }, + { + value: MetricAggregateOperator.P90, + label: 'P90', + }, + { + value: MetricAggregateOperator.P95, + label: 'P95', + }, + { + value: MetricAggregateOperator.P99, + label: 'P99', + }, +]; + +export const metricsEmptyTimeAggregateOperatorOptions: SelectOption< + string, + string +>[] = []; diff --git a/frontend/src/constants/queryFunctionOptions.ts b/frontend/src/constants/queryFunctionOptions.ts new file mode 100644 index 0000000000..b79f673c46 --- /dev/null +++ b/frontend/src/constants/queryFunctionOptions.ts @@ -0,0 +1,137 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { QueryFunctionsTypes } from 'types/common/queryBuilder'; +import { SelectOption } from 'types/common/select'; + +export const queryFunctionOptions: SelectOption[] = [ + { + value: QueryFunctionsTypes.CUTOFF_MIN, + label: 'Cut Off Min', + }, + { + value: QueryFunctionsTypes.CUTOFF_MAX, + label: 'Cut Off Max', + }, + { + value: QueryFunctionsTypes.CLAMP_MIN, + label: 'Clamp Min', + }, + { + value: QueryFunctionsTypes.CLAMP_MAX, + label: 'Clamp Max', + }, + { + value: QueryFunctionsTypes.ABSOLUTE, + label: 'Absolute', + }, + { + value: QueryFunctionsTypes.LOG_2, + label: 'Log2', + }, + { + value: QueryFunctionsTypes.LOG_10, + label: 'Log10', + }, + { + value: QueryFunctionsTypes.CUMULATIVE_SUM, + label: 'Cumulative Sum', + }, + { + value: QueryFunctionsTypes.EWMA_3, + label: 'EWMA 3', + }, + { + value: QueryFunctionsTypes.EWMA_5, + label: 'EWMA 5', + }, + { + value: QueryFunctionsTypes.EWMA_7, + label: 'EWMA 7', + }, + { + value: QueryFunctionsTypes.MEDIAN_3, + label: 'Median 3', + }, + { + value: QueryFunctionsTypes.MEDIAN_5, + label: 'Median 5', + }, + { + value: QueryFunctionsTypes.MEDIAN_7, + label: 'Median 7', + }, + { + value: QueryFunctionsTypes.TIME_SHIFT, + label: 'Time Shift', + }, +]; + +interface QueryFunctionConfigType { + [key: string]: { + showInput: boolean; + inputType?: string; + placeholder?: string; + }; +} + +export const queryFunctionsTypesConfig: QueryFunctionConfigType = { + cutOffMin: { + showInput: true, + inputType: 'text', + placeholder: 'Threshold', + }, + cutOffMax: { + showInput: true, + inputType: 'text', + placeholder: 'Threshold', + }, + clampMin: { + showInput: true, + inputType: 'text', + placeholder: 'Threshold', + }, + clampMax: { + showInput: true, + inputType: 'text', + placeholder: 'Threshold', + }, + absolute: { + showInput: false, + }, + log2: { + showInput: false, + }, + log10: { + showInput: false, + }, + cumSum: { + showInput: false, + }, + ewma3: { + showInput: true, + inputType: 'text', + placeholder: 'Alpha', + }, + ewma5: { + showInput: true, + inputType: 'text', + placeholder: 'Alpha', + }, + ewma7: { + showInput: true, + inputType: 'text', + placeholder: 'Alpha', + }, + median3: { + showInput: false, + }, + median5: { + showInput: false, + }, + median7: { + showInput: false, + }, + timeShift: { + showInput: true, + inputType: 'text', + }, +}; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 2f7c650912..0b087ff8cd 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -7,6 +7,11 @@ const ROUTES = { TRACE_DETAIL: '/trace/:id', TRACES_EXPLORER: '/traces-explorer', GET_STARTED: '/get-started', + GET_STARTED_APPLICATION_MONITORING: '/get-started/application-monitoring', + GET_STARTED_LOGS_MANAGEMENT: '/get-started/logs-management', + GET_STARTED_INFRASTRUCTURE_MONITORING: + '/get-started/infrastructure-monitoring', + GET_STARTED_AWS_MONITORING: '/get-started/aws-monitoring', USAGE_EXPLORER: '/usage-explorer', APPLICATION: '/services', ALL_DASHBOARD: '/dashboard', @@ -42,10 +47,13 @@ const ROUTES = { TRACE_EXPLORER: '/trace-explorer', BILLING: '/billing', SUPPORT: '/support', - LOGS_SAVE_VIEWS: '/logs-save-views', - TRACES_SAVE_VIEWS: '/traces-save-views', + LOGS_SAVE_VIEWS: '/logs/saved-views', + TRACES_SAVE_VIEWS: '/traces/saved-views', WORKSPACE_LOCKED: '/workspace-locked', SHORTCUTS: '/shortcuts', + INTEGRATIONS_BASE: '/integrations', + INTEGRATIONS_INSTALLED: '/integrations/installed', + INTEGRATIONS_MARKETPLACE: '/integrations/marketplace', } as const; export default ROUTES; diff --git a/frontend/src/constants/shortcuts/DashboardShortcuts.ts b/frontend/src/constants/shortcuts/DashboardShortcuts.ts new file mode 100644 index 0000000000..ee861708f7 --- /dev/null +++ b/frontend/src/constants/shortcuts/DashboardShortcuts.ts @@ -0,0 +1,17 @@ +import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS'; + +const userOS = getUserOperatingSystem(); + +export const DashboardShortcuts = { + SaveChanges: 's+meta', + DiscardChanges: 'd+meta', +}; + +export const DashboardShortcutsName = { + SaveChanges: `${userOS === UserOperatingSystem.MACOS ? 'cmd' : 'ctrl'}+s`, +}; + +export const DashboardShortcutsDescription = { + SaveChanges: 'Save Changes', + DiscardChanges: 'Discard Changes', +}; diff --git a/frontend/src/constants/shortcuts/QBShortcuts.ts b/frontend/src/constants/shortcuts/QBShortcuts.ts new file mode 100644 index 0000000000..56fea081df --- /dev/null +++ b/frontend/src/constants/shortcuts/QBShortcuts.ts @@ -0,0 +1,17 @@ +import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS'; + +const userOS = getUserOperatingSystem(); + +export const QBShortcuts = { + StageAndRunQuery: 'enter+meta', +}; + +export const QBShortcutsName = { + StageAndRunQuery: `${ + userOS === UserOperatingSystem.MACOS ? 'cmd' : 'ctrl' + }+enter`, +}; + +export const QBShortcutsDescription = { + StageAndRunQuery: 'Stage and Run the query', +}; diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index d44a2d26b8..5d18e7d307 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -231,7 +231,12 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const routeKey = useMemo(() => getRouteKey(pathname), [pathname]); const pageTitle = t(routeKey); const renderFullScreen = - pathname === ROUTES.GET_STARTED || pathname === ROUTES.WORKSPACE_LOCKED; + 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 || + pathname === ROUTES.GET_STARTED_AWS_MONITORING; const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false); diff --git a/frontend/src/container/BillingContainer/BillingContainer.styles.scss b/frontend/src/container/BillingContainer/BillingContainer.styles.scss index afb9e80253..05a672b18c 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.styles.scss +++ b/frontend/src/container/BillingContainer/BillingContainer.styles.scss @@ -1,13 +1,29 @@ .billing-container { - padding: 16px 0; - width: 100%; + padding-top: 36px; + width: 65%; .billing-summary { margin: 24px 8px; } .billing-details { - margin: 36px 8px; + margin: 24px 0px; + + .ant-table-title { + color: var(--bg-vanilla-400); + background-color: rgb(27, 28, 32); + } + + .ant-table-cell { + background-color: var(--bg-ink-400); + border-color: var(--bg-slate-500); + } + + .ant-table-tbody { + td { + border-color: var(--bg-slate-500); + } + } } .upgrade-plan-benefits { @@ -24,6 +40,15 @@ } } } + + .empty-graph-card { + .ant-card-body { + height: 40vh; + display: flex; + justify-content: center; + align-items: center; + } + } } .ant-skeleton.ant-skeleton-element.ant-skeleton-active { @@ -34,3 +59,20 @@ .ant-skeleton.ant-skeleton-element .ant-skeleton-input { min-width: 100% !important; } + +.lightMode { + .billing-container { + .billing-details { + .ant-table-cell { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-200); + } + + .ant-table-tbody { + td { + border-color: var(--bg-vanilla-200); + } + } + } + } +} diff --git a/frontend/src/container/BillingContainer/BillingContainer.test.tsx b/frontend/src/container/BillingContainer/BillingContainer.test.tsx index b4eadd433b..cd447e5d60 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.test.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.test.tsx @@ -12,13 +12,36 @@ import BillingContainer from './BillingContainer'; const lisenceUrl = 'http://localhost/api/v2/licenses'; +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + + const uplotMock = jest.fn(() => ({ + paths, + })); + + return { + paths, + default: uplotMock, + }; +}); + +window.ResizeObserver = + window.ResizeObserver || + jest.fn().mockImplementation(() => ({ + disconnect: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), + })); + describe('BillingContainer', () => { test('Component should render', async () => { act(() => { render(); }); - const unit = screen.getAllByText(/unit/i); - expect(unit[1]).toBeInTheDocument(); + const dataInjection = screen.getByRole('columnheader', { name: /data ingested/i, }); @@ -32,24 +55,15 @@ describe('BillingContainer', () => { }); expect(cost).toBeInTheDocument(); - const total = screen.getByRole('cell', { - name: /total/i, - }); - expect(total).toBeInTheDocument(); - const manageBilling = screen.getByRole('button', { name: /manage billing/i, }); expect(manageBilling).toBeInTheDocument(); - const dollar = screen.getByRole('cell', { - name: /\$0/i, - }); + const dollar = screen.getByText(/\$0/i); expect(dollar).toBeInTheDocument(); - const currentBill = screen.getByRole('heading', { - name: /current bill total/i, - }); + const currentBill = screen.getByText('Billing'); expect(currentBill).toBeInTheDocument(); }); @@ -61,9 +75,7 @@ describe('BillingContainer', () => { const freeTrailText = await screen.findByText('Free Trial'); expect(freeTrailText).toBeInTheDocument(); - const currentBill = await screen.findByRole('heading', { - name: /current bill total/i, - }); + const currentBill = screen.getByText('Billing'); expect(currentBill).toBeInTheDocument(); const dollar0 = await screen.findByText(/\$0/i); @@ -102,9 +114,7 @@ describe('BillingContainer', () => { render(); }); - const currentBill = await screen.findByRole('heading', { - name: /current bill total/i, - }); + const currentBill = screen.getByText('Billing'); expect(currentBill).toBeInTheDocument(); const dollar0 = await screen.findByText(/\$0/i); @@ -137,45 +147,30 @@ describe('BillingContainer', () => { res(ctx.status(200), ctx.json(notOfTrailResponse)), ), ); - render(); + const { findByText } = render(); const billingPeriodText = `Your current billing period is from ${getFormattedDate( billingSuccessResponse.data.billingPeriodStart, )} to ${getFormattedDate(billingSuccessResponse.data.billingPeriodEnd)}`; - const billingPeriod = await screen.findByRole('heading', { - name: new RegExp(billingPeriodText, 'i'), - }); + const billingPeriod = await findByText(billingPeriodText); expect(billingPeriod).toBeInTheDocument(); - const currentBill = await screen.findByRole('heading', { - name: /current bill total/i, - }); + const currentBill = screen.getByText('Billing'); expect(currentBill).toBeInTheDocument(); - const dollar0 = await screen.findAllByText(/\$1278.3/i); - expect(dollar0[0]).toBeInTheDocument(); - expect(dollar0.length).toBe(2); + const dollar0 = await screen.findByText(/\$1,278.3/i); + expect(dollar0).toBeInTheDocument(); const metricsRow = await screen.findByRole('row', { - name: /metrics Million 4012 0.1 \$ 401.2/i, + name: /metrics 4012 Million 0.1 \$ 401.2/i, }); expect(metricsRow).toBeInTheDocument(); const logRow = await screen.findByRole('row', { - name: /Logs GB 497 0.4 \$ 198.8/i, + name: /Logs 497 GB 0.4 \$ 198.8/i, }); expect(logRow).toBeInTheDocument(); - - const totalBill = await screen.findByRole('cell', { - name: /\$1278/i, - }); - expect(totalBill).toBeInTheDocument(); - - const totalBillRow = await screen.findByRole('row', { - name: /total \$1278/i, - }); - expect(totalBillRow).toBeInTheDocument(); }); test('Should render corrent day remaining in billing period', async () => { diff --git a/frontend/src/container/BillingContainer/BillingContainer.tsx b/frontend/src/container/BillingContainer/BillingContainer.tsx index e419c581ed..b31f9c4745 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.tsx @@ -2,11 +2,24 @@ import './BillingContainer.styles.scss'; import { CheckCircleOutlined } from '@ant-design/icons'; -import { Button, Col, Row, Skeleton, Table, Tag, Typography } from 'antd'; +import { Color } from '@signozhq/design-tokens'; +import { + Alert, + Button, + Card, + Col, + Flex, + Row, + Skeleton, + Table, + Tag, + Typography, +} from 'antd'; import { ColumnsType } from 'antd/es/table'; import updateCreditCardApi from 'api/billing/checkout'; import getUsage from 'api/billing/getUsage'; import manageCreditCardApi from 'api/billing/manage'; +import Spinner from 'components/Spinner'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import useAnalytics from 'hooks/analytics/useAnalytics'; @@ -22,8 +35,11 @@ import { ErrorResponse, SuccessResponse } from 'types/api'; import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout'; import { License } from 'types/api/licenses/def'; import AppReducer from 'types/reducer/app'; +import { isCloudUser } from 'utils/app'; import { getFormattedDate, getRemainingDays } from 'utils/timeUtils'; +import { BillingUsageGraph } from './BillingUsageGraph/BillingUsageGraph'; + interface DataType { key: string; name: string; @@ -104,12 +120,11 @@ export default function BillingContainer(): JSX.Element { const daysRemainingStr = 'days remaining in your billing period.'; const [headerText, setHeaderText] = useState(''); const [billAmount, setBillAmount] = useState(0); - const [totalBillAmount, setTotalBillAmount] = useState(0); const [activeLicense, setActiveLicense] = useState(null); const [daysRemaining, setDaysRemaining] = useState(0); const [isFreeTrial, setIsFreeTrial] = useState(false); const [data, setData] = useState([]); - const billCurrency = '$'; + const [apiResponse, setApiResponse] = useState({}); const { trackEvent } = useAnalytics(); @@ -120,10 +135,12 @@ export default function BillingContainer(): JSX.Element { const handleError = useAxiosError(); + const isCloudUserVal = isCloudUser(); + const processUsageData = useCallback( (data: any): void => { const { - details: { breakdown = [], total, billTotal }, + details: { breakdown = [], billTotal }, billingPeriodStart, billingPeriodEnd, } = data?.payload || {}; @@ -141,8 +158,7 @@ export default function BillingContainer(): JSX.Element { formattedUsageData.push({ key: `${index}${i}`, name: i === 0 ? element?.type : '', - unit: element?.unit, - dataIngested: tier.quantity, + dataIngested: `${tier.quantity} ${element?.unit}`, pricePerUnit: tier.unitPrice, cost: `$ ${tier.tierCost}`, }); @@ -152,7 +168,6 @@ export default function BillingContainer(): JSX.Element { } setData(formattedUsageData); - setTotalBillAmount(total); if (!licensesData?.payload?.onTrial) { const remainingDays = getRemainingDays(billingPeriodEnd) - 1; @@ -165,11 +180,13 @@ export default function BillingContainer(): JSX.Element { setDaysRemaining(remainingDays > 0 ? remainingDays : 0); setBillAmount(billTotal); } + + setApiResponse(data?.payload || {}); }, [licensesData?.payload?.onTrial], ); - const { isLoading } = useQuery( + const { isLoading, isFetching: isFetchingBillingData } = useQuery( [REACT_QUERY_KEY.GET_BILLING_USAGE, user?.userId], { queryFn: () => getUsage(activeLicense?.key || ''), @@ -208,11 +225,6 @@ export default function BillingContainer(): JSX.Element { key: 'name', render: (text): JSX.Element =>
{text}
, }, - { - title: 'Unit', - dataIndex: 'unit', - key: 'unit', - }, { title: 'Data Ingested', dataIndex: 'dataIngested', @@ -230,24 +242,6 @@ export default function BillingContainer(): JSX.Element { }, ]; - const renderSummary = (): JSX.Element => ( - - - - Total - - -   -   -   - - - ${totalBillAmount} - - - - ); - const renderTableSkeleton = (): JSX.Element => ( + !isLoading && !isFetchingBillingData ? ( + + ) : ( + + + + ), + [apiResponse, billAmount, isLoading, isFetchingBillingData], + ); + return (
- + + Billing + + + Manage your billing information, invoices, and monitor costs. + + + + -
- - {headerText} - - - {licensesData?.payload?.onTrial && - licensesData?.payload?.trialConvertedToSubscription && ( - - We have received your card details, your billing will only start after - the end of your free trial period. - - )} - - - + + + + {isCloudUserVal ? 'Enterprise Cloud' : 'Enterprise'}{' '} + {isFreeTrial ? Free Trial : ''} + + {!isLoading && !isFetchingBillingData ? ( + + {daysRemaining} {daysRemainingStr} + + ) : null} + - - + -
- - Current bill total - + {licensesData?.payload?.onTrial && + licensesData?.payload?.trialConvertedToSubscription && ( + + We have received your card details, your billing will only start after + the end of your free trial period. + + )} - - {billCurrency} - {billAmount}   - {isFreeTrial ? Free Trial : ''} - + {!isLoading && !isFetchingBillingData ? ( + + ) : ( + + )} + - - {daysRemaining} {daysRemainingStr} - -
+
- {!isLoading && ( + {!isLoading && !isFetchingBillingData && (
)} - {isLoading && renderTableSkeleton()} + {(isLoading || isFetchingBillingData) && renderTableSkeleton()} {isFreeTrial && !licensesData?.payload?.trialConvertedToSubscription && ( diff --git a/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.styles.scss b/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.styles.scss new file mode 100644 index 0000000000..e5722d4f4a --- /dev/null +++ b/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.styles.scss @@ -0,0 +1,29 @@ +.billing-graph-card { + .ant-card-body { + height: 40vh; + .uplot-graph-container { + padding: 8px; + } + } + .total-spent { + font-family: 'SF Mono' monospace; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: 24px; + } + + .total-spent-title { + font-size: 12px; + font-weight: 500; + line-height: 22px; + letter-spacing: 0.48px; + color: rgba(255, 255, 255, 0.5); + } +} + +.lightMode { + .total-spent-title { + color: var(--bg-ink-100); + } +} diff --git a/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.tsx b/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.tsx new file mode 100644 index 0000000000..fa6ce813a6 --- /dev/null +++ b/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.tsx @@ -0,0 +1,190 @@ +import './BillingUsageGraph.styles.scss'; +import '../../../lib/uPlotLib/uPlotLib.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Card, Flex, Typography } from 'antd'; +import { getComponentForPanelType } from 'constants/panelTypes'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { PropsTypePropsMap } from 'container/GridPanelSwitch/types'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useResizeObserver } from 'hooks/useDimensions'; +import tooltipPlugin from 'lib/uPlotLib/plugins/tooltipPlugin'; +import getAxes from 'lib/uPlotLib/utils/getAxes'; +import getRenderer from 'lib/uPlotLib/utils/getRenderer'; +import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData'; +import { getXAxisScale } from 'lib/uPlotLib/utils/getXAxisScale'; +import { getYAxisScale } from 'lib/uPlotLib/utils/getYAxisScale'; +import { FC, useMemo, useRef } from 'react'; +import uPlot from 'uplot'; + +import { + convertDataToMetricRangePayload, + fillMissingValuesForQuantities, +} from './utils'; + +interface BillingUsageGraphProps { + data: any; + billAmount: number; +} +const paths = ( + u: any, + seriesIdx: number, + idx0: number, + idx1: number, + extendGap: boolean, + buildClip: boolean, +): uPlot.Series.PathBuilder => { + const s = u.series[seriesIdx]; + const style = s.drawStyle; + const interp = s.lineInterpolation; + + const renderer = getRenderer(style, interp); + + return renderer(u, seriesIdx, idx0, idx1, extendGap, buildClip); +}; + +export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element { + const { data, billAmount } = props; + const graphCompatibleData = useMemo( + () => convertDataToMetricRangePayload(data), + [data], + ); + const chartData = getUPlotChartData(graphCompatibleData); + const graphRef = useRef(null); + const isDarkMode = useIsDarkMode(); + const containerDimensions = useResizeObserver(graphRef); + + const { billingPeriodStart: startTime, billingPeriodEnd: endTime } = data; + + const Component = getComponentForPanelType(PANEL_TYPES.BAR) as FC< + PropsTypePropsMap[PANEL_TYPES] + >; + + const getGraphSeries = (color: string, label: string): any => ({ + drawStyle: 'bars', + paths, + lineInterpolation: 'spline', + show: true, + label, + fill: color, + stroke: color, + width: 2, + spanGaps: true, + points: { + size: 5, + show: false, + stroke: color, + }, + }); + + const uPlotSeries: any = useMemo( + () => [ + { label: 'Timestamp', stroke: 'purple' }, + getGraphSeries( + '#7CEDBE', + graphCompatibleData.data.result[0]?.legend as string, + ), + getGraphSeries( + '#4E74F8', + graphCompatibleData.data.result[1]?.legend as string, + ), + getGraphSeries( + '#F24769', + graphCompatibleData.data.result[2]?.legend as string, + ), + ], + [graphCompatibleData.data.result], + ); + + const axesOptions = getAxes(isDarkMode, ''); + + const optionsForChart: uPlot.Options = useMemo( + () => ({ + id: 'billing-usage-breakdown', + series: uPlotSeries, + width: containerDimensions.width, + height: containerDimensions.height - 30, + axes: [ + { + ...axesOptions[0], + grid: { + ...axesOptions.grid, + show: false, + stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400, + }, + }, + { + ...axesOptions[1], + stroke: isDarkMode ? Color.BG_SLATE_200 : Color.BG_INK_400, + }, + ], + scales: { + x: { + ...getXAxisScale(startTime - 86400, endTime), // Minus 86400 from startTime to decrease a day to have a buffer start + }, + y: { + ...getYAxisScale({ + series: graphCompatibleData?.data.newResult.data.result, + yAxisUnit: '', + softMax: null, + softMin: null, + }), + }, + }, + legend: { + show: true, + live: false, + isolate: true, + }, + cursor: { + lock: false, + focus: { + prox: 1e6, + bias: 1, + }, + }, + focus: { + alpha: 0.3, + }, + padding: [32, 32, 16, 16], + plugins: [ + tooltipPlugin( + fillMissingValuesForQuantities(graphCompatibleData, chartData[0]), + '', + true, + ), + ], + }), + [ + axesOptions, + chartData, + containerDimensions.height, + containerDimensions.width, + endTime, + graphCompatibleData, + isDarkMode, + startTime, + uPlotSeries, + ], + ); + + const numberFormatter = new Intl.NumberFormat('en-US'); + + return ( + + + + + TOTAL SPENT + + + ${numberFormatter.format(billAmount)} + + + +
+ +
+
+ ); +} diff --git a/frontend/src/container/BillingContainer/BillingUsageGraph/utils.ts b/frontend/src/container/BillingContainer/BillingUsageGraph/utils.ts new file mode 100644 index 0000000000..d40c8a6097 --- /dev/null +++ b/frontend/src/container/BillingContainer/BillingUsageGraph/utils.ts @@ -0,0 +1,87 @@ +import { isEmpty, isNull } from 'lodash-es'; +import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; + +export const convertDataToMetricRangePayload = ( + data: any, +): MetricRangePayloadProps => { + const emptyStateData = { + data: { + newResult: { data: { result: [], resultType: '' } }, + result: [], + resultType: '', + }, + }; + if (isEmpty(data)) { + return emptyStateData; + } + const { + details: { breakdown = [] }, + } = data || {}; + + if (isNull(breakdown) || breakdown.length === 0) { + return emptyStateData; + } + + const payload = breakdown.map((info: any) => { + const metric = info.type; + const sortedBreakdownData = (info?.dayWiseBreakdown?.breakdown || []).sort( + (a: any, b: any) => a.timestamp - b.timestamp, + ); + const values = (sortedBreakdownData || []).map((categoryInfo: any) => [ + categoryInfo.timestamp, + categoryInfo.total, + ]); + const queryName = info.type; + const legend = info.type; + const { unit } = info; + const quantity = sortedBreakdownData.map( + (categoryInfo: any) => categoryInfo.quantity, + ); + return { metric, values, queryName, legend, quantity, unit }; + }); + + const sortedData = payload.sort((a: any, b: any) => { + const sumA = a.values.reduce((acc: any, val: any) => acc + val[1], 0); + const avgA = a.values.length ? sumA / a.values.length : 0; + const sumB = b.values.reduce((acc: any, val: any) => acc + val[1], 0); + const avgB = b.values.length ? sumB / b.values.length : 0; + + return sumA === sumB ? avgB - avgA : sumB - sumA; + }); + + return { + data: { + newResult: { data: { result: sortedData, resultType: '' } }, + result: sortedData, + resultType: '', + }, + }; +}; + +export function fillMissingValuesForQuantities( + data: any, + timestampArray: number[], +): MetricRangePayloadProps { + const { result } = data.data; + + const transformedResultArr: any[] = []; + result.forEach((item: any) => { + const timestampToQuantityMap: { [timestamp: number]: number } = {}; + item.values.forEach((val: number[], index: number) => { + timestampToQuantityMap[val[0]] = item.quantity[index]; + }); + + const quantityArray = timestampArray.map( + (timestamp: number) => timestampToQuantityMap[timestamp] ?? null, + ); + transformedResultArr.push({ ...item, quantity: quantityArray }); + }); + + return { + data: { + newResult: { data: { result: transformedResultArr, resultType: '' } }, + result: transformedResultArr, + resultType: '', + }, + }; +} diff --git a/frontend/src/container/CreateAlertChannels/config.ts b/frontend/src/container/CreateAlertChannels/config.ts index e15c1d7e08..3ee3882cc1 100644 --- a/frontend/src/container/CreateAlertChannels/config.ts +++ b/frontend/src/container/CreateAlertChannels/config.ts @@ -64,6 +64,16 @@ export interface OpsgenieChannel extends Channel { priority?: string; } +export interface EmailChannel extends Channel { + // comma separated list of email addresses to send alerts to + to: string; + // HTML body of the email notification. + html: string; + // Further headers email header key/value pairs. + // [ headers: { : , ... } ] + headers: Record; +} + export const ValidatePagerChannel = (p: PagerChannel): string => { if (!p) { return 'Received unexpected input for this channel, please contact your administrator '; diff --git a/frontend/src/container/CreateAlertChannels/defaults.ts b/frontend/src/container/CreateAlertChannels/defaults.ts index 3068d8dd0c..f687164a72 100644 --- a/frontend/src/container/CreateAlertChannels/defaults.ts +++ b/frontend/src/container/CreateAlertChannels/defaults.ts @@ -1,4 +1,4 @@ -import { OpsgenieChannel, PagerChannel } from './config'; +import { EmailChannel, OpsgenieChannel, PagerChannel } from './config'; export const PagerInitialConfig: Partial = { description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }} @@ -50,3 +50,399 @@ export const OpsgenieInitialConfig: Partial = { priority: '{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}', }; + +export const EmailInitialConfig: Partial = { + send_resolved: true, + html: ` + + + + + + {{ template "__subject" . }} + + + +
+ + + + + +
+
+ + + {{ if gt (len .Alerts.Firing) 0 }} + + + + + +
+ {{ else }} + + {{ end }} + {{ .Alerts | len }} alert{{ if gt (len .Alerts) 1 }}s{{ end }} for {{ range .GroupLabels.SortedPairs }} + {{ .Name }}={{ .Value }} + {{ end }} +
+ + {{ if gt (len .Alerts.Firing) 0 }} + + + + {{ end }} + {{ range .Alerts.Firing }} + + + + {{ end }} + {{ if gt (len .Alerts.Resolved) 0 }} + {{ if gt (len .Alerts.Firing) 0 }} + + + + {{ end }} + + + + {{ end }} + {{ range .Alerts.Resolved }} + + + + {{ end }} +
+ [{{ .Alerts.Firing | len }}] Firing +
+ Labels
+ {{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} + {{ if gt (len .Annotations) 0 }}Annotations
{{ end }} + {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} + Source
+
+
+
+
+
+ [{{ .Alerts.Resolved | len }}] Resolved +
+ Labels
+ {{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} + {{ if gt (len .Annotations) 0 }}Annotations
{{ end }} + {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} + Source
+
+
+
+
+ + `, +}; diff --git a/frontend/src/container/CreateAlertChannels/index.tsx b/frontend/src/container/CreateAlertChannels/index.tsx index d8426f71b9..51a0b6214e 100644 --- a/frontend/src/container/CreateAlertChannels/index.tsx +++ b/frontend/src/container/CreateAlertChannels/index.tsx @@ -1,9 +1,11 @@ import { Form } from 'antd'; +import createEmail from 'api/channels/createEmail'; import createMsTeamsApi from 'api/channels/createMsTeams'; import createOpsgenie from 'api/channels/createOpsgenie'; import createPagerApi from 'api/channels/createPager'; import createSlackApi from 'api/channels/createSlack'; import createWebhookApi from 'api/channels/createWebhook'; +import testEmail from 'api/channels/testEmail'; import testMsTeamsApi from 'api/channels/testMsTeams'; import testOpsGenie from 'api/channels/testOpsgenie'; import testPagerApi from 'api/channels/testPager'; @@ -18,6 +20,7 @@ import { useTranslation } from 'react-i18next'; import { ChannelType, + EmailChannel, MsTeamsChannel, OpsgenieChannel, PagerChannel, @@ -25,7 +28,11 @@ import { ValidatePagerChannel, WebhookChannel, } from './config'; -import { OpsgenieInitialConfig, PagerInitialConfig } from './defaults'; +import { + EmailInitialConfig, + OpsgenieInitialConfig, + PagerInitialConfig, +} from './defaults'; import { isChannelType } from './utils'; function CreateAlertChannels({ @@ -42,7 +49,8 @@ function CreateAlertChannels({ WebhookChannel & PagerChannel & MsTeamsChannel & - OpsgenieChannel + OpsgenieChannel & + EmailChannel > >({ text: `{{ range .Alerts -}} @@ -94,6 +102,14 @@ function CreateAlertChannels({ ...OpsgenieInitialConfig, })); } + + // reset config to email defaults + if (value === ChannelType.Email && currentType !== value) { + setSelectedConfig((selectedConfig) => ({ + ...selectedConfig, + ...EmailInitialConfig, + })); + } }, [type, selectedConfig], ); @@ -293,6 +309,43 @@ function CreateAlertChannels({ setSavingState(false); }, [prepareOpsgenieRequest, t, notifications]); + const prepareEmailRequest = useCallback( + () => ({ + name: selectedConfig?.name || '', + send_resolved: true, + to: selectedConfig?.to || '', + html: selectedConfig?.html || '', + headers: selectedConfig?.headers || {}, + }), + [selectedConfig], + ); + + const onEmailHandler = useCallback(async () => { + setSavingState(true); + try { + const request = prepareEmailRequest(); + const response = await createEmail(request); + if (response.statusCode === 200) { + notifications.success({ + message: 'Success', + description: t('channel_creation_done'), + }); + history.replace(ROUTES.ALL_CHANNELS); + } else { + notifications.error({ + message: 'Error', + description: response.error || t('channel_creation_failed'), + }); + } + } catch (error) { + notifications.error({ + message: 'Error', + description: t('channel_creation_failed'), + }); + } + setSavingState(false); + }, [prepareEmailRequest, t, notifications]); + const prepareMsTeamsRequest = useCallback( () => ({ webhook_url: selectedConfig?.webhook_url || '', @@ -339,6 +392,7 @@ function CreateAlertChannels({ [ChannelType.Pagerduty]: onPagerHandler, [ChannelType.Opsgenie]: onOpsgenieHandler, [ChannelType.MsTeams]: onMsTeamsHandler, + [ChannelType.Email]: onEmailHandler, }; if (isChannelType(value)) { @@ -360,6 +414,7 @@ function CreateAlertChannels({ onPagerHandler, onOpsgenieHandler, onMsTeamsHandler, + onEmailHandler, notifications, t, ], @@ -392,6 +447,10 @@ function CreateAlertChannels({ request = prepareOpsgenieRequest(); response = await testOpsGenie(request); break; + case ChannelType.Email: + request = prepareEmailRequest(); + response = await testEmail(request); + break; default: notifications.error({ message: 'Error', @@ -427,6 +486,7 @@ function CreateAlertChannels({ prepareOpsgenieRequest, prepareSlackRequest, prepareMsTeamsRequest, + prepareEmailRequest, notifications, ], ); @@ -455,6 +515,7 @@ function CreateAlertChannels({ ...selectedConfig, ...PagerInitialConfig, ...OpsgenieInitialConfig, + ...EmailInitialConfig, }, }} /> diff --git a/frontend/src/container/CreateAlertRule/defaults.ts b/frontend/src/container/CreateAlertRule/defaults.ts index 8517d9b18c..677f4accc4 100644 --- a/frontend/src/container/CreateAlertRule/defaults.ts +++ b/frontend/src/container/CreateAlertRule/defaults.ts @@ -1,9 +1,9 @@ +import { ENTITY_VERSION_V4 } from 'constants/app'; import { initialQueryBuilderFormValuesMap, initialQueryPromQLData, PANEL_TYPES, } from 'constants/queryBuilder'; -import ROUTES from 'constants/routes'; import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertDef, @@ -25,6 +25,7 @@ const defaultAnnotations = { export const alertDefaults: AlertDef = { alertType: AlertTypes.METRICS_BASED_ALERT, + version: ENTITY_VERSION_V4, condition: { compositeQuery: { builderQueries: { @@ -78,7 +79,6 @@ export const logAlertDefaults: AlertDef = { }, labels: { severity: 'warning', - details: `${window.location.protocol}//${window.location.host}${ROUTES.LOGS_EXPLORER}`, }, annotations: defaultAnnotations, evalWindow: defaultEvalWindow, @@ -109,7 +109,6 @@ export const traceAlertDefaults: AlertDef = { }, labels: { severity: 'warning', - details: `${window.location.protocol}//${window.location.host}/traces`, }, annotations: defaultAnnotations, evalWindow: defaultEvalWindow, @@ -140,7 +139,6 @@ export const exceptionAlertDefaults: AlertDef = { }, labels: { severity: 'warning', - details: `${window.location.protocol}//${window.location.host}/exceptions`, }, annotations: defaultAnnotations, evalWindow: defaultEvalWindow, diff --git a/frontend/src/container/CreateAlertRule/index.tsx b/frontend/src/container/CreateAlertRule/index.tsx index 9ce1634d13..a5924531b2 100644 --- a/frontend/src/container/CreateAlertRule/index.tsx +++ b/frontend/src/container/CreateAlertRule/index.tsx @@ -1,7 +1,9 @@ import { Form, Row } from 'antd'; +import { ENTITY_VERSION_V4 } from 'constants/app'; import FormAlertRules from 'container/FormAlertRules'; import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam'; import { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertDef } from 'types/api/alerts/def'; @@ -20,6 +22,10 @@ function CreateRules(): JSX.Element { AlertTypes.METRICS_BASED_ALERT, ); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const version = queryParams.get('version'); + const compositeQuery = useGetCompositeQueryParam(); const [formInstance] = Form.useForm(); @@ -37,7 +43,10 @@ function CreateRules(): JSX.Element { setInitValues(exceptionAlertDefaults); break; default: - setInitValues(alertDefaults); + setInitValues({ + ...alertDefaults, + version: version || ENTITY_VERSION_V4, + }); } }; @@ -52,6 +61,7 @@ function CreateRules(): JSX.Element { if (alertType) { onSelectType(alertType); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [compositeQuery]); if (!initValues) { diff --git a/frontend/src/container/EditAlertChannels/index.tsx b/frontend/src/container/EditAlertChannels/index.tsx index 58401bb48e..29d7816d90 100644 --- a/frontend/src/container/EditAlertChannels/index.tsx +++ b/frontend/src/container/EditAlertChannels/index.tsx @@ -1,9 +1,11 @@ import { Form } from 'antd'; +import editEmail from 'api/channels/editEmail'; import editMsTeamsApi from 'api/channels/editMsTeams'; import editOpsgenie from 'api/channels/editOpsgenie'; import editPagerApi from 'api/channels/editPager'; import editSlackApi from 'api/channels/editSlack'; import editWebhookApi from 'api/channels/editWebhook'; +import testEmail from 'api/channels/testEmail'; import testMsTeamsApi from 'api/channels/testMsTeams'; import testOpsgenie from 'api/channels/testOpsgenie'; import testPagerApi from 'api/channels/testPager'; @@ -12,6 +14,7 @@ import testWebhookApi from 'api/channels/testWebhook'; import ROUTES from 'constants/routes'; import { ChannelType, + EmailChannel, MsTeamsChannel, OpsgenieChannel, PagerChannel, @@ -39,7 +42,8 @@ function EditAlertChannels({ WebhookChannel & PagerChannel & MsTeamsChannel & - OpsgenieChannel + OpsgenieChannel & + EmailChannel > >({ ...initialValue, @@ -156,6 +160,36 @@ function EditAlertChannels({ setSavingState(false); }, [prepareWebhookRequest, t, notifications, selectedConfig]); + const prepareEmailRequest = useCallback( + () => ({ + name: selectedConfig?.name || '', + to: selectedConfig.to || '', + html: selectedConfig.html || '', + headers: selectedConfig.headers || {}, + id, + }), + [id, selectedConfig], + ); + + const onEmailEditHandler = useCallback(async () => { + setSavingState(true); + const request = prepareEmailRequest(); + const response = await editEmail(request); + if (response.statusCode === 200) { + notifications.success({ + message: 'Success', + description: t('channel_edit_done'), + }); + history.replace(ROUTES.ALL_CHANNELS); + } else { + notifications.error({ + message: 'Error', + description: response.error || t('channel_edit_failed'), + }); + } + setSavingState(false); + }, [prepareEmailRequest, t, notifications]); + const preparePagerRequest = useCallback( () => ({ name: selectedConfig.name || '', @@ -300,6 +334,8 @@ function EditAlertChannels({ onMsTeamsEditHandler(); } else if (value === ChannelType.Opsgenie) { onOpsgenieEditHandler(); + } else if (value === ChannelType.Email) { + onEmailEditHandler(); } }, [ @@ -308,6 +344,7 @@ function EditAlertChannels({ onPagerEditHandler, onMsTeamsEditHandler, onOpsgenieEditHandler, + onEmailEditHandler, ], ); @@ -338,6 +375,10 @@ function EditAlertChannels({ request = prepareOpsgenieRequest(); if (request) response = await testOpsgenie(request); break; + case ChannelType.Email: + request = prepareEmailRequest(); + if (request) response = await testEmail(request); + break; default: notifications.error({ message: 'Error', @@ -373,6 +414,7 @@ function EditAlertChannels({ prepareSlackRequest, prepareMsTeamsRequest, prepareOpsgenieRequest, + prepareEmailRequest, notifications, ], ); diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptionWrapper.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptionWrapper.tsx new file mode 100644 index 0000000000..bdb300c404 --- /dev/null +++ b/frontend/src/container/ExplorerOptions/ExplorerOptionWrapper.tsx @@ -0,0 +1,56 @@ +import { DndContext, DragEndEvent } from '@dnd-kit/core'; +import { useEffect, useState } from 'react'; + +import ExplorerOptions, { ExplorerOptionsProps } from './ExplorerOptions'; +import { + getExplorerToolBarVisibility, + setExplorerToolBarVisibility, +} from './utils'; + +type ExplorerOptionsWrapperProps = Omit< + ExplorerOptionsProps, + 'isExplorerOptionDrop' +>; + +function ExplorerOptionWrapper({ + disabled, + query, + isLoading, + onExport, + sourcepage, +}: ExplorerOptionsWrapperProps): JSX.Element { + const [isExplorerOptionHidden, setIsExplorerOptionHidden] = useState(false); + + useEffect(() => { + const toolbarVisibility = getExplorerToolBarVisibility(sourcepage); + setIsExplorerOptionHidden(!toolbarVisibility); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleDragEnd = (event: DragEndEvent): void => { + const { active, over } = event; + if ( + over !== null && + active.id === 'explorer-options-draggable' && + over.id === 'explorer-options-droppable' + ) { + setIsExplorerOptionHidden(true); + setExplorerToolBarVisibility(false, sourcepage); + } + }; + return ( + + + + ); +} + +export default ExplorerOptionWrapper; diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss b/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss index 3d04f3741a..9f4441904d 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss +++ b/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss @@ -1,6 +1,9 @@ +.hide-update { + left: calc(50% - 41px) !important; +} .explorer-update { position: fixed; - bottom: 16px; + bottom: 24px; left: calc(50% - 225px); display: flex; align-items: center; @@ -23,6 +26,10 @@ cursor: pointer; } + .hidden { + display: none; + } + .ant-divider { margin: 0; height: 28px; @@ -40,7 +47,7 @@ box-shadow: 4px 4px 16px 4px rgba(0, 0, 0, 0.25); backdrop-filter: blur(20px); position: fixed; - bottom: 16px; + bottom: 24px; left: calc(50% + 240px); transform: translate(calc(-50% - 120px), 0); transition: left 0.2s linear; @@ -55,6 +62,10 @@ .view-options, .actions { + .hidden { + display: none; + } + display: flex; justify-content: center; align-items: center; @@ -102,6 +113,9 @@ } } } + .hidden { + display: none; + } } .app-content { diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx index 1ac7fefcdd..ab26e03abc 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx +++ b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx @@ -1,5 +1,7 @@ +/* eslint-disable react/jsx-props-no-spreading */ import './ExplorerOptions.styles.scss'; +import { useDraggable } from '@dnd-kit/core'; import { Color } from '@signozhq/design-tokens'; import { Button, @@ -13,6 +15,7 @@ import { Typography, } from 'antd'; import axios from 'axios'; +import cx from 'classnames'; import { getViewDetailsUsingViewKey } from 'components/ExplorerCard/utils'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import { QueryParams } from 'constants/query'; @@ -30,12 +33,25 @@ import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange'; import { useNotifications } from 'hooks/useNotifications'; import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery'; import { Check, ConciergeBell, Disc3, Plus, X, XCircle } from 'lucide-react'; -import { CSSProperties, useCallback, useMemo, useRef, useState } from 'react'; +import { + CSSProperties, + Dispatch, + SetStateAction, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { AppState } from 'store/reducers'; import { Dashboard } from 'types/api/dashboard/getAll'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource } from 'types/common/queryBuilder'; +import AppReducer from 'types/reducer/app'; +import { USER_ROLES } from 'types/roles'; +import ExplorerOptionsDroppableArea from './ExplorerOptionsDroppableArea'; import { DATASOURCE_VS_ROUTES, generateRGBAFromHex, @@ -43,12 +59,17 @@ import { saveNewViewHandler, } from './utils'; +const allowedRoles = [USER_ROLES.ADMIN, USER_ROLES.AUTHOR, USER_ROLES.EDITOR]; + +// eslint-disable-next-line sonarjs/cognitive-complexity function ExplorerOptions({ disabled, isLoading, onExport, query, sourcepage, + isExplorerOptionHidden = false, + setIsExplorerOptionHidden, }: ExplorerOptionsProps): JSX.Element { const [isExport, setIsExport] = useState(false); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); @@ -58,6 +79,7 @@ function ExplorerOptions({ const history = useHistory(); const ref = useRef(null); const isDarkMode = useIsDarkMode(); + const [isDragEnabled, setIsDragEnabled] = useState(false); const onModalToggle = useCallback((value: boolean) => { setIsExport(value); @@ -71,6 +93,8 @@ function ExplorerOptions({ setIsSaveModalOpen(false); }; + const { role } = useSelector((state) => state.app); + const onCreateAlertsHandler = useCallback(() => { history.push( `${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent( @@ -247,10 +271,37 @@ function ExplorerOptions({ [isDarkMode], ); + const { + attributes, + listeners, + setNodeRef, + transform, + isDragging, + } = useDraggable({ + id: 'explorer-options-draggable', + disabled: isDragEnabled, + }); + + const isEditDeleteSupported = allowedRoles.includes(role as string); + + const style: React.CSSProperties | undefined = transform + ? { + transform: `translate3d(${transform.x - 338}px, ${transform.y}px, 0)`, + width: `${400 - transform.y * 6}px`, + maxWidth: '440px', // initial width of the explorer options + overflow: 'hidden', + } + : undefined; + return ( <> - {isQueryUpdated && ( -
+ {isQueryUpdated && !isExplorerOptionHidden && !isDragging && ( +
)} -
-
- - showSearch - placeholder="Select a view" - loading={viewsIsLoading || isRefetching} - value={viewName || undefined} - onSelect={handleSelect} - style={{ - minWidth: 170, - }} - dropdownStyle={dropdownStyle} - className="views-dropdown" - allowClear={{ - clearIcon: , - }} - onClear={handleClearSelect} - ref={ref} - > - {viewsData?.data?.data?.map((view) => { - const extraData = - view.extraData !== '' ? JSON.parse(view.extraData) : ''; - let bgColor = getRandomColor(); - if (extraData !== '') { - bgColor = extraData.color; - } - return ( - -
- {' '} - {view.name} -
-
- ); - })} - - - -
- -
- -
- - - + {viewsData?.data?.data?.map((view) => { + const extraData = + view.extraData !== '' ? JSON.parse(view.extraData) : ''; + let bgColor = getRandomColor(); + if (extraData !== '') { + bgColor = extraData.color; + } + return ( + +
+ {' '} + {view.name} +
+
+ ); + })} + - - - +
+ +
+ +
+ + + + + + + +
-
+ )} + + >; } -ExplorerOptions.defaultProps = { isLoading: false }; +ExplorerOptions.defaultProps = { + isLoading: false, + isExplorerOptionHidden: false, + setIsExplorerOptionHidden: undefined, +}; export default ExplorerOptions; diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptionsDroppableArea.styles.scss b/frontend/src/container/ExplorerOptions/ExplorerOptionsDroppableArea.styles.scss new file mode 100644 index 0000000000..e092229bb9 --- /dev/null +++ b/frontend/src/container/ExplorerOptions/ExplorerOptionsDroppableArea.styles.scss @@ -0,0 +1,55 @@ +.explorer-option-droppable-container { + position: fixed; + bottom: 0; + width: -webkit-fill-available; + height: 24px; + display: flex; + justify-content: center; + border-radius: 10px 10px 0px 0px; + // box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.25); + // backdrop-filter: blur(20px); + + .explorer-actions-btn { + display: flex; + gap: 8px; + margin-right: 8px; + + .action-btn { + display: flex; + justify-content: center; + align-items: center; + border-radius: 10px 10px 0px 0px; + box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(20px); + height: 24px !important; + border: none; + } + } + + .explorer-show-btn { + border-radius: 10px 10px 0px 0px; + border: 1px solid var(--bg-slate-400); + background: rgba(22, 24, 29, 0.40); + box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(20px); + align-self: center; + padding: 8px 12px; + height: 24px !important; + + .menu-bar { + border-radius: 50px; + background: var(--bg-slate-200); + height: 4px; + width: 50px; + } + } +} + +.lightMode { + .explorer-option-droppable-container { + + .explorer-show-btn { + background: var(--bg-vanilla-400); + } + } +} \ No newline at end of file diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptionsDroppableArea.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptionsDroppableArea.tsx new file mode 100644 index 0000000000..33bef7c984 --- /dev/null +++ b/frontend/src/container/ExplorerOptions/ExplorerOptionsDroppableArea.tsx @@ -0,0 +1,83 @@ +/* eslint-disable no-nested-ternary */ +import './ExplorerOptionsDroppableArea.styles.scss'; + +import { useDroppable } from '@dnd-kit/core'; +import { Color } from '@signozhq/design-tokens'; +import { Button, Tooltip } from 'antd'; +import { Disc3, X } from 'lucide-react'; +import { Dispatch, SetStateAction } from 'react'; +import { DataSource } from 'types/common/queryBuilder'; + +import { setExplorerToolBarVisibility } from './utils'; + +interface DroppableAreaProps { + isQueryUpdated: boolean; + isExplorerOptionHidden?: boolean; + sourcepage: DataSource; + setIsExplorerOptionHidden?: Dispatch>; + handleClearSelect: () => void; + onUpdateQueryHandler: () => void; +} + +function ExplorerOptionsDroppableArea({ + isQueryUpdated, + isExplorerOptionHidden, + sourcepage, + setIsExplorerOptionHidden, + handleClearSelect, + onUpdateQueryHandler, +}: DroppableAreaProps): JSX.Element { + const { setNodeRef } = useDroppable({ + id: 'explorer-options-droppable', + }); + + const handleShowExplorerOption = (): void => { + if (setIsExplorerOptionHidden) { + setIsExplorerOptionHidden(false); + setExplorerToolBarVisibility(true, sourcepage); + } + }; + + return ( +
+ {isExplorerOptionHidden && ( + <> + {isQueryUpdated && ( +
+ +
+ )} + + + )} +
+ ); +} + +ExplorerOptionsDroppableArea.defaultProps = { + isExplorerOptionHidden: undefined, + setIsExplorerOptionHidden: undefined, +}; + +export default ExplorerOptionsDroppableArea; diff --git a/frontend/src/container/ExplorerOptions/utils.ts b/frontend/src/container/ExplorerOptions/utils.ts index e3ac710609..d94e64161e 100644 --- a/frontend/src/container/ExplorerOptions/utils.ts +++ b/frontend/src/container/ExplorerOptions/utils.ts @@ -1,5 +1,6 @@ import { Color } from '@signozhq/design-tokens'; import { showErrorNotification } from 'components/ExplorerCard/utils'; +import { LOCALSTORAGE } from 'constants/localStorage'; import { QueryParams } from 'constants/query'; import ROUTES from 'constants/routes'; import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; @@ -67,3 +68,54 @@ export const generateRGBAFromHex = (hex: string, opacity: number): string => hex.slice(3, 5), 16, )}, ${parseInt(hex.slice(5, 7), 16)}, ${opacity})`; + +export const getExplorerToolBarVisibility = (dataSource: string): boolean => { + try { + const showExplorerToolbar = localStorage.getItem( + LOCALSTORAGE.SHOW_EXPLORER_TOOLBAR, + ); + if (showExplorerToolbar === null) { + const parsedShowExplorerToolbar: { + [DataSource.LOGS]: boolean; + [DataSource.TRACES]: boolean; + [DataSource.METRICS]: boolean; + } = { + [DataSource.METRICS]: true, + [DataSource.TRACES]: true, + [DataSource.LOGS]: true, + }; + localStorage.setItem( + LOCALSTORAGE.SHOW_EXPLORER_TOOLBAR, + JSON.stringify(parsedShowExplorerToolbar), + ); + return true; + } + const parsedShowExplorerToolbar = JSON.parse(showExplorerToolbar || '{}'); + return parsedShowExplorerToolbar[dataSource]; + } catch (error) { + console.error(error); + return false; + } +}; + +export const setExplorerToolBarVisibility = ( + value: boolean, + dataSource: string, +): void => { + try { + const showExplorerToolbar = localStorage.getItem( + LOCALSTORAGE.SHOW_EXPLORER_TOOLBAR, + ); + if (showExplorerToolbar) { + const parsedShowExplorerToolbar = JSON.parse(showExplorerToolbar); + parsedShowExplorerToolbar[dataSource] = value; + localStorage.setItem( + LOCALSTORAGE.SHOW_EXPLORER_TOOLBAR, + JSON.stringify(parsedShowExplorerToolbar), + ); + return; + } + } catch (error) { + console.error(error); + } +}; diff --git a/frontend/src/container/FormAlertChannels/Settings/Email.tsx b/frontend/src/container/FormAlertChannels/Settings/Email.tsx new file mode 100644 index 0000000000..398e172a57 --- /dev/null +++ b/frontend/src/container/FormAlertChannels/Settings/Email.tsx @@ -0,0 +1,48 @@ +import { Form, Input } from 'antd'; +import { Dispatch, SetStateAction } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { EmailChannel } from '../../CreateAlertChannels/config'; + +function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element { + const { t } = useTranslation('channels'); + + const handleInputChange = (field: string) => ( + event: React.ChangeEvent, + ): void => { + setSelectedConfig((value) => ({ + ...value, + [field]: event.target.value, + })); + }; + + return ( + <> + + + + + {/* +