diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..81ed7f265d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,33 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Matches multiple files with brace expansion notation +# Set default charset +[*.{js,py}] +charset = utf-8 + +# 4 space indentation +[*.py] +indent_style = space +indent_size = 4 + +# Tab indentation (no size specified) +[Makefile] +indent_style = tab + +# Indentation override for all JS under lib directory +[lib/**.js] +indent_style = space +indent_size = 2 + +# Matches the exact files either package.json or .travis.yml +[{package.json,.travis.yml}] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.github/workflows/pr_verify_linked_issue.yml b/.github/workflows/pr_verify_linked_issue.yml new file mode 100644 index 0000000000..adf2718a46 --- /dev/null +++ b/.github/workflows/pr_verify_linked_issue.yml @@ -0,0 +1,20 @@ +# This workflow will inspect a pull request to ensure there is a linked issue or a +# valid issue is mentioned in the body. If neither is present it fails the check and adds +# a comment alerting users of this missing requirement. +name: VerifyIssue + +on: + pull_request: + types: [edited, synchronize, opened, reopened] + check_run: + +jobs: + verify_linked_issue: + runs-on: ubuntu-latest + name: Ensure Pull Request has a linked issue. + steps: + - name: Verify Linked Issue + uses: hattan/verify-linked-issue-action@v1.1.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.gitignore b/.gitignore index 01a9526908..e876823dcd 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,7 @@ frontend/cypress.env.json frontend/*.env pkg/query-service/signoz.db -pkg/query-service/tframe/test-deploy/data/ +pkg/query-service/tests/test-deploy/data/ # local data diff --git a/.scripts/commentLinesForSetup.sh b/.scripts/commentLinesForSetup.sh index 7ea6b468ad..c0dfd40e9f 100644 --- a/.scripts/commentLinesForSetup.sh +++ b/.scripts/commentLinesForSetup.sh @@ -4,4 +4,4 @@ # Update the Line Numbers when deploy/docker/clickhouse-setup/docker-compose.yaml chnages. # Docs Ref.: https://github.com/SigNoz/signoz/blob/main/CONTRIBUTING.md#contribute-to-frontend-with-docker-installation-of-signoz -sed -i 38,70's/.*/# &/' .././deploy/docker/clickhouse-setup/docker-compose.yaml +sed -i 38,62's/.*/# &/' .././deploy/docker/clickhouse-setup/docker-compose.yaml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0e7a7169b..6205d85884 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,10 +18,10 @@ Need to update [https://github.com/SigNoz/signoz/tree/main/frontend](https://git ### Contribute to Frontend with Docker installation of SigNoz - `git clone https://github.com/SigNoz/signoz.git && cd signoz` -- comment out frontend service section at `deploy/docker/clickhouse-setup/docker-compose.yaml#L59` +- comment out frontend service section at `deploy/docker/clickhouse-setup/docker-compose.yaml#L62` - run `cd deploy` to move to deploy directory - Install signoz locally without the frontend - - Add below configuration to query-service section at `docker/clickhouse-setup/docker-compose.yaml#L36` + - Add below configuration to query-service section at `docker/clickhouse-setup/docker-compose.yaml#L38` ```docker ports: @@ -55,9 +55,9 @@ Need to update [https://github.com/SigNoz/signoz/tree/main/pkg/query-service](ht - git clone https://github.com/SigNoz/signoz.git - run `cd signoz` to move to signoz directory - run `sudo make dev-setup` to configure local setup to run query-service -- comment out frontend service section at `docker/clickhouse-setup/docker-compose.yaml#L45` -- comment out query-service section at `docker/clickhouse-setup/docker-compose.yaml#L28` -- add below configuration to clickhouse section at `docker/clickhouse-setup/docker-compose.yaml#L6` +- comment out frontend service section at `docker/clickhouse-setup/docker-compose.yaml` +- comment out query-service section at `docker/clickhouse-setup/docker-compose.yaml` +- add below configuration to clickhouse section at `docker/clickhouse-setup/docker-compose.yaml` ```docker expose: - 9000 diff --git a/Makefile b/Makefile index bf2b038992..8dc880a971 100644 --- a/Makefile +++ b/Makefile @@ -115,11 +115,9 @@ down-arm: @docker-compose -f $(STANDALONE_DIRECTORY)/docker-compose.arm.yaml down -v clear-standalone-data: - @cd $(STANDALONE_DIRECTORY) - @docker run --rm -v "data:/pwd" busybox \ + @docker run --rm -v "$(PWD)/$(STANDALONE_DIRECTORY)/data:/pwd" busybox \ sh -c "cd /pwd && rm -rf alertmanager/* clickhouse/* signoz/*" clear-swarm-data: - @cd $(SWARM_DIRECTORY) - @docker run --rm -v "data:/pwd" busybox \ + @docker run --rm -v "$(PWD)/$(SWARM_DIRECTORY)/data:/pwd" busybox \ sh -c "cd /pwd && rm -rf alertmanager/* clickhouse/* signoz/*" diff --git a/deploy/docker-swarm/clickhouse-setup/clickhouse-config.xml b/deploy/docker-swarm/clickhouse-setup/clickhouse-config.xml index 23898ef5e7..7a5f40d299 100644 --- a/deploy/docker-swarm/clickhouse-setup/clickhouse-config.xml +++ b/deploy/docker-swarm/clickhouse-setup/clickhouse-config.xml @@ -1,11 +1,8 @@ - trace - /var/log/clickhouse-server/clickhouse-server.log - /var/log/clickhouse-server/clickhouse-server.err.log - 1000M - 10 + information + 1 8123 @@ -45,6 +42,34 @@ + + + + - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/deploy/docker/clickhouse-setup/docker-compose.arm.yaml b/deploy/docker/clickhouse-setup/docker-compose.arm.yaml index 0540fa2868..bb5dbb5207 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.arm.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.arm.yaml @@ -3,10 +3,17 @@ version: "2.4" services: clickhouse: image: altinity/clickhouse-server:21.12.3.32.altinitydev.arm + # ports: + # - "9000:9000" + # - "8123:8123" volumes: - ./clickhouse-config.xml:/etc/clickhouse-server/config.xml - ./data/clickhouse/:/var/lib/clickhouse/ restart: on-failure + logging: + options: + max-size: 50m + max-file: "3" healthcheck: # "clickhouse", "client", "-u ${CLICKHOUSE_USER}", "--password ${CLICKHOUSE_PASSWORD}", "-q 'SELECT 1'" test: ["CMD", "wget", "--spider", "-q", "localhost:8123/ping"] @@ -15,17 +22,22 @@ services: retries: 3 alertmanager: - image: signoz/alertmanager:0.6.0 + image: signoz/alertmanager:0.23.0-0.1 volumes: - ./data/alertmanager:/data depends_on: - - query-service + query-service: + condition: service_healthy + restart: on-failure command: - --queryService.url=http://query-service:8080 - --storage.path=/data +# 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:0.7.4 + image: signoz/query-service:0.8.0 container_name: query-service command: ["-config=/root/config/prometheus.yml"] volumes: @@ -33,21 +45,28 @@ services: - ../dashboards:/root/config/dashboards - ./data/signoz/:/var/lib/signoz/ environment: - - ClickHouseUrl=tcp://clickhouse:9000 + - ClickHouseUrl=tcp://clickhouse:9000/?database=signoz_traces - STORAGE=clickhouse - GODEBUG=netdns=go - TELEMETRY_ENABLED=true - DEPLOYMENT_TYPE=docker-standalone-arm restart: on-failure + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "localhost:8080/api/v1/version"] + interval: 30s + timeout: 5s + retries: 3 depends_on: clickhouse: condition: service_healthy frontend: - image: signoz/frontend:0.7.4 + image: signoz/frontend:0.8.0 container_name: frontend + restart: on-failure depends_on: + - alertmanager - query-service ports: - "3301:3301" @@ -55,7 +74,7 @@ services: - ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf otel-collector: - image: signoz/otelcontribcol:0.43.0 + image: signoz/otelcontribcol:0.43.0-0.1 command: ["--config=/etc/otel-collector-config.yaml"] volumes: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml @@ -76,7 +95,7 @@ services: condition: service_healthy otel-collector-metrics: - image: signoz/otelcontribcol:0.43.0 + image: signoz/otelcontribcol:0.43.0-0.1 command: ["--config=/etc/otel-collector-metrics-config.yaml"] volumes: - ./otel-collector-metrics-config.yaml:/etc/otel-collector-metrics-config.yaml diff --git a/deploy/docker/clickhouse-setup/docker-compose.yaml b/deploy/docker/clickhouse-setup/docker-compose.yaml index b9c96c7bdb..7f484c913d 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.yaml @@ -3,10 +3,17 @@ version: "2.4" services: clickhouse: image: yandex/clickhouse-server:21.12.3.32 + # ports: + # - "9000:9000" + # - "8123:8123" volumes: - ./clickhouse-config.xml:/etc/clickhouse-server/config.xml - ./data/clickhouse/:/var/lib/clickhouse/ restart: on-failure + logging: + options: + max-size: 50m + max-file: "3" healthcheck: # "clickhouse", "client", "-u ${CLICKHOUSE_USER}", "--password ${CLICKHOUSE_PASSWORD}", "-q 'SELECT 1'" test: ["CMD", "wget", "--spider", "-q", "localhost:8123/ping"] @@ -15,11 +22,13 @@ services: retries: 3 alertmanager: - image: signoz/alertmanager:0.6.0 + image: signoz/alertmanager:0.23.0-0.1 volumes: - ./data/alertmanager:/data depends_on: - - query-service + query-service: + condition: service_healthy + restart: on-failure command: - --queryService.url=http://query-service:8080 - --storage.path=/data @@ -27,7 +36,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:0.7.4 + image: signoz/query-service:0.8.0 container_name: query-service command: ["-config=/root/config/prometheus.yml"] volumes: @@ -35,20 +44,27 @@ services: - ../dashboards:/root/config/dashboards - ./data/signoz/:/var/lib/signoz/ environment: - - ClickHouseUrl=tcp://clickhouse:9000 + - ClickHouseUrl=tcp://clickhouse:9000/?database=signoz_traces - STORAGE=clickhouse - GODEBUG=netdns=go - TELEMETRY_ENABLED=true - DEPLOYMENT_TYPE=docker-standalone-amd restart: on-failure + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "localhost:8080/api/v1/version"] + interval: 30s + timeout: 5s + retries: 3 depends_on: clickhouse: condition: service_healthy frontend: - image: signoz/frontend:0.7.4 + image: signoz/frontend:0.8.0 container_name: frontend + restart: on-failure depends_on: + - alertmanager - query-service ports: - "3301:3301" @@ -56,7 +72,7 @@ services: - ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf otel-collector: - image: signoz/otelcontribcol:0.43.0 + image: signoz/otelcontribcol:0.43.0-0.1 command: ["--config=/etc/otel-collector-config.yaml"] volumes: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml @@ -77,7 +93,7 @@ services: condition: service_healthy otel-collector-metrics: - image: signoz/otelcontribcol:0.43.0 + image: signoz/otelcontribcol:0.43.0-0.1 command: ["--config=/etc/otel-collector-metrics-config.yaml"] volumes: - ./otel-collector-metrics-config.yaml:/etc/otel-collector-metrics-config.yaml @@ -87,15 +103,15 @@ services: condition: service_healthy hotrod: - image: jaegertracing/example-hotrod:1.30 - container_name: hotrod - logging: - options: - max-size: 50m - max-file: "3" - command: ["all"] - environment: - - JAEGER_ENDPOINT=http://otel-collector:14268/api/traces + image: jaegertracing/example-hotrod:1.30 + container_name: hotrod + logging: + options: + max-size: 50m + max-file: "3" + command: ["all"] + environment: + - JAEGER_ENDPOINT=http://otel-collector:14268/api/traces load-hotrod: image: "grubykarol/locust:1.2.3-python3.9-alpine3.12" diff --git a/deploy/docker/clickhouse-setup/otel-collector-config.yaml b/deploy/docker/clickhouse-setup/otel-collector-config.yaml index 98a336988c..c59a8f0e87 100644 --- a/deploy/docker/clickhouse-setup/otel-collector-config.yaml +++ b/deploy/docker/clickhouse-setup/otel-collector-config.yaml @@ -28,6 +28,11 @@ processors: metrics_exporter: prometheus latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ] dimensions_cache_size: 10000 + dimensions: + - name: service.namespace + default: default + - name: deployment.environment + default: default # memory_limiter: # # 80% of maximum memory up to 2G # limit_mib: 1500 @@ -48,7 +53,7 @@ extensions: zpages: {} exporters: clickhouse: - datasource: tcp://clickhouse:9000 + datasource: tcp://clickhouse:9000/?database=signoz_traces clickhousemetricswrite: endpoint: tcp://clickhouse:9000/?database=signoz_metrics resource_to_telemetry_conversion: @@ -68,4 +73,4 @@ service: exporters: [clickhousemetricswrite] metrics/spanmetrics: receivers: [otlp/spanmetrics] - exporters: [prometheus] \ No newline at end of file + exporters: [prometheus] diff --git a/deploy/docker/clickhouse-setup/otel-collector-metrics-config.yaml b/deploy/docker/clickhouse-setup/otel-collector-metrics-config.yaml index 0563a397da..cd5ede2358 100644 --- a/deploy/docker/clickhouse-setup/otel-collector-metrics-config.yaml +++ b/deploy/docker/clickhouse-setup/otel-collector-metrics-config.yaml @@ -44,4 +44,4 @@ service: metrics: receivers: [otlp, prometheus] processors: [batch] - exporters: [clickhousemetricswrite] \ No newline at end of file + exporters: [clickhousemetricswrite] diff --git a/deploy/docker/common/nginx-config.conf b/deploy/docker/common/nginx-config.conf index 3c7a9db8f0..3444de7808 100644 --- a/deploy/docker/common/nginx-config.conf +++ b/deploy/docker/common/nginx-config.conf @@ -1,7 +1,7 @@ server { listen 3301; server_name _; - + gzip on; gzip_static on; gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; @@ -12,22 +12,25 @@ server { gzip_http_version 1.1; location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri/ /index.html; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0"; + add_header Last-Modified $date_gmt; + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; } + location /api/alertmanager{ - proxy_pass http://alertmanager:9093/api/v2; + proxy_pass http://alertmanager:9093/api/v2; } + location /api { - proxy_pass http://query-service:8080/api; - + proxy_pass http://query-service:8080/api; } # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { - root /usr/share/nginx/html; + root /usr/share/nginx/html; } } \ No newline at end of file diff --git a/deploy/install.sh b/deploy/install.sh index 569394bee5..abca7f0878 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -247,7 +247,7 @@ bye() { # Prints a friendly good bye message and exits the script. echo "or reach us for support in #help channel in our Slack Community https://signoz.io/slack" echo "++++++++++++++++++++++++++++++++++++++++" - if [[ email == "" ]]; then + if [[ $email == "" ]]; then echo -e "\n📨 Please share your email to receive support with the installation" read -rp 'Email: ' email diff --git a/frontend/.eslintignore b/frontend/.eslintignore index b7dab5e9cb..545037e39a 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -1,2 +1,3 @@ node_modules -build \ No newline at end of file +build +*.typegen.ts diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index fb9d999579..1c1aef5939 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -101,12 +101,11 @@ module.exports = { }, }, ], + '@typescript-eslint/no-unused-vars': 'error', // eslint rules need to remove 'no-shadow': 'off', '@typescript-eslint/no-shadow': 'off', - 'global-require': 'off', - '@typescript-eslint/no-var-requires': 'off', 'import/no-cycle': 'off', 'prettier/prettier': [ diff --git a/frontend/babel.config.js b/frontend/babel.config.js new file mode 100644 index 0000000000..b3df57dfb0 --- /dev/null +++ b/frontend/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], +}; diff --git a/frontend/cypress/CustomFunctions/Login.ts b/frontend/cypress/CustomFunctions/Login.ts index dba33d90aa..3d3c8f791a 100644 --- a/frontend/cypress/CustomFunctions/Login.ts +++ b/frontend/cypress/CustomFunctions/Login.ts @@ -1,5 +1,6 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ const Login = ({ email, name }: LoginProps): void => { - const emailInput = cy.findByPlaceholderText('mike@netflix.com'); + const emailInput = cy.findByPlaceholderText('name@yourcompany.com'); emailInput.then((emailInput) => { const element = emailInput[0]; @@ -13,7 +14,7 @@ const Login = ({ email, name }: LoginProps): void => { expect(inputValue).to.be.equals(email); }); - const firstNameInput = cy.findByPlaceholderText('Mike'); + const firstNameInput = cy.findByPlaceholderText('Your Name'); firstNameInput.then((firstNameInput) => { const element = firstNameInput[0]; // element is present diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index a949a7611b..5b5ebcdbc7 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -9,12 +9,19 @@ const config: Config.InitialOptions = { moduleNameMapper: { '\\.(css|less)$': '/__mocks__/cssMock.ts', }, - notify: true, - notifyMode: 'always', - testMatch: ['/src/**/?(*.)(test).(ts|js)?(x)'], - transform: { - '\\.(js|jsx|ts|tsx)?$': 'babel-jest', + globals: { + extensionsToTreatAsEsm: ['.ts'], + 'ts-jest': { + useESM: true, + }, }, + testMatch: ['/src/**/?(*.)(test).(ts|js)?(x)'], + preset: 'ts-jest/presets/js-with-ts-esm', + transform: { + '^.+\\.(ts|tsx)?$': 'ts-jest', + '^.+\\.(js|jsx)$': 'babel-jest', + }, + transformIgnorePatterns: ['node_modules/(?!(lodash-es)/)'], setupFilesAfterEnv: ['jest.setup.ts'], testPathIgnorePatterns: ['/node_modules/', '/public/'], moduleDirectories: ['node_modules', 'src'], diff --git a/frontend/package.json b/frontend/package.json index 28666512fb..dad3b0589e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "@welldone-software/why-did-you-render": "^6.2.1", + "@xstate/react": "^3.0.0", "antd": "4.19.2", "axios": "^0.21.0", "babel-eslint": "^10.1.0", @@ -57,7 +58,8 @@ "i18next": "^21.6.12", "i18next-browser-languagedetector": "^6.1.3", "i18next-http-backend": "^1.3.2", - "jest": "26.6.0", + "jest": "^27.5.1", + "js-base64": "^3.7.2", "less": "^4.1.2", "less-loader": "^10.2.0", "lodash-es": "^4.17.21", @@ -68,6 +70,7 @@ "react-graph-vis": "^1.0.5", "react-grid-layout": "^1.2.5", "react-i18next": "^11.16.1", + "react-query": "^3.34.19", "react-redux": "^7.2.2", "react-router-dom": "^5.2.0", "react-use": "^17.3.2", @@ -84,7 +87,8 @@ "uuid": "^8.3.2", "web-vitals": "^0.2.4", "webpack": "^5.23.0", - "webpack-dev-server": "^4.3.1" + "webpack-dev-server": "^4.3.1", + "xstate": "^4.31.0" }, "browserslist": { "production": [ @@ -107,6 +111,7 @@ "@babel/preset-typescript": "^7.12.17", "@jest/globals": "^27.5.1", "@testing-library/cypress": "^8.0.0", + "@testing-library/react-hooks": "^7.0.2", "@types/color": "^3.0.3", "@types/compression-webpack-plugin": "^9.0.0", "@types/copy-webpack-plugin": "^8.0.1", @@ -155,6 +160,7 @@ "portfinder-sync": "^0.0.2", "prettier": "2.2.1", "react-hot-loader": "^4.13.0", + "ts-jest": "^27.1.4", "ts-node": "^10.2.1", "typescript-plugin-css-modules": "^3.4.0", "webpack-bundle-analyzer": "^4.5.0", diff --git a/frontend/public/locales/en-GB/channels.json b/frontend/public/locales/en-GB/channels.json new file mode 100644 index 0000000000..5e670cc536 --- /dev/null +++ b/frontend/public/locales/en-GB/channels.json @@ -0,0 +1,48 @@ +{ + "page_title_create": "New Notification Channels", + "page_title_edit": "Edit Notification Channels", + "button_save_channel": "Save", + "button_test_channel": "Test", + "button_return": "Back", + "field_channel_name": "Name", + "field_channel_type": "Type", + "field_webhook_url": "Webhook URL", + "field_slack_recipient": "Recipient", + "field_slack_title": "Title", + "field_slack_description": "Description", + "field_webhook_username": "User Name (optional)", + "field_webhook_password": "Password (optional)", + "field_pager_routing_key": "Routing Key", + "field_pager_description": "Description", + "field_pager_severity": "Severity", + "field_pager_details": "Additional Information", + "field_pager_component": "Component", + "field_pager_group": "Group", + "field_pager_class": "Class", + "field_pager_client": "Client", + "field_pager_client_url": "Client URL", + "placeholder_slack_description": "Description", + "placeholder_pager_description": "Description", + "help_pager_client": "Shows up as event source in Pagerduty", + "help_pager_client_url": "Shows up as event source link in Pagerduty", + "help_pager_class": "The class/type of the event", + "help_pager_details": "Specify a key-value format (must be a valid json)", + "help_pager_group": "A cluster or grouping of sources", + "help_pager_component": "The part or component of the affected system that is broke", + "help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'", + "help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.", + "help_webhook_password": "Specify a password or bearer token", + "help_pager_description": "Shows up as description in pagerduty", + "channel_creation_done": "Successfully created the channel", + "channel_creation_failed": "An unexpected error occurred while creating this channel", + "channel_edit_done": "Channels Edited Successfully", + "channel_edit_failed": "An unexpected error occurred while updating this channel", + "selected_channel_invalid": "Channel type selected is invalid", + "username_no_password": "A Password must be provided with user name", + "test_unsupported": "Sorry, this channel type does not support test yet", + "channel_test_done": "An alert has been sent to this channel", + "channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly", + "channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again", + "webhook_url_required": "Webhook URL is mandatory", + "slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)" +} \ No newline at end of file diff --git a/frontend/public/locales/en-GB/common.json b/frontend/public/locales/en-GB/common.json new file mode 100644 index 0000000000..f167aecffc --- /dev/null +++ b/frontend/public/locales/en-GB/common.json @@ -0,0 +1,10 @@ +{ + "something_went_wrong": "Something went wrong", + "already_logged_in": "Already Logged In", + "success": "Success", + "cancel": "Cancel", + "share": "Share", + "save": "Save", + "edit": "Edit", + "logged_in": "Logged In" +} diff --git a/frontend/public/locales/en-GB/dashboard.json b/frontend/public/locales/en-GB/dashboard.json new file mode 100644 index 0000000000..7f21149511 --- /dev/null +++ b/frontend/public/locales/en-GB/dashboard.json @@ -0,0 +1,16 @@ +{ + "create_dashboard": "Create Dashboard", + "import_json": "Import JSON", + "copy_to_clipboard": "Copy To ClipBoard", + "download_json": "Download JSON", + "view_json": "View JSON", + "export_dashboard": "Export this dashboard.", + "upload_json_file": "Upload JSON file", + "paste_json_below": "Paste JSON below", + "error_upload_json": "Invalid JSON", + "load_json": "Load JSON", + "import_dashboard_by_pasting": "Import dashboard by pasting JSON or importing JSON file", + "error_loading_json": "Error loading JSON file", + "empty_json_not_allowed": "Empty JSON is not allowed", + "new_dashboard_title": "Sample Title" +} diff --git a/frontend/public/locales/en-GB/errorDetails.json b/frontend/public/locales/en-GB/errorDetails.json new file mode 100644 index 0000000000..f29f4993c8 --- /dev/null +++ b/frontend/public/locales/en-GB/errorDetails.json @@ -0,0 +1,7 @@ +{ + "see_trace_graph": "See what happened before and after this error in a trace graph", + "see_error_in_trace_graph": "See the error in trace graph", + "stack_trace": "Stacktrace", + "older": "Older", + "newer": "Newer" +} diff --git a/frontend/public/locales/en-GB/organizationsettings.json b/frontend/public/locales/en-GB/organizationsettings.json new file mode 100644 index 0000000000..74797b447b --- /dev/null +++ b/frontend/public/locales/en-GB/organizationsettings.json @@ -0,0 +1,13 @@ +{ + "display_name": "Display Name", + "signoz": "SigNoz", + "email_address": "Email address", + "name_optional": "Name (optional)", + "role": "Role", + "email_placeholder": "john@signoz.io", + "name_placeholder": "John", + "add_another_team_member": "Add another team member", + "invite_team_members": "Invite team members", + "invite_members": "Invite Members", + "pending_invites": "Pending Invites" +} diff --git a/frontend/public/locales/en-GB/routes.json b/frontend/public/locales/en-GB/routes.json new file mode 100644 index 0000000000..f3df8f6c9d --- /dev/null +++ b/frontend/public/locales/en-GB/routes.json @@ -0,0 +1,6 @@ +{ + "general": "General", + "alert_channels": "Alert Channels", + "organization_settings": "Organization Settings", + "my_settings": "My Settings" +} diff --git a/frontend/public/locales/en-GB/settings.json b/frontend/public/locales/en-GB/settings.json new file mode 100644 index 0000000000..b5041b3136 --- /dev/null +++ b/frontend/public/locales/en-GB/settings.json @@ -0,0 +1,5 @@ +{ + "current_password": "Current Password", + "new_password": "New Password", + "change_password": "Change Password" +} diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json new file mode 100644 index 0000000000..7ad8e9a716 --- /dev/null +++ b/frontend/public/locales/en-GB/translation.json @@ -0,0 +1,28 @@ +{ + "monitor_signup": "Monitor your applications. Find what is causing issues.", + "version": "Version", + "latest_version": "Latest version", + "current_version": "Current version", + "release_notes": "Release Notes", + "read_how_to_upgrade": "Read instructions on how to upgrade", + "latest_version_signoz": "You are running the latest version of SigNoz.", + "stale_version": "You are on an older version and may be loosing on the latest features we have shipped. We recommend to upgrade to the latest version", + "oops_something_went_wrong_version": "Oops.. facing issues with fetching updated version information", + "n_a": "N/A", + "routes": { + "general": "General", + "alert_channels": "Alert Channels", + "all_errors": "All Exceptions" + }, + "settings": { + "total_retention_period": "Total Retention Period", + "move_to_s3": "Move to S3\n(should be lower than total retention period)", + "retention_success_message": "Congrats. The retention periods for {{name}} has been updated successfully.", + "retention_error_message": "There was an issue in changing the retention period for {{name}}. Please try again or reach out to support@signoz.io", + "retention_failed_message": "There was an issue in changing the retention period. Please try again or reach out to support@signoz.io", + "retention_comparison_error": "Total retention period for {{name}} can’t be lower or equal to the period after which data is moved to s3.", + "retention_null_value_error": "Retention Period for {{name}} is not set yet. Please set by choosing below", + "retention_confirmation": "Are you sure you want to change the retention period?", + "retention_confirmation_description": "This will change the amount of storage needed for saving metrics & traces." + } +} diff --git a/frontend/public/locales/en/channels.json b/frontend/public/locales/en/channels.json new file mode 100644 index 0000000000..5e670cc536 --- /dev/null +++ b/frontend/public/locales/en/channels.json @@ -0,0 +1,48 @@ +{ + "page_title_create": "New Notification Channels", + "page_title_edit": "Edit Notification Channels", + "button_save_channel": "Save", + "button_test_channel": "Test", + "button_return": "Back", + "field_channel_name": "Name", + "field_channel_type": "Type", + "field_webhook_url": "Webhook URL", + "field_slack_recipient": "Recipient", + "field_slack_title": "Title", + "field_slack_description": "Description", + "field_webhook_username": "User Name (optional)", + "field_webhook_password": "Password (optional)", + "field_pager_routing_key": "Routing Key", + "field_pager_description": "Description", + "field_pager_severity": "Severity", + "field_pager_details": "Additional Information", + "field_pager_component": "Component", + "field_pager_group": "Group", + "field_pager_class": "Class", + "field_pager_client": "Client", + "field_pager_client_url": "Client URL", + "placeholder_slack_description": "Description", + "placeholder_pager_description": "Description", + "help_pager_client": "Shows up as event source in Pagerduty", + "help_pager_client_url": "Shows up as event source link in Pagerduty", + "help_pager_class": "The class/type of the event", + "help_pager_details": "Specify a key-value format (must be a valid json)", + "help_pager_group": "A cluster or grouping of sources", + "help_pager_component": "The part or component of the affected system that is broke", + "help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'", + "help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.", + "help_webhook_password": "Specify a password or bearer token", + "help_pager_description": "Shows up as description in pagerduty", + "channel_creation_done": "Successfully created the channel", + "channel_creation_failed": "An unexpected error occurred while creating this channel", + "channel_edit_done": "Channels Edited Successfully", + "channel_edit_failed": "An unexpected error occurred while updating this channel", + "selected_channel_invalid": "Channel type selected is invalid", + "username_no_password": "A Password must be provided with user name", + "test_unsupported": "Sorry, this channel type does not support test yet", + "channel_test_done": "An alert has been sent to this channel", + "channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly", + "channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again", + "webhook_url_required": "Webhook URL is mandatory", + "slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)" +} \ No newline at end of file diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json new file mode 100644 index 0000000000..f167aecffc --- /dev/null +++ b/frontend/public/locales/en/common.json @@ -0,0 +1,10 @@ +{ + "something_went_wrong": "Something went wrong", + "already_logged_in": "Already Logged In", + "success": "Success", + "cancel": "Cancel", + "share": "Share", + "save": "Save", + "edit": "Edit", + "logged_in": "Logged In" +} diff --git a/frontend/public/locales/en/dashboard.json b/frontend/public/locales/en/dashboard.json new file mode 100644 index 0000000000..7f21149511 --- /dev/null +++ b/frontend/public/locales/en/dashboard.json @@ -0,0 +1,16 @@ +{ + "create_dashboard": "Create Dashboard", + "import_json": "Import JSON", + "copy_to_clipboard": "Copy To ClipBoard", + "download_json": "Download JSON", + "view_json": "View JSON", + "export_dashboard": "Export this dashboard.", + "upload_json_file": "Upload JSON file", + "paste_json_below": "Paste JSON below", + "error_upload_json": "Invalid JSON", + "load_json": "Load JSON", + "import_dashboard_by_pasting": "Import dashboard by pasting JSON or importing JSON file", + "error_loading_json": "Error loading JSON file", + "empty_json_not_allowed": "Empty JSON is not allowed", + "new_dashboard_title": "Sample Title" +} diff --git a/frontend/public/locales/en/errorDetails.json b/frontend/public/locales/en/errorDetails.json new file mode 100644 index 0000000000..f29f4993c8 --- /dev/null +++ b/frontend/public/locales/en/errorDetails.json @@ -0,0 +1,7 @@ +{ + "see_trace_graph": "See what happened before and after this error in a trace graph", + "see_error_in_trace_graph": "See the error in trace graph", + "stack_trace": "Stacktrace", + "older": "Older", + "newer": "Newer" +} diff --git a/frontend/public/locales/en/organizationsettings.json b/frontend/public/locales/en/organizationsettings.json new file mode 100644 index 0000000000..74797b447b --- /dev/null +++ b/frontend/public/locales/en/organizationsettings.json @@ -0,0 +1,13 @@ +{ + "display_name": "Display Name", + "signoz": "SigNoz", + "email_address": "Email address", + "name_optional": "Name (optional)", + "role": "Role", + "email_placeholder": "john@signoz.io", + "name_placeholder": "John", + "add_another_team_member": "Add another team member", + "invite_team_members": "Invite team members", + "invite_members": "Invite Members", + "pending_invites": "Pending Invites" +} diff --git a/frontend/public/locales/en/routes.json b/frontend/public/locales/en/routes.json new file mode 100644 index 0000000000..f3df8f6c9d --- /dev/null +++ b/frontend/public/locales/en/routes.json @@ -0,0 +1,6 @@ +{ + "general": "General", + "alert_channels": "Alert Channels", + "organization_settings": "Organization Settings", + "my_settings": "My Settings" +} diff --git a/frontend/public/locales/en/settings.json b/frontend/public/locales/en/settings.json new file mode 100644 index 0000000000..94a4f71407 --- /dev/null +++ b/frontend/public/locales/en/settings.json @@ -0,0 +1,6 @@ +{ + "current_password": "Current Password", + "new_password": "New Password", + "change_password": "Change Password", + "input_password": "input password" +} diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index e63d6d083e..7ad8e9a716 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -1,3 +1,28 @@ { - "monitor_signup": "Monitor your applications. Find what is causing issues." + "monitor_signup": "Monitor your applications. Find what is causing issues.", + "version": "Version", + "latest_version": "Latest version", + "current_version": "Current version", + "release_notes": "Release Notes", + "read_how_to_upgrade": "Read instructions on how to upgrade", + "latest_version_signoz": "You are running the latest version of SigNoz.", + "stale_version": "You are on an older version and may be loosing on the latest features we have shipped. We recommend to upgrade to the latest version", + "oops_something_went_wrong_version": "Oops.. facing issues with fetching updated version information", + "n_a": "N/A", + "routes": { + "general": "General", + "alert_channels": "Alert Channels", + "all_errors": "All Exceptions" + }, + "settings": { + "total_retention_period": "Total Retention Period", + "move_to_s3": "Move to S3\n(should be lower than total retention period)", + "retention_success_message": "Congrats. The retention periods for {{name}} has been updated successfully.", + "retention_error_message": "There was an issue in changing the retention period for {{name}}. Please try again or reach out to support@signoz.io", + "retention_failed_message": "There was an issue in changing the retention period. Please try again or reach out to support@signoz.io", + "retention_comparison_error": "Total retention period for {{name}} can’t be lower or equal to the period after which data is moved to s3.", + "retention_null_value_error": "Retention Period for {{name}} is not set yet. Please set by choosing below", + "retention_confirmation": "Are you sure you want to change the retention period?", + "retention_confirmation_description": "This will change the amount of storage needed for saving metrics & traces." + } } diff --git a/frontend/public/signoz.svg b/frontend/public/signoz.svg index 53a3a23754..cdfe945052 100644 --- a/frontend/public/signoz.svg +++ b/frontend/public/signoz.svg @@ -1,5 +1,4 @@ - - - - - \ No newline at end of file + + + + diff --git a/frontend/src/AppRoutes/Private.tsx b/frontend/src/AppRoutes/Private.tsx new file mode 100644 index 0000000000..3e97adec58 --- /dev/null +++ b/frontend/src/AppRoutes/Private.tsx @@ -0,0 +1,164 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { notification } from 'antd'; +import getLocalStorageApi from 'api/browser/localstorage/get'; +import loginApi from 'api/user/login'; +import Spinner from 'components/Spinner'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import React, { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { matchPath, Redirect, useLocation } from 'react-router-dom'; +import { Dispatch } from 'redux'; +import { AppState } from 'store/reducers'; +import { getInitialUserTokenRefreshToken } from 'store/utils'; +import AppActions from 'types/actions'; +import { UPDATE_USER_IS_FETCH } from 'types/actions/app'; +import AppReducer from 'types/reducer/app'; +import { routePermission } from 'utils/permission'; + +import routes from './routes'; +import afterLogin from './utils'; + +function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { + const { pathname } = useLocation(); + + const mapRoutes = useMemo( + () => + new Map( + routes.map((e) => { + const currentPath = matchPath(pathname, { + path: e.path, + }); + return [currentPath === null ? null : 'current', e]; + }), + ), + [pathname], + ); + const { + isUserFetching, + isUserFetchingError, + isLoggedIn: isLoggedInState, + } = useSelector((state) => state.app); + + const { t } = useTranslation(['common']); + + const dispatch = useDispatch>(); + + const currentRoute = mapRoutes.get('current'); + + const navigateToLoginIfNotLoggedIn = (isLoggedIn = isLoggedInState): void => { + dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + + if (!isLoggedIn) { + history.push(ROUTES.LOGIN); + } + }; + + // eslint-disable-next-line sonarjs/cognitive-complexity + useEffect(() => { + (async (): Promise => { + try { + const isLocalStorageLoggedIn = + getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true'; + if (currentRoute) { + const { isPrivate, key } = currentRoute; + + if (isPrivate) { + const localStorageUserAuthToken = getInitialUserTokenRefreshToken(); + + if ( + localStorageUserAuthToken && + localStorageUserAuthToken.refreshJwt && + isUserFetching + ) { + // localstorage token is present + const { refreshJwt } = localStorageUserAuthToken; + + // renew web access token + const response = await loginApi({ + refreshToken: refreshJwt, + }); + + if (response.statusCode === 200) { + const route = routePermission[key]; + + // get all resource and put it over redux + const userResponse = await afterLogin( + response.payload.userId, + response.payload.accessJwt, + response.payload.refreshJwt, + ); + + if ( + userResponse && + route.find((e) => e === userResponse.payload.role) === undefined + ) { + history.push(ROUTES.UN_AUTHORIZED); + } + } else { + history.push(ROUTES.SOMETHING_WENT_WRONG); + + notification.error({ + message: response.error || t('something_went_wrong'), + }); + } + } else { + // user does have localstorage values + navigateToLoginIfNotLoggedIn(isLocalStorageLoggedIn); + } + } else { + // no need to fetch the user and make user fetching false + + if (getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true') { + history.push(ROUTES.APPLICATION); + } + dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + } + } else if (pathname === ROUTES.HOME_PAGE) { + // routing to application page over root page + if (isLoggedInState) { + history.push(ROUTES.APPLICATION); + } else { + navigateToLoginIfNotLoggedIn(); + } + } else { + // not found + navigateToLoginIfNotLoggedIn(isLocalStorageLoggedIn); + } + } catch (error) { + // something went wrong + history.push(ROUTES.SOMETHING_WENT_WRONG); + } + })(); + }, [dispatch, isLoggedInState, currentRoute]); + + if (isUserFetchingError) { + return ; + } + + if (isUserFetching) { + return ; + } + + // NOTE: disabling this rule as there is no need to have div + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{children}; +} + +interface PrivateRouteProps { + children: React.ReactChild; +} + +export default PrivateRoute; diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index 018708c1d0..968ac0b2ff 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -1,42 +1,36 @@ import NotFound from 'components/NotFound'; import Spinner from 'components/Spinner'; -import ROUTES from 'constants/routes'; import AppLayout from 'container/AppLayout'; import history from 'lib/history'; import React, { Suspense } from 'react'; -import { useSelector } from 'react-redux'; -import { Redirect, Route, Router, Switch } from 'react-router-dom'; -import { AppState } from 'store/reducers'; -import AppReducer from 'types/reducer/app'; +import { Route, Router, Switch } from 'react-router-dom'; +import PrivateRoute from './Private'; import routes from './routes'; function App(): JSX.Element { - const { isLoggedIn } = useSelector((state) => state.app); - return ( - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} - - isLoggedIn ? ( - - ) : ( - - ) - } - /> - - - - + + + }> + + {routes.map(({ path, component, exact }) => { + return ( + + ); + })} + + + + + + ); } diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index eb7430eb3c..fd59d0bc7b 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -83,5 +83,44 @@ export const EditAlertChannelsAlerts = Loadable( ); export const AllAlertChannels = Loadable( - () => import(/* webpackChunkName: "All Channels" */ 'pages/AllAlertChannels'), + () => import(/* webpackChunkName: "All Channels" */ 'pages/Settings'), +); + +export const AllErrors = Loadable( + /* webpackChunkName: "All Exceptions" */ () => import('pages/AllErrors'), +); + +export const ErrorDetails = Loadable( + () => import(/* webpackChunkName: "Error Details" */ 'pages/ErrorDetails'), +); + +export const StatusPage = Loadable( + () => import(/* webpackChunkName: "All Status" */ 'pages/Status'), +); + +export const OrganizationSettings = Loadable( + () => import(/* webpackChunkName: "All Settings" */ 'pages/Settings'), +); + +export const MySettings = Loadable( + () => import(/* webpackChunkName: "All MySettings" */ 'pages/MySettings'), +); + +export const Login = Loadable( + () => import(/* webpackChunkName: "Login" */ 'pages/Login'), +); + +export const UnAuthorized = Loadable( + () => import(/* webpackChunkName: "UnAuthorized" */ 'pages/UnAuthorized'), +); + +export const PasswordReset = Loadable( + () => import(/* webpackChunkName: "ResetPassword" */ 'pages/ResetPassword'), +); + +export const SomethingWentWrong = Loadable( + () => + import( + /* webpackChunkName: "SomethingWentWrong" */ 'pages/SomethingWentWrong' + ), ); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 474f71f510..5958d93b11 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -4,21 +4,30 @@ import { RouteProps } from 'react-router-dom'; import { AllAlertChannels, + AllErrors, CreateAlertChannelAlerts, CreateNewAlerts, DashboardPage, EditAlertChannelsAlerts, EditRulesPage, + ErrorDetails, InstrumentationPage, ListAllALertsPage, + Login, + MySettings, NewDashboardPage, + OrganizationSettings, + PasswordReset, ServiceMapPage, ServiceMetricsPage, ServicesTablePage, SettingsPage, SignupPage, + SomethingWentWrong, + StatusPage, TraceDetail, TraceFilter, + UnAuthorized, UsageExplorerPage, } from './pageComponents'; @@ -27,99 +36,199 @@ const routes: AppRoutes[] = [ component: SignupPage, path: ROUTES.SIGN_UP, exact: true, + isPrivate: false, + key: 'SIGN_UP', }, { component: ServicesTablePage, path: ROUTES.APPLICATION, exact: true, + isPrivate: true, + key: 'APPLICATION', }, { path: ROUTES.SERVICE_METRICS, exact: true, component: ServiceMetricsPage, + isPrivate: true, + key: 'SERVICE_METRICS', }, { path: ROUTES.SERVICE_MAP, component: ServiceMapPage, + isPrivate: true, exact: true, + key: 'SERVICE_MAP', }, { path: ROUTES.TRACE_DETAIL, exact: true, component: TraceDetail, + isPrivate: true, + key: 'TRACE_DETAIL', }, { path: ROUTES.SETTINGS, exact: true, component: SettingsPage, + isPrivate: true, + key: 'SETTINGS', }, { path: ROUTES.USAGE_EXPLORER, exact: true, component: UsageExplorerPage, + isPrivate: true, + key: 'USAGE_EXPLORER', }, { path: ROUTES.INSTRUMENTATION, exact: true, component: InstrumentationPage, + isPrivate: true, + key: 'INSTRUMENTATION', }, { path: ROUTES.ALL_DASHBOARD, exact: true, component: DashboardPage, + isPrivate: true, + key: 'ALL_DASHBOARD', }, { path: ROUTES.DASHBOARD, exact: true, component: NewDashboardPage, + isPrivate: true, + key: 'DASHBOARD', }, { path: ROUTES.DASHBOARD_WIDGET, exact: true, component: DashboardWidget, + isPrivate: true, + key: 'DASHBOARD_WIDGET', }, { path: ROUTES.EDIT_ALERTS, exact: true, component: EditRulesPage, + isPrivate: true, + key: 'EDIT_ALERTS', }, { path: ROUTES.LIST_ALL_ALERT, exact: true, component: ListAllALertsPage, + isPrivate: true, + key: 'LIST_ALL_ALERT', }, { path: ROUTES.ALERTS_NEW, exact: true, component: CreateNewAlerts, + isPrivate: true, + key: 'ALERTS_NEW', }, { path: ROUTES.TRACE, exact: true, component: TraceFilter, + isPrivate: true, + key: 'TRACE', }, { path: ROUTES.CHANNELS_NEW, exact: true, component: CreateAlertChannelAlerts, + isPrivate: true, + key: 'CHANNELS_NEW', }, { path: ROUTES.CHANNELS_EDIT, exact: true, component: EditAlertChannelsAlerts, + isPrivate: true, + key: 'CHANNELS_EDIT', }, { path: ROUTES.ALL_CHANNELS, exact: true, component: AllAlertChannels, + isPrivate: true, + key: 'ALL_CHANNELS', + }, + { + path: ROUTES.ALL_ERROR, + exact: true, + isPrivate: true, + component: AllErrors, + key: 'ALL_ERROR', + }, + { + path: ROUTES.ERROR_DETAIL, + exact: true, + component: ErrorDetails, + isPrivate: true, + key: 'ERROR_DETAIL', + }, + { + path: ROUTES.VERSION, + exact: true, + component: StatusPage, + isPrivate: true, + key: 'VERSION', + }, + { + path: ROUTES.ORG_SETTINGS, + exact: true, + component: OrganizationSettings, + isPrivate: true, + key: 'ORG_SETTINGS', + }, + { + path: ROUTES.MY_SETTINGS, + exact: true, + component: MySettings, + isPrivate: true, + key: 'MY_SETTINGS', + }, + { + path: ROUTES.LOGIN, + exact: true, + component: Login, + isPrivate: false, + key: 'LOGIN', + }, + { + path: ROUTES.UN_AUTHORIZED, + exact: true, + component: UnAuthorized, + key: 'UN_AUTHORIZED', + isPrivate: true, + }, + { + path: ROUTES.PASSWORD_RESET, + exact: true, + component: PasswordReset, + key: 'PASSWORD_RESET', + isPrivate: false, + }, + { + path: ROUTES.SOMETHING_WENT_WRONG, + exact: true, + component: SomethingWentWrong, + key: 'SOMETHING_WENT_WRONG', + isPrivate: false, }, ]; -interface AppRoutes { +export interface AppRoutes { component: RouteProps['component']; path: RouteProps['path']; exact: RouteProps['exact']; - isPrivate?: boolean; + isPrivate: boolean; + key: keyof typeof ROUTES; } export default routes; diff --git a/frontend/src/AppRoutes/utils.ts b/frontend/src/AppRoutes/utils.ts new file mode 100644 index 0000000000..698629d325 --- /dev/null +++ b/frontend/src/AppRoutes/utils.ts @@ -0,0 +1,91 @@ +import getLocalStorageApi from 'api/browser/localstorage/get'; +import setLocalStorageApi from 'api/browser/localstorage/set'; +import getUserApi from 'api/user/getUser'; +import { Logout } from 'api/utils'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import store from 'store'; +import AppActions from 'types/actions'; +import { + LOGGED_IN, + UPDATE_USER, + UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN, + UPDATE_USER_IS_FETCH, +} from 'types/actions/app'; +import { SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/user/getUser'; + +const afterLogin = async ( + userId: string, + authToken: string, + refreshToken: string, +): Promise | undefined> => { + setLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN, authToken); + setLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN, refreshToken); + + store.dispatch({ + type: UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN, + payload: { + accessJwt: authToken, + refreshJwt: refreshToken, + }, + }); + + const [getUserResponse] = await Promise.all([ + getUserApi({ + userId, + token: authToken, + }), + ]); + + if (getUserResponse.statusCode === 200 && getUserResponse.payload) { + store.dispatch({ + type: LOGGED_IN, + payload: { + isLoggedIn: true, + }, + }); + + const { payload } = getUserResponse; + + store.dispatch({ + type: UPDATE_USER, + payload: { + ROLE: payload.role, + email: payload.email, + name: payload.name, + orgName: payload.organization, + profilePictureURL: payload.profilePictureURL, + userId: payload.id, + orgId: payload.orgId, + }, + }); + + const isLoggedInLocalStorage = getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN); + + if (isLoggedInLocalStorage === null) { + setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true'); + } + + store.dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + + return getUserResponse; + } + + store.dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + + Logout(); + + return undefined; +}; + +export default afterLogin; diff --git a/frontend/src/ReactI18/index.tsx b/frontend/src/ReactI18/index.tsx index f452ed227b..3b37751caf 100644 --- a/frontend/src/ReactI18/index.tsx +++ b/frontend/src/ReactI18/index.tsx @@ -12,7 +12,7 @@ i18n .use(initReactI18next) // init i18next .init({ - debug: true, + debug: false, fallbackLng: 'en', interpolation: { escapeValue: false, // not needed for react as it escapes by default diff --git a/frontend/src/api/ErrorResponseHandler.ts b/frontend/src/api/ErrorResponseHandler.ts index 9356b7ee77..060b93493f 100644 --- a/frontend/src/api/ErrorResponseHandler.ts +++ b/frontend/src/api/ErrorResponseHandler.ts @@ -21,10 +21,15 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse { }; } + const { errors, error } = data; + + const errorMessage = + Array.isArray(errors) && errors.length >= 1 ? errors[0].msg : error; + return { statusCode, payload: null, - error: data.error, + error: errorMessage, message: null, }; } diff --git a/frontend/src/api/apiV1.ts b/frontend/src/api/apiV1.ts index 22054bf229..5145443b2a 100644 --- a/frontend/src/api/apiV1.ts +++ b/frontend/src/api/apiV1.ts @@ -1,4 +1,6 @@ const apiV1 = '/api/v1/'; -export const apiV2 = '/api/alertmanager'; + +export const apiV2 = '/api/v2/'; +export const apiAlertManager = '/api/alertmanager'; export default apiV1; diff --git a/frontend/src/api/channels/createPager.ts b/frontend/src/api/channels/createPager.ts new file mode 100644 index 0000000000..2747768cf1 --- /dev/null +++ b/frontend/src/api/channels/createPager.ts @@ -0,0 +1,42 @@ +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/createPager'; + +const create = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/channels', { + name: props.name, + pagerduty_configs: [ + { + send_resolved: true, + routing_key: props.routing_key, + client: props.client, + client_url: props.client_url, + description: props.description, + severity: props.severity, + class: props.class, + component: props.component, + group: props.group, + details: { + ...props.detailsArray, + }, + }, + ], + }); + + 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/editPager.ts b/frontend/src/api/channels/editPager.ts new file mode 100644 index 0000000000..a31d73dcdb --- /dev/null +++ b/frontend/src/api/channels/editPager.ts @@ -0,0 +1,42 @@ +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/editPager'; + +const editPager = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/channels/${props.id}`, { + name: props.name, + pagerduty_configs: [ + { + send_resolved: true, + routing_key: props.routing_key, + client: props.client, + client_url: props.client_url, + description: props.description, + severity: props.severity, + class: props.class, + component: props.component, + group: props.group, + details: { + ...props.detailsArray, + }, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default editPager; diff --git a/frontend/src/api/channels/testPager.ts b/frontend/src/api/channels/testPager.ts new file mode 100644 index 0000000000..717404649a --- /dev/null +++ b/frontend/src/api/channels/testPager.ts @@ -0,0 +1,42 @@ +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/createPager'; + +const testPager = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/testChannel', { + name: props.name, + pagerduty_configs: [ + { + send_resolved: true, + routing_key: props.routing_key, + client: props.client, + client_url: props.client_url, + description: props.description, + severity: props.severity, + class: props.class, + component: props.component, + group: props.group, + details: { + ...props.detailsArray, + }, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default testPager; diff --git a/frontend/src/api/channels/testSlack.ts b/frontend/src/api/channels/testSlack.ts new file mode 100644 index 0000000000..a2b4b1f40a --- /dev/null +++ b/frontend/src/api/channels/testSlack.ts @@ -0,0 +1,35 @@ +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/createSlack'; + +const testSlack = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/testChannel', { + name: props.name, + slack_configs: [ + { + send_resolved: true, + api_url: props.api_url, + channel: props.channel, + title: props.title, + text: props.text, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default testSlack; diff --git a/frontend/src/api/channels/testWebhook.ts b/frontend/src/api/channels/testWebhook.ts new file mode 100644 index 0000000000..4b915e9a3a --- /dev/null +++ b/frontend/src/api/channels/testWebhook.ts @@ -0,0 +1,51 @@ +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/createWebhook'; + +const testWebhook = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + let httpConfig = {}; + + if (props.username !== '' && props.password !== '') { + httpConfig = { + basic_auth: { + username: props.username, + password: props.password, + }, + }; + } else if (props.username === '' && props.password !== '') { + httpConfig = { + authorization: { + type: 'bearer', + credentials: props.password, + }, + }; + } + + const response = await axios.post('/testChannel', { + name: props.name, + webhook_configs: [ + { + send_resolved: true, + url: props.api_url, + http_config: httpConfig, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default testWebhook; diff --git a/frontend/src/api/dashboard/update.ts b/frontend/src/api/dashboard/update.ts index b22689c0ed..37341524f8 100644 --- a/frontend/src/api/dashboard/update.ts +++ b/frontend/src/api/dashboard/update.ts @@ -9,7 +9,7 @@ const update = async ( ): Promise | ErrorResponse> => { try { const response = await axios.put(`/dashboards/${props.uuid}`, { - ...props, + ...props.data, }); return { diff --git a/frontend/src/api/disks/getDisks.ts b/frontend/src/api/disks/getDisks.ts new file mode 100644 index 0000000000..9dced1b0fd --- /dev/null +++ b/frontend/src/api/disks/getDisks.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/disks/getDisks'; + +const getDisks = async (): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.get(`/disks`); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getDisks; diff --git a/frontend/src/api/errors/getAll.ts b/frontend/src/api/errors/getAll.ts new file mode 100644 index 0000000000..dcd8aa8e73 --- /dev/null +++ b/frontend/src/api/errors/getAll.ts @@ -0,0 +1,30 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import createQueryParams from 'lib/createQueryParams'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/errors/getAll'; + +const getAll = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get( + `/errors?${createQueryParams({ + start: props.start.toString(), + end: props.end.toString(), + })}`, + ); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getAll; diff --git a/frontend/src/api/errors/getByErrorTypeAndService.ts b/frontend/src/api/errors/getByErrorTypeAndService.ts new file mode 100644 index 0000000000..6a2c6964d9 --- /dev/null +++ b/frontend/src/api/errors/getByErrorTypeAndService.ts @@ -0,0 +1,32 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import createQueryParams from 'lib/createQueryParams'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/errors/getByErrorTypeAndService'; + +const getByErrorType = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get( + `/errorWithType?${createQueryParams({ + start: props.start.toString(), + end: props.end.toString(), + serviceName: props.serviceName, + errorType: props.errorType, + })}`, + ); + + return { + statusCode: 200, + error: null, + message: response.data.message, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getByErrorType; diff --git a/frontend/src/api/errors/getById.ts b/frontend/src/api/errors/getById.ts new file mode 100644 index 0000000000..3ab7c4aa60 --- /dev/null +++ b/frontend/src/api/errors/getById.ts @@ -0,0 +1,31 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import createQueryParams from 'lib/createQueryParams'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/errors/getById'; + +const getById = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get( + `/errorWithId?${createQueryParams({ + start: props.start.toString(), + end: props.end.toString(), + errorId: props.errorId, + })}`, + ); + + return { + statusCode: 200, + error: null, + message: response.data.message, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getById; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index feaac180e4..82f4bdc010 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,14 +1,113 @@ -import axios from 'axios'; +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable no-param-reassign */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import getLocalStorageApi from 'api/browser/localstorage/get'; +import loginApi from 'api/user/login'; +import afterLogin from 'AppRoutes/utils'; +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; import { ENVIRONMENT } from 'constants/env'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import store from 'store'; -import apiV1, { apiV2 } from './apiV1'; +import apiV1, { apiAlertManager, apiV2 } from './apiV1'; +import { Logout } from './utils'; -export default axios.create({ +const interceptorsResponse = ( + value: AxiosResponse, +): Promise> => Promise.resolve(value); + +const interceptorsRequestResponse = ( + value: AxiosRequestConfig, +): AxiosRequestConfig => { + const token = + store.getState().app.user?.accessJwt || + getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || + ''; + + value.headers.Authorization = token ? `Bearer ${token}` : ''; + + return value; +}; + +const interceptorRejected = async ( + value: AxiosResponse, +): Promise> => { + try { + if (axios.isAxiosError(value) && value.response) { + const { response } = value; + // reject the refresh token error + if (response.status === 401 && response.config.url !== '/login') { + const response = await loginApi({ + refreshToken: store.getState().app.user?.refreshJwt, + }); + + if (response.statusCode === 200) { + const user = await afterLogin( + response.payload.userId, + response.payload.accessJwt, + response.payload.refreshJwt, + ); + + if (user) { + const reResponse = await axios( + `${value.config.baseURL}${value.config.url?.substring(1)}`, + { + method: value.config.method, + headers: { + ...value.config.headers, + Authorization: `Bearer ${response.payload.accessJwt}`, + }, + data: { + ...JSON.parse(value.config.data || '{}'), + }, + }, + ); + + if (reResponse.status === 200) { + return await Promise.resolve(reResponse); + } + Logout(); + + return await Promise.reject(reResponse); + } + Logout(); + + return await Promise.reject(value); + } + Logout(); + } + + // when refresh token is expired + if (response.status === 401 && response.config.url === '/login') { + Logout(); + } + } + return await Promise.reject(value); + } catch (error) { + return await Promise.reject(error); + } +}; + +const instance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${apiV1}`, }); +instance.interceptors.response.use(interceptorsResponse, interceptorRejected); +instance.interceptors.request.use(interceptorsRequestResponse); + export const AxiosAlertManagerInstance = axios.create({ + baseURL: `${ENVIRONMENT.baseURL}${apiAlertManager}`, +}); + +export const ApiV2Instance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${apiV2}`, }); +AxiosAlertManagerInstance.interceptors.response.use( + interceptorsResponse, + interceptorRejected, +); +AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse); + export { apiV1 }; +export default instance; diff --git a/frontend/src/api/metrics/getResourceAttributes.ts b/frontend/src/api/metrics/getResourceAttributes.ts new file mode 100644 index 0000000000..5be45af6f1 --- /dev/null +++ b/frontend/src/api/metrics/getResourceAttributes.ts @@ -0,0 +1,47 @@ +import { ApiV2Instance as axios } from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + TagKeysPayloadProps, + TagValueProps, + TagValuesPayloadProps, +} from 'types/api/metrics/getResourceAttributes'; + +export const getResourceAttributesTagKeys = async (): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.get( + '/metrics/autocomplete/tagKey?metricName=signoz_calls_total&match=resource_', + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export const getResourceAttributesTagValues = async ( + props: TagValueProps, +): Promise | ErrorResponse> => { + try { + const response = await axios.get( + `/metrics/autocomplete/tagValue?metricName=signoz_calls_total&tagKey=${props}`, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; diff --git a/frontend/src/api/metrics/getService.ts b/frontend/src/api/metrics/getService.ts index 29909b2905..d3bb27c741 100644 --- a/frontend/src/api/metrics/getService.ts +++ b/frontend/src/api/metrics/getService.ts @@ -8,9 +8,11 @@ const getService = async ( props: Props, ): Promise | ErrorResponse> => { try { - const response = await axios.get( - `/services?&start=${props.start}&end=${props.end}`, - ); + const response = await axios.post(`/services`, { + start: `${props.start}`, + end: `${props.end}`, + tags: props.selectedTags, + }); return { statusCode: 200, diff --git a/frontend/src/api/metrics/getServiceOverview.ts b/frontend/src/api/metrics/getServiceOverview.ts index 3ceb794e0e..ea0ddb4062 100644 --- a/frontend/src/api/metrics/getServiceOverview.ts +++ b/frontend/src/api/metrics/getServiceOverview.ts @@ -8,9 +8,13 @@ const getServiceOverview = async ( props: Props, ): Promise | ErrorResponse> => { try { - const response = await axios.get( - `/service/overview?&start=${props.start}&end=${props.end}&service=${props.service}&step=${props.step}`, - ); + const response = await axios.post(`/service/overview`, { + start: `${props.start}`, + end: `${props.end}`, + service: props.service, + step: props.step, + tags: props.selectedTags, + }); return { statusCode: 200, diff --git a/frontend/src/api/metrics/getTopEndPoints.ts b/frontend/src/api/metrics/getTopEndPoints.ts index a2973d1866..db78aae9e3 100644 --- a/frontend/src/api/metrics/getTopEndPoints.ts +++ b/frontend/src/api/metrics/getTopEndPoints.ts @@ -8,9 +8,12 @@ const getTopEndPoints = async ( props: Props, ): Promise | ErrorResponse> => { try { - const response = await axios.get( - `/service/top_endpoints?&start=${props.start}&end=${props.end}&service=${props.service}`, - ); + const response = await axios.post(`/service/top_endpoints`, { + start: `${props.start}`, + end: `${props.end}`, + service: props.service, + tags: props.selectedTags, + }); return { statusCode: 200, diff --git a/frontend/src/api/settings/setRetention.ts b/frontend/src/api/settings/setRetention.ts index dcf0a0f2c3..62aa3559e5 100644 --- a/frontend/src/api/settings/setRetention.ts +++ b/frontend/src/api/settings/setRetention.ts @@ -9,7 +9,11 @@ const setRetention = async ( ): Promise | ErrorResponse> => { try { const response = await axios.post( - `/settings/ttl?duration=${props.duration}&type=${props.type}`, + `/settings/ttl?duration=${props.totalDuration}&type=${props.type}${ + props.coldStorage + ? `&coldStorage=${props.coldStorage};toColdDuration=${props.toColdDuration}` + : '' + }`, ); return { diff --git a/frontend/src/api/trace/getSpansAggregate.ts b/frontend/src/api/trace/getSpansAggregate.ts index ad18cb2143..077577febf 100644 --- a/frontend/src/api/trace/getSpansAggregate.ts +++ b/frontend/src/api/trace/getSpansAggregate.ts @@ -16,6 +16,7 @@ const getSpanAggregate = async ( limit: props.limit, offset: props.offset, order: props.order, + orderParam: props.orderParam, }; const exclude: TraceFilterEnum[] = []; diff --git a/frontend/src/api/trace/getTagFilter.ts b/frontend/src/api/trace/getTagFilter.ts index 746765c4bf..e4cadb710c 100644 --- a/frontend/src/api/trace/getTagFilter.ts +++ b/frontend/src/api/trace/getTagFilter.ts @@ -4,6 +4,7 @@ import { AxiosError } from 'axios'; import { omitBy } from 'lodash-es'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/trace/getTagFilters'; +import { TraceFilterEnum } from 'types/reducer/trace'; const getTagFilters = async ( props: Props, @@ -12,6 +13,14 @@ const getTagFilters = async ( const duration = omitBy(props.other, (_, key) => !key.startsWith('duration')) || []; + const exclude: TraceFilterEnum[] = []; + + props.isFilterExclude.forEach((value, key) => { + if (value) { + exclude.push(key); + } + }); + const nonDuration = omitBy(props.other, (_, key) => key.startsWith('duration'), ); @@ -22,6 +31,7 @@ const getTagFilters = async ( ...nonDuration, maxDuration: String((duration.duration || [])[0] || ''), minDuration: String((duration.duration || [])[1] || ''), + exclude, }); return { diff --git a/frontend/src/api/trace/getTagValue.ts b/frontend/src/api/trace/getTagValue.ts new file mode 100644 index 0000000000..25156d32ef --- /dev/null +++ b/frontend/src/api/trace/getTagValue.ts @@ -0,0 +1,28 @@ +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/trace/getTagValue'; + +const getTagValue = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/getTagValues`, { + start: props.start.toString(), + end: props.end.toString(), + tagKey: props.tagKey, + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getTagValue; diff --git a/frontend/src/api/user/changeMyPassword.ts b/frontend/src/api/user/changeMyPassword.ts new file mode 100644 index 0000000000..cdca2cd6bb --- /dev/null +++ b/frontend/src/api/user/changeMyPassword.ts @@ -0,0 +1,26 @@ +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/user/changeMyPassword'; + +const changeMyPassword = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/changePassword/${props.userId}`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default changeMyPassword; diff --git a/frontend/src/api/user/deleteInvite.ts b/frontend/src/api/user/deleteInvite.ts new file mode 100644 index 0000000000..16233ef97f --- /dev/null +++ b/frontend/src/api/user/deleteInvite.ts @@ -0,0 +1,24 @@ +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/user/deleteInvite'; + +const deleteInvite = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.delete(`/invite/${props.email}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default deleteInvite; diff --git a/frontend/src/api/user/deleteUser.ts b/frontend/src/api/user/deleteUser.ts new file mode 100644 index 0000000000..4eb2694782 --- /dev/null +++ b/frontend/src/api/user/deleteUser.ts @@ -0,0 +1,24 @@ +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/user/deleteUser'; + +const deleteUser = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.delete(`/user/${props.userId}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default deleteUser; diff --git a/frontend/src/api/user/editOrg.ts b/frontend/src/api/user/editOrg.ts new file mode 100644 index 0000000000..da980acea0 --- /dev/null +++ b/frontend/src/api/user/editOrg.ts @@ -0,0 +1,28 @@ +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/user/editOrg'; + +const editOrg = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/org/${props.orgId}`, { + name: props.name, + isAnonymous: props.isAnonymous, + hasOptedUpdates: props.hasOptedUpdates, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default editOrg; diff --git a/frontend/src/api/user/editUser.ts b/frontend/src/api/user/editUser.ts new file mode 100644 index 0000000000..88f7c40a25 --- /dev/null +++ b/frontend/src/api/user/editUser.ts @@ -0,0 +1,26 @@ +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/user/editUser'; + +const editUser = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/user/${props.userId}`, { + Name: props.name, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default editUser; diff --git a/frontend/src/api/user/getInviteDetails.ts b/frontend/src/api/user/getInviteDetails.ts new file mode 100644 index 0000000000..b1e4ad7ae5 --- /dev/null +++ b/frontend/src/api/user/getInviteDetails.ts @@ -0,0 +1,24 @@ +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/user/getInviteDetails'; + +const getInviteDetails = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/invite/${props.inviteId}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getInviteDetails; diff --git a/frontend/src/api/user/getLatestVersion.ts b/frontend/src/api/user/getLatestVersion.ts new file mode 100644 index 0000000000..28a72f78be --- /dev/null +++ b/frontend/src/api/user/getLatestVersion.ts @@ -0,0 +1,25 @@ +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import axios, { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/user/getLatestVersion'; + +const getLatestVersion = async (): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.get( + `https://api.github.com/repos/signoz/signoz/releases/latest`, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getLatestVersion; diff --git a/frontend/src/api/user/getOrgUser.ts b/frontend/src/api/user/getOrgUser.ts new file mode 100644 index 0000000000..8956adc1ba --- /dev/null +++ b/frontend/src/api/user/getOrgUser.ts @@ -0,0 +1,24 @@ +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/user/getOrgMembers'; + +const getOrgUser = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/orgUsers/${props.orgId}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getOrgUser; diff --git a/frontend/src/api/user/getOrganization.ts b/frontend/src/api/user/getOrganization.ts new file mode 100644 index 0000000000..dfda5e44e6 --- /dev/null +++ b/frontend/src/api/user/getOrganization.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/user/getOrganization'; + +const getOrganization = async ( + token?: string, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/org`, { + headers: { + Authorization: `bearer ${token}`, + }, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getOrganization; diff --git a/frontend/src/api/user/getPendingInvites.ts b/frontend/src/api/user/getPendingInvites.ts new file mode 100644 index 0000000000..947b7bf755 --- /dev/null +++ b/frontend/src/api/user/getPendingInvites.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/user/getPendingInvites'; + +const getPendingInvites = async (): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.get(`/invite`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getPendingInvites; diff --git a/frontend/src/api/user/getResetPasswordToken.ts b/frontend/src/api/user/getResetPasswordToken.ts new file mode 100644 index 0000000000..845826ed70 --- /dev/null +++ b/frontend/src/api/user/getResetPasswordToken.ts @@ -0,0 +1,24 @@ +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/user/getResetPasswordToken'; + +const getResetPasswordToken = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/getResetPasswordToken/${props.userId}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getResetPasswordToken; diff --git a/frontend/src/api/user/getRoles.ts b/frontend/src/api/user/getRoles.ts new file mode 100644 index 0000000000..0602a0aa63 --- /dev/null +++ b/frontend/src/api/user/getRoles.ts @@ -0,0 +1,28 @@ +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/user/getUserRole'; + +const getRoles = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/rbac/role/${props.userId}`, { + headers: { + Authorization: `bearer ${props.token}`, + }, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getRoles; diff --git a/frontend/src/api/user/getUser.ts b/frontend/src/api/user/getUser.ts new file mode 100644 index 0000000000..6bedb78d2e --- /dev/null +++ b/frontend/src/api/user/getUser.ts @@ -0,0 +1,28 @@ +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/user/getUser'; + +const getUser = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/user/${props.userId}`, { + headers: { + Authorization: `bearer ${props.token}`, + }, + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getUser; diff --git a/frontend/src/api/user/login.ts b/frontend/src/api/user/login.ts new file mode 100644 index 0000000000..4eff88337b --- /dev/null +++ b/frontend/src/api/user/login.ts @@ -0,0 +1,26 @@ +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/user/login'; + +const login = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/login`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.statusText, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default login; diff --git a/frontend/src/api/user/resetPassword.ts b/frontend/src/api/user/resetPassword.ts new file mode 100644 index 0000000000..eb6d2752c7 --- /dev/null +++ b/frontend/src/api/user/resetPassword.ts @@ -0,0 +1,26 @@ +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/user/resetPassword'; + +const resetPassword = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/resetPassword`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.statusText, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default resetPassword; diff --git a/frontend/src/api/user/setPreference.ts b/frontend/src/api/user/sendInvite.ts similarity index 71% rename from frontend/src/api/user/setPreference.ts rename to frontend/src/api/user/sendInvite.ts index de8e309b65..9835588907 100644 --- a/frontend/src/api/user/setPreference.ts +++ b/frontend/src/api/user/sendInvite.ts @@ -2,13 +2,13 @@ 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/user/setUserPreference'; +import { PayloadProps, Props } from 'types/api/user/setInvite'; -const setPreference = async ( +const sendInvite = async ( props: Props, ): Promise | ErrorResponse> => { try { - const response = await axios.post(`/userPreferences`, { + const response = await axios.post(`/invite`, { ...props, }); @@ -23,4 +23,4 @@ const setPreference = async ( } }; -export default setPreference; +export default sendInvite; diff --git a/frontend/src/api/user/signup.ts b/frontend/src/api/user/signup.ts index 8778b5c037..9d7ff78fa4 100644 --- a/frontend/src/api/user/signup.ts +++ b/frontend/src/api/user/signup.ts @@ -6,9 +6,9 @@ import { Props } from 'types/api/user/signup'; const signup = async ( props: Props, -): Promise | ErrorResponse> => { +): Promise | ErrorResponse> => { try { - const response = await axios.post(`/user`, { + const response = await axios.post(`/register`, { ...props, }); diff --git a/frontend/src/api/user/updateRole.ts b/frontend/src/api/user/updateRole.ts new file mode 100644 index 0000000000..5d82a3d991 --- /dev/null +++ b/frontend/src/api/user/updateRole.ts @@ -0,0 +1,26 @@ +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/user/updateRole'; + +const updateRole = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/rbac/role/${props.userId}`, { + group_name: props.group_name, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default updateRole; diff --git a/frontend/src/api/utils.ts b/frontend/src/api/utils.ts new file mode 100644 index 0000000000..56867927a8 --- /dev/null +++ b/frontend/src/api/utils.ts @@ -0,0 +1,55 @@ +import deleteLocalStorageKey from 'api/browser/localstorage/remove'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import store from 'store'; +import { + LOGGED_IN, + UPDATE_USER, + UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN, + UPDATE_USER_ORG_ROLE, +} from 'types/actions/app'; + +export const Logout = (): void => { + deleteLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN); + deleteLocalStorageKey(LOCALSTORAGE.IS_LOGGED_IN); + deleteLocalStorageKey(LOCALSTORAGE.REFRESH_AUTH_TOKEN); + + store.dispatch({ + type: LOGGED_IN, + payload: { + isLoggedIn: false, + }, + }); + + store.dispatch({ + type: UPDATE_USER_ORG_ROLE, + payload: { + org: null, + role: null, + }, + }); + + store.dispatch({ + type: UPDATE_USER, + payload: { + ROLE: 'VIEWER', + email: '', + name: '', + orgId: '', + orgName: '', + profilePictureURL: '', + userId: '', + }, + }); + + store.dispatch({ + type: UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN, + payload: { + accessJwt: '', + refreshJwt: '', + }, + }); + + history.push(ROUTES.LOGIN); +}; diff --git a/frontend/src/assets/SomethingWentWrong.tsx b/frontend/src/assets/SomethingWentWrong.tsx new file mode 100644 index 0000000000..874515d96a --- /dev/null +++ b/frontend/src/assets/SomethingWentWrong.tsx @@ -0,0 +1,470 @@ +import React from 'react'; + +function SomethingWentWrong(): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SomethingWentWrong; diff --git a/frontend/src/assets/UnAuthorized.tsx b/frontend/src/assets/UnAuthorized.tsx new file mode 100644 index 0000000000..53a9977400 --- /dev/null +++ b/frontend/src/assets/UnAuthorized.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +function UnAuthorized(): JSX.Element { + return ( + + + + + + ); +} + +export default UnAuthorized; diff --git a/frontend/src/components/Editor/index.tsx b/frontend/src/components/Editor/index.tsx index 51a024f607..0ed486e248 100644 --- a/frontend/src/components/Editor/index.tsx +++ b/frontend/src/components/Editor/index.tsx @@ -1,18 +1,22 @@ import MEditor from '@monaco-editor/react'; import React from 'react'; -function Editor({ value }: EditorProps): JSX.Element { +function Editor({ + value, + language = 'yaml', + onChange, + readOnly = false, +}: EditorProps): JSX.Element { return ( { - if (value.current && newValue) { - // eslint-disable-next-line no-param-reassign - value.current = newValue; + if (newValue) { + onChange(newValue); } }} /> @@ -20,7 +24,15 @@ function Editor({ value }: EditorProps): JSX.Element { } interface EditorProps { - value: React.MutableRefObject; + value: string; + language?: string; + onChange: (value: string) => void; + readOnly?: boolean; } +Editor.defaultProps = { + language: undefined, + readOnly: false, +}; + export default Editor; diff --git a/frontend/src/components/Graph/Plugin/EmptyGraph.ts b/frontend/src/components/Graph/Plugin/EmptyGraph.ts new file mode 100644 index 0000000000..0f0577bb80 --- /dev/null +++ b/frontend/src/components/Graph/Plugin/EmptyGraph.ts @@ -0,0 +1,17 @@ +import { grey } from '@ant-design/colors'; +import { Chart } from 'chart.js'; + +export const emptyGraph = { + id: 'emptyChart', + afterDraw(chart: Chart): void { + const { height, width, ctx } = chart; + chart.clear(); + ctx.save(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = '1.5rem sans-serif'; + ctx.fillStyle = `${grey.primary}`; + ctx.fillText('No data to display', width / 2, height / 2); + ctx.restore(); + }, +}; diff --git a/frontend/src/components/Graph/hasData.ts b/frontend/src/components/Graph/hasData.ts new file mode 100644 index 0000000000..5ba968bc34 --- /dev/null +++ b/frontend/src/components/Graph/hasData.ts @@ -0,0 +1,19 @@ +/* eslint-disable no-restricted-syntax */ +import { ChartData } from 'chart.js'; + +export const hasData = (data: ChartData): boolean => { + const { datasets = [] } = data; + let hasData = false; + try { + for (const dataset of datasets) { + if (dataset.data.length > 0) { + hasData = true; + break; + } + } + } catch (error) { + console.error(error); + } + + return hasData; +}; diff --git a/frontend/src/components/Graph/index.tsx b/frontend/src/components/Graph/index.tsx index a8f668235d..2194387dd4 100644 --- a/frontend/src/components/Graph/index.tsx +++ b/frontend/src/components/Graph/index.tsx @@ -27,10 +27,12 @@ import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; import AppReducer from 'types/reducer/app'; +import { hasData } from './hasData'; import { legend } from './Plugin'; +import { emptyGraph } from './Plugin/EmptyGraph'; import { LegendsContainer } from './styles'; import { useXAxisTimeUnit } from './xAxisConfig'; -import { getYAxisFormattedValue } from './yAxisConfig'; +import { getToolTipValue, getYAxisFormattedValue } from './yAxisConfig'; Chart.register( LineElement, @@ -113,7 +115,7 @@ function Graph({ label += ': '; } if (context.parsed.y !== null) { - label += getYAxisFormattedValue(context.parsed.y, yAxisUnit); + label += getToolTipValue(context.parsed.y.toString(), yAxisUnit); } return label; }, @@ -128,6 +130,7 @@ function Graph({ grid: { display: true, color: getGridColor(), + drawTicks: true, }, adapters: { date: chartjsAdapter, @@ -157,10 +160,7 @@ function Graph({ ticks: { // Include a dollar sign in the ticks callback(value) { - return getYAxisFormattedValue( - parseInt(value.toString(), 10), - yAxisUnit, - ); + return getYAxisFormattedValue(value.toString(), yAxisUnit); }, }, }, @@ -180,12 +180,18 @@ function Graph({ } }, }; - + const chartHasData = hasData(data); + const chartPlugins = []; + if (chartHasData) { + chartPlugins.push(legend(name, data.datasets.length > 3)); + } else { + chartPlugins.push(emptyGraph); + } lineChartRef.current = new Chart(chartRef.current, { type, data, options, - plugins: [legend(name, data.datasets.length > 3)], + plugins: chartPlugins, }); } }, [ diff --git a/frontend/src/components/Graph/yAxisConfig.ts b/frontend/src/components/Graph/yAxisConfig.ts index 5434eeceb3..5d1eeb5da7 100644 --- a/frontend/src/components/Graph/yAxisConfig.ts +++ b/frontend/src/components/Graph/yAxisConfig.ts @@ -1,12 +1,55 @@ import { formattedValueToString, getValueFormat } from '@grafana/data'; export const getYAxisFormattedValue = ( - value: number, + value: string, format: string, ): string => { + let decimalPrecision: number | undefined; + const parsedValue = getValueFormat(format)( + parseFloat(value), + undefined, + undefined, + undefined, + ); + try { + const decimalSplitted = parsedValue.text.split('.'); + if (decimalSplitted.length === 1) { + decimalPrecision = 0; + } else { + const decimalDigits = decimalSplitted[1].split(''); + decimalPrecision = decimalDigits.length; + let nonZeroCtr = 0; + for (let idx = 0; idx < decimalDigits.length; idx += 1) { + if (decimalDigits[idx] !== '0') { + nonZeroCtr += 1; + if (nonZeroCtr >= 2) { + decimalPrecision = idx + 1; + } + } else if (nonZeroCtr) { + decimalPrecision = idx; + break; + } + } + } + + return formattedValueToString( + getValueFormat(format)( + parseFloat(value), + decimalPrecision, + undefined, + undefined, + ), + ); + } catch (error) { + console.error(error); + } + return `${parseFloat(value)}`; +}; + +export const getToolTipValue = (value: string, format: string): string => { try { return formattedValueToString( - getValueFormat(format)(value, undefined, undefined, undefined), + getValueFormat(format)(parseFloat(value), undefined, undefined, undefined), ); } catch (error) { console.error(error); diff --git a/frontend/src/components/NotFound/NotFound.test.tsx b/frontend/src/components/NotFound/NotFound.test.tsx index c8aab78ef7..3f4be8c009 100644 --- a/frontend/src/components/NotFound/NotFound.test.tsx +++ b/frontend/src/components/NotFound/NotFound.test.tsx @@ -1,3 +1,7 @@ +/** + * @jest-environment jsdom + */ + import { expect } from '@jest/globals'; import { render } from '@testing-library/react'; import React from 'react'; diff --git a/frontend/src/components/NotFound/__snapshots__/NotFound.test.tsx.snap b/frontend/src/components/NotFound/__snapshots__/NotFound.test.tsx.snap index e65af86a8a..0e9ce92e30 100644 --- a/frontend/src/components/NotFound/__snapshots__/NotFound.test.tsx.snap +++ b/frontend/src/components/NotFound/__snapshots__/NotFound.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `

Ah, seems like we reached a dead end!

Page Not Found

diff --git a/frontend/src/components/NotFound/index.tsx b/frontend/src/components/NotFound/index.tsx index 85be7276b8..ffe7f30cdc 100644 --- a/frontend/src/components/NotFound/index.tsx +++ b/frontend/src/components/NotFound/index.tsx @@ -1,10 +1,19 @@ +import getLocalStorageKey from 'api/browser/localstorage/get'; import NotFoundImage from 'assets/NotFound'; +import { LOCALSTORAGE } from 'constants/localStorage'; import ROUTES from 'constants/routes'; import React from 'react'; +import { useDispatch } from 'react-redux'; +import { Dispatch } from 'redux'; +import AppActions from 'types/actions'; +import { LOGGED_IN } from 'types/actions/app'; import { Button, Container, Text, TextContainer } from './styles'; function NotFound(): JSX.Element { + const dispatch = useDispatch>(); + const isLoggedIn = getLocalStorageKey(LOCALSTORAGE.IS_LOGGED_IN); + return ( @@ -14,7 +23,20 @@ function NotFound(): JSX.Element { Page Not Found - diff --git a/frontend/src/components/WelcomeLeftContainer/index.tsx b/frontend/src/components/WelcomeLeftContainer/index.tsx new file mode 100644 index 0000000000..ef69a4599d --- /dev/null +++ b/frontend/src/components/WelcomeLeftContainer/index.tsx @@ -0,0 +1,40 @@ +import { Card, Space, Typography } from 'antd'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Container, LeftContainer, Logo } from './styles'; + +const { Title } = Typography; + +function WelcomeLeftContainer({ + version, + children, +}: WelcomeLeftContainerProps): JSX.Element { + const { t } = useTranslation(); + + return ( + + + + + SigNoz + + {t('monitor_signup')} + + SigNoz {version} + + + {children} + + ); +} + +interface WelcomeLeftContainerProps { + version: string; + children: React.ReactChild; +} + +export default WelcomeLeftContainer; diff --git a/frontend/src/components/WelcomeLeftContainer/styles.ts b/frontend/src/components/WelcomeLeftContainer/styles.ts new file mode 100644 index 0000000000..70428a7f1d --- /dev/null +++ b/frontend/src/components/WelcomeLeftContainer/styles.ts @@ -0,0 +1,22 @@ +import { Space } from 'antd'; +import styled from 'styled-components'; + +export const LeftContainer = styled(Space)` + flex: 1; +`; + +export const Logo = styled.img` + width: 60px; +`; + +export const Container = styled.div` + &&& { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + + max-width: 1024px; + margin: 0 auto; + } +`; diff --git a/frontend/src/constants/app.ts b/frontend/src/constants/app.ts index 90d6222689..35ae663592 100644 --- a/frontend/src/constants/app.ts +++ b/frontend/src/constants/app.ts @@ -7,3 +7,4 @@ export const AUTH0_REDIRECT_PATH = '/redirect'; export const DEFAULT_AUTH0_APP_REDIRECTION_PATH = ROUTES.APPLICATION; export const IS_SIDEBAR_COLLAPSED = 'isSideBarCollapsed'; +export const INVITE_MEMBERS_HASH = '#invite-team-members'; diff --git a/frontend/src/constants/auth.ts b/frontend/src/constants/auth.ts deleted file mode 100644 index ff93594646..0000000000 --- a/frontend/src/constants/auth.ts +++ /dev/null @@ -1 +0,0 @@ -export const IS_LOGGED_IN = 'isLoggedIn'; diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index 86d879122d..d45dbc6cec 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -1,3 +1,6 @@ export enum LOCALSTORAGE { METRICS_TIME_IN_DURATION = 'metricsTimeDurations', + IS_LOGGED_IN = 'IS_LOGGED_IN', + AUTH_TOKEN = 'AUTH_TOKEN', + REFRESH_AUTH_TOKEN = 'REFRESH_AUTH_TOKEN', } diff --git a/frontend/src/constants/resourceAttributes.ts b/frontend/src/constants/resourceAttributes.ts new file mode 100644 index 0000000000..4e82cef590 --- /dev/null +++ b/frontend/src/constants/resourceAttributes.ts @@ -0,0 +1,18 @@ +import { OperatorValues } from 'types/reducer/trace'; + +export const OperatorConversions: Array<{ + label: string; + metricValue: string; + traceValue: OperatorValues; +}> = [ + { + label: 'IN', + metricValue: '=~', + traceValue: 'in', + }, + { + label: 'Not IN', + metricValue: '!~', + traceValue: 'not in', + }, +]; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index acd95003ea..3c7db9c995 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -1,5 +1,6 @@ const ROUTES = { SIGN_UP: '/signup', + LOGIN: '/login', SERVICE_METRICS: '/application/:servicename', SERVICE_MAP: '/service-map', TRACE: '/trace', @@ -17,6 +18,16 @@ const ROUTES = { ALL_CHANNELS: '/settings/channels', CHANNELS_NEW: '/setting/channels/new', CHANNELS_EDIT: '/setting/channels/edit/:id', + ALL_ERROR: '/errors', + ERROR_DETAIL: '/error-detail', + VERSION: '/status', + MY_SETTINGS: '/my-settings', + ORG_SETTINGS: '/settings/org-settings', + SOMETHING_WENT_WRONG: '/something-went-wrong', + UN_AUTHORIZED: '/un-authorized', + NOT_FOUND: '/not-found', + HOME_PAGE: '/', + PASSWORD_RESET: '/password-reset', }; export default ROUTES; diff --git a/frontend/src/container/AllAlertChannels/AlertChannels.tsx b/frontend/src/container/AllAlertChannels/AlertChannels.tsx index f537e969e1..974530c6e5 100644 --- a/frontend/src/container/AllAlertChannels/AlertChannels.tsx +++ b/frontend/src/container/AllAlertChannels/AlertChannels.tsx @@ -2,16 +2,22 @@ import { Button, notification, Table } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import ROUTES from 'constants/routes'; +import useComponentPermission from 'hooks/useComponentPermission'; import history from 'lib/history'; import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; import { generatePath } from 'react-router-dom'; +import { AppState } from 'store/reducers'; import { Channels, PayloadProps } from 'types/api/channels/getAll'; +import AppReducer from 'types/reducer/app'; import Delete from './Delete'; function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element { const [notifications, Element] = notification.useNotification(); const [channels, setChannels] = useState(allChannels); + const { role } = useSelector((state) => state.app); + const [action] = useComponentPermission(['new_alert_action'], role); const onClickEditHandler = useCallback((id: string) => { history.replace( @@ -32,7 +38,10 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element { dataIndex: 'type', key: 'type', }, - { + ]; + + if (action) { + columns.push({ title: 'Action', dataIndex: 'id', key: 'action', @@ -45,8 +54,8 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element { ), - }, - ]; + }); + } return ( <> diff --git a/frontend/src/container/AllAlertChannels/index.tsx b/frontend/src/container/AllAlertChannels/index.tsx index 4aeaba3354..44ab948f0b 100644 --- a/frontend/src/container/AllAlertChannels/index.tsx +++ b/frontend/src/container/AllAlertChannels/index.tsx @@ -4,16 +4,25 @@ import getAll from 'api/channels/getAll'; import Spinner from 'components/Spinner'; import TextToolTip from 'components/TextToolTip'; import ROUTES from 'constants/routes'; +import useComponentPermission from 'hooks/useComponentPermission'; import useFetch from 'hooks/useFetch'; import history from 'lib/history'; import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; -import AlertChannlesComponent from './AlertChannels'; +import AlertChannelsComponent from './AlertChannels'; import { Button, ButtonContainer } from './styles'; const { Paragraph } = Typography; function AlertChannels(): JSX.Element { + const { role } = useSelector((state) => state.app); + const [addNewChannelPermission] = useComponentPermission( + ['add_new_channel'], + role, + ); const onToggleHandler = useCallback(() => { history.push(ROUTES.CHANNELS_NEW); }, []); @@ -41,13 +50,15 @@ function AlertChannels(): JSX.Element { url="https://signoz.io/docs/userguide/alerts-management/#setting-notification-channel" /> - + {addNewChannelPermission && ( + + )}
- + ); } diff --git a/frontend/src/container/AllError/index.tsx b/frontend/src/container/AllError/index.tsx new file mode 100644 index 0000000000..3f49fdb5a4 --- /dev/null +++ b/frontend/src/container/AllError/index.tsx @@ -0,0 +1,114 @@ +import { notification, Table, Typography } from 'antd'; +import { ColumnsType } from 'antd/lib/table'; +import getAll from 'api/errors/getAll'; +import ROUTES from 'constants/routes'; +import dayjs from 'dayjs'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from 'react-query'; +import { useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; +import { AppState } from 'store/reducers'; +import { Exception } from 'types/api/errors/getAll'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +function AllErrors(): JSX.Element { + const { maxTime, minTime } = useSelector( + (state) => state.globalTime, + ); + + const { t } = useTranslation(['common']); + + const { isLoading, data } = useQuery(['getAllError', [maxTime, minTime]], { + queryFn: () => + getAll({ + end: maxTime, + start: minTime, + }), + }); + + useEffect(() => { + if (data?.error) { + notification.error({ + message: data.error || t('something_went_wrong'), + }); + } + }, [data?.error, data?.payload, t]); + + const getDateValue = (value: string): JSX.Element => { + return ( + {dayjs(value).format('DD/MM/YYYY HH:mm:ss A')} + ); + }; + + const columns: ColumnsType = [ + { + title: 'Exception Type', + dataIndex: 'exceptionType', + key: 'exceptionType', + render: (value, record): JSX.Element => ( + + {value} + + ), + sorter: (a, b): number => + a.exceptionType.charCodeAt(0) - b.exceptionType.charCodeAt(0), + }, + { + title: 'Error Message', + dataIndex: 'exceptionMessage', + key: 'exceptionMessage', + render: (value): JSX.Element => ( + + {value} + + ), + }, + { + title: 'Count', + dataIndex: 'exceptionCount', + key: 'exceptionCount', + sorter: (a, b): number => a.exceptionCount - b.exceptionCount, + }, + { + title: 'Last Seen', + dataIndex: 'lastSeen', + key: 'lastSeen', + render: getDateValue, + sorter: (a, b): number => + dayjs(b.lastSeen).isBefore(dayjs(a.lastSeen)) === true ? 1 : 0, + }, + { + title: 'First Seen', + dataIndex: 'firstSeen', + key: 'firstSeen', + render: getDateValue, + sorter: (a, b): number => + dayjs(b.firstSeen).isBefore(dayjs(a.firstSeen)) === true ? 1 : 0, + }, + { + title: 'Application', + dataIndex: 'serviceName', + key: 'serviceName', + sorter: (a, b): number => + a.serviceName.charCodeAt(0) - b.serviceName.charCodeAt(0), + }, + ]; + + return ( + + ); +} + +export default AllErrors; diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 84f7d237e2..911dcd018c 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -1,40 +1,155 @@ -import ROUTES from 'constants/routes'; -import TopNav from 'container/Header'; +import { notification } from 'antd'; +import getUserLatestVersion from 'api/user/getLatestVersion'; +import getUserVersion from 'api/user/getVersion'; +import Header from 'container/Header'; import SideNav from 'container/SideNav'; -import history from 'lib/history'; -import React, { ReactNode, useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; +import TopNav from 'container/TopNav'; +import React, { ReactNode, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQueries } from 'react-query'; +import { useDispatch, useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; +import { Dispatch } from 'redux'; import { AppState } from 'store/reducers'; +import AppActions from 'types/actions'; +import { + UPDATE_CURRENT_ERROR, + UPDATE_CURRENT_VERSION, + UPDATE_LATEST_VERSION, + UPDATE_LATEST_VERSION_ERROR, +} from 'types/actions/app'; import AppReducer from 'types/reducer/app'; -import { Content, Layout } from './styles'; +import { ChildrenContainer, Layout } from './styles'; function AppLayout(props: AppLayoutProps): JSX.Element { const { isLoggedIn } = useSelector((state) => state.app); const { pathname } = useLocation(); + const { t } = useTranslation(); - const [isSignUpPage, setIsSignUpPage] = useState(ROUTES.SIGN_UP === pathname); + const [getUserVersionResponse, getUserLatestVersionResponse] = useQueries([ + { + queryFn: getUserVersion, + queryKey: 'getUserVersion', + enabled: isLoggedIn, + }, + { + queryFn: getUserLatestVersion, + queryKey: 'getUserLatestVersion', + enabled: isLoggedIn, + }, + ]); + + useEffect(() => { + if (getUserLatestVersionResponse.status === 'idle' && isLoggedIn) { + getUserLatestVersionResponse.refetch(); + } + + if (getUserVersionResponse.status === 'idle' && isLoggedIn) { + getUserVersionResponse.refetch(); + } + }, [getUserLatestVersionResponse, getUserVersionResponse, isLoggedIn]); const { children } = props; + const dispatch = useDispatch>(); + + const latestCurrentCounter = useRef(0); + const latestVersionCounter = useRef(0); + useEffect(() => { - if (!isLoggedIn) { - setIsSignUpPage(true); - history.push(ROUTES.SIGN_UP); - } else if (isSignUpPage) { - setIsSignUpPage(false); + if ( + getUserLatestVersionResponse.isFetched && + getUserLatestVersionResponse.isError && + latestCurrentCounter.current === 0 + ) { + latestCurrentCounter.current = 1; + + dispatch({ + type: UPDATE_LATEST_VERSION_ERROR, + payload: { + isError: true, + }, + }); + notification.error({ + message: t('oops_something_went_wrong_version'), + }); } - }, [isLoggedIn, isSignUpPage]); + + if ( + getUserVersionResponse.isFetched && + getUserVersionResponse.isError && + latestVersionCounter.current === 0 + ) { + latestVersionCounter.current = 1; + + dispatch({ + type: UPDATE_CURRENT_ERROR, + payload: { + isError: true, + }, + }); + notification.error({ + message: t('oops_something_went_wrong_version'), + }); + } + + if ( + getUserVersionResponse.isFetched && + getUserLatestVersionResponse.isSuccess && + getUserVersionResponse.data && + getUserVersionResponse.data.payload + ) { + dispatch({ + type: UPDATE_CURRENT_VERSION, + payload: { + currentVersion: getUserVersionResponse.data.payload.version, + }, + }); + } + + if ( + getUserLatestVersionResponse.isFetched && + getUserLatestVersionResponse.isSuccess && + getUserLatestVersionResponse.data && + getUserLatestVersionResponse.data.payload + ) { + dispatch({ + type: UPDATE_LATEST_VERSION, + payload: { + latestVersion: getUserLatestVersionResponse.data.payload.tag_name, + }, + }); + } + }, [ + dispatch, + isLoggedIn, + pathname, + t, + getUserLatestVersionResponse.isLoading, + getUserLatestVersionResponse.isError, + getUserLatestVersionResponse.data, + getUserVersionResponse.isLoading, + getUserVersionResponse.isError, + getUserVersionResponse.data, + getUserLatestVersionResponse.isFetched, + getUserVersionResponse.isFetched, + getUserLatestVersionResponse.isSuccess, + ]); + + const isToDisplayLayout = isLoggedIn; return ( - {!isSignUpPage && } + {isToDisplayLayout &&
} - - {!isSignUpPage && } - {children} - + {isToDisplayLayout && } + + + {isToDisplayLayout && } + {children} + + ); diff --git a/frontend/src/container/AppLayout/styles.ts b/frontend/src/container/AppLayout/styles.ts index f3e9d573b0..71547d1592 100644 --- a/frontend/src/container/AppLayout/styles.ts +++ b/frontend/src/container/AppLayout/styles.ts @@ -3,16 +3,15 @@ import styled from 'styled-components'; export const Layout = styled(LayoutComponent)` &&& { - min-height: 100vh; + min-height: 91vh; display: flex; position: relative; } `; -export const Content = styled(LayoutComponent.Content)` - &&& { - margin: 0 1rem; - display: flex; - flex-direction: column; - } +export const ChildrenContainer = styled.div` + margin: 0 1rem; + display: flex; + flex-direction: column; + height: 100%; `; diff --git a/frontend/src/container/CreateAlertChannels/config.ts b/frontend/src/container/CreateAlertChannels/config.ts index f104a84076..6c89764637 100644 --- a/frontend/src/container/CreateAlertChannels/config.ts +++ b/frontend/src/container/CreateAlertChannels/config.ts @@ -1,6 +1,7 @@ export interface Channel { send_resolved?: boolean; name: string; + filter?: Partial>; } export interface SlackChannel extends Channel { @@ -17,6 +18,66 @@ export interface WebhookChannel extends Channel { password?: string; } -export type ChannelType = 'slack' | 'email' | 'webhook'; +// PagerChannel configures alert manager to send +// events to pagerduty +export interface PagerChannel extends Channel { + // ref: https://prometheus.io/docs/alerting/latest/configuration/#pagerduty_config + routing_key?: string; + // displays source of the event in pager duty + client?: string; + client_url?: string; + // A description of the incident + description?: string; + // Severity of the incident + severity?: string; + // The part or component of the affected system that is broken + component?: string; + // A cluster or grouping of sources + group?: string; + // The class/type of the event. + class?: string; + + details?: string; + detailsArray?: Record; +} +export const ValidatePagerChannel = (p: PagerChannel): string => { + if (!p) { + return 'Received unexpected input for this channel, please contact your administrator '; + } + + if (!p.name || p.name === '') { + return 'Name is mandatory for creating a channel'; + } + + if (!p.routing_key || p.routing_key === '') { + return 'Routing Key is mandatory for creating pagerduty channel'; + } + + // validate details json + try { + JSON.parse(p.details || '{}'); + } catch (e) { + return 'failed to parse additional information, please enter a valid json'; + } + + return ''; +}; + +export type ChannelType = 'slack' | 'email' | 'webhook' | 'pagerduty'; export const SlackType: ChannelType = 'slack'; export const WebhookType: ChannelType = 'webhook'; +export const PagerType: ChannelType = 'pagerduty'; + +// LabelFilterStatement will be used for preparing filter conditions / matchers +export interface LabelFilterStatement { + // ref: https://prometheus.io/docs/alerting/latest/configuration/#matcher + + // label name + name: string; + + // comparators supported by promql are =, !=, =~, or !~. = + comparator: string; + + // filter value + value: string; +} diff --git a/frontend/src/container/CreateAlertChannels/defaults.ts b/frontend/src/container/CreateAlertChannels/defaults.ts new file mode 100644 index 0000000000..ac15056703 --- /dev/null +++ b/frontend/src/container/CreateAlertChannels/defaults.ts @@ -0,0 +1,22 @@ +import { PagerChannel } from './config'; + +export const PagerInitialConfig: Partial = { + description: `{{ range .Alerts -}} + *Alert:* {{ if .Annotations.title }} {{ .Annotations.title }} {{ else }} {{ .Annotations.summary }} {{end}} {{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} + + *Description:* {{ .Annotations.description }} + + *Details:* + {{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }} + {{ end }} + {{ end }}`, + severity: '{{ (index .Alerts 0).Labels.severity }}', + client: 'SigNoz Alert Manager', + client_url: 'https://enter-signoz-host-n-port-here/alerts', + details: JSON.stringify({ + firing: `{{ template "pagerduty.default.instances" .Alerts.Firing }}`, + resolved: `{{ template "pagerduty.default.instances" .Alerts.Resolved }}`, + num_firing: '{{ .Alerts.Firing | len }}', + num_resolved: '{{ .Alerts.Resolved | len }}', + }), +}; diff --git a/frontend/src/container/CreateAlertChannels/index.tsx b/frontend/src/container/CreateAlertChannels/index.tsx index f999627154..fb5ea8a3f6 100644 --- a/frontend/src/container/CreateAlertChannels/index.tsx +++ b/frontend/src/container/CreateAlertChannels/index.tsx @@ -1,29 +1,43 @@ import { Form, notification } from 'antd'; +import createPagerApi from 'api/channels/createPager'; import createSlackApi from 'api/channels/createSlack'; import createWebhookApi from 'api/channels/createWebhook'; +import testPagerApi from 'api/channels/testPager'; +import testSlackApi from 'api/channels/testSlack'; +import testWebhookApi from 'api/channels/testWebhook'; import ROUTES from 'constants/routes'; import FormAlertChannels from 'container/FormAlertChannels'; import history from 'lib/history'; import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { ChannelType, + PagerChannel, + PagerType, SlackChannel, SlackType, + ValidatePagerChannel, WebhookChannel, WebhookType, } from './config'; +import { PagerInitialConfig } from './defaults'; function CreateAlertChannels({ preType = 'slack', }: CreateAlertChannelsProps): JSX.Element { - const [formInstance] = Form.useForm(); - const [selectedConfig, setSelectedConfig] = useState< - Partial - >({ - text: ` {{ range .Alerts -}} - *Alert:* {{ .Annotations.title }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} + // init namespace for translations + const { t } = useTranslation('channels'); + const [formInstance] = Form.useForm(); + + const [selectedConfig, setSelectedConfig] = useState< + Partial + >({ + text: `{{ range .Alerts -}} + *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} + + *Summary:* {{ .Annotations.summary }} *Description:* {{ .Annotations.description }} *Details:* @@ -43,33 +57,48 @@ function CreateAlertChannels({ {{- end }}`, }); const [savingState, setSavingState] = useState(false); + const [testingState, setTestingState] = useState(false); const [notifications, NotificationElement] = notification.useNotification(); const [type, setType] = useState(preType); - const onTypeChangeHandler = useCallback((value: string) => { - setType(value as ChannelType); - }, []); + const onTypeChangeHandler = useCallback( + (value: string) => { + const currentType = type; + setType(value as ChannelType); - const onTestHandler = useCallback(() => { - console.log('test'); - }, []); + if (value === PagerType && currentType !== value) { + // reset config to pager defaults + setSelectedConfig({ + name: selectedConfig?.name, + send_resolved: selectedConfig.send_resolved, + ...PagerInitialConfig, + }); + } + }, + [type, selectedConfig], + ); + + const prepareSlackRequest = useCallback(() => { + return { + api_url: selectedConfig?.api_url || '', + channel: selectedConfig?.channel || '', + name: selectedConfig?.name || '', + send_resolved: true, + text: selectedConfig?.text || '', + title: selectedConfig?.title || '', + }; + }, [selectedConfig]); const onSlackHandler = useCallback(async () => { + setSavingState(true); + try { - setSavingState(true); - const response = await createSlackApi({ - api_url: selectedConfig?.api_url || '', - channel: selectedConfig?.channel || '', - name: selectedConfig?.name || '', - send_resolved: true, - text: selectedConfig?.text || '', - title: selectedConfig?.title || '', - }); + const response = await createSlackApi(prepareSlackRequest()); if (response.statusCode === 200) { notifications.success({ message: 'Success', - description: 'Successfully created the channel', + description: t('channel_creation_done'), }); setTimeout(() => { history.replace(ROUTES.SETTINGS); @@ -77,21 +106,19 @@ function CreateAlertChannels({ } else { notifications.error({ message: 'Error', - description: response.error || 'Error while creating the channel', + description: response.error || t('channel_creation_failed'), }); } - setSavingState(false); } catch (error) { notifications.error({ message: 'Error', - description: - 'An unexpected error occurred while creating this channel, please try again', + description: t('channel_creation_failed'), }); - setSavingState(false); } - }, [notifications, selectedConfig]); + setSavingState(false); + }, [prepareSlackRequest, t, notifications]); - const onWebhookHandler = useCallback(async () => { + const prepareWebhookRequest = useCallback(() => { // initial api request without auth params let request: WebhookChannel = { api_url: selectedConfig?.api_url || '', @@ -99,39 +126,42 @@ function CreateAlertChannels({ send_resolved: true, }; - setSavingState(true); - - try { - if (selectedConfig?.username !== '' || selectedConfig?.password !== '') { - if (selectedConfig?.username !== '') { - // if username is not null then password must be passed - if (selectedConfig?.password !== '') { - request = { - ...request, - username: selectedConfig.username, - password: selectedConfig.password, - }; - } else { - notifications.error({ - message: 'Error', - description: 'A Password must be provided with user name', - }); - } - } else if (selectedConfig?.password !== '') { - // only password entered, set bearer token + if (selectedConfig?.username !== '' || selectedConfig?.password !== '') { + if (selectedConfig?.username !== '') { + // if username is not null then password must be passed + if (selectedConfig?.password !== '') { request = { ...request, - username: '', + username: selectedConfig.username, password: selectedConfig.password, }; + } else { + notifications.error({ + message: 'Error', + description: t('username_no_password'), + }); } + } else if (selectedConfig?.password !== '') { + // only password entered, set bearer token + request = { + ...request, + username: '', + password: selectedConfig.password, + }; } + } + return request; + }, [notifications, t, selectedConfig]); + const onWebhookHandler = useCallback(async () => { + setSavingState(true); + try { + const request = prepareWebhookRequest(); const response = await createWebhookApi(request); if (response.statusCode === 200) { notifications.success({ message: 'Success', - description: 'Successfully created the channel', + description: t('channel_creation_done'), }); setTimeout(() => { history.replace(ROUTES.SETTINGS); @@ -139,18 +169,75 @@ function CreateAlertChannels({ } else { notifications.error({ message: 'Error', - description: response.error || 'Error while creating the channel', + description: response.error || t('channel_creation_failed'), }); } } catch (error) { notifications.error({ message: 'Error', - description: - 'An unexpected error occurred while creating this channel, please try again', + description: t('channel_creation_failed'), }); } setSavingState(false); - }, [notifications, selectedConfig]); + }, [prepareWebhookRequest, t, notifications]); + + const preparePagerRequest = useCallback(() => { + const validationError = ValidatePagerChannel(selectedConfig as PagerChannel); + if (validationError !== '') { + notifications.error({ + message: 'Error', + description: validationError, + }); + return null; + } + + return { + name: selectedConfig?.name || '', + send_resolved: true, + routing_key: selectedConfig?.routing_key || '', + client: selectedConfig?.client || '', + client_url: selectedConfig?.client_url || '', + description: selectedConfig?.description || '', + severity: selectedConfig?.severity || '', + component: selectedConfig?.component || '', + group: selectedConfig?.group || '', + class: selectedConfig?.class || '', + details: selectedConfig.details || '', + detailsArray: JSON.parse(selectedConfig.details || '{}'), + }; + }, [selectedConfig, notifications]); + + const onPagerHandler = useCallback(async () => { + setSavingState(true); + const request = preparePagerRequest(); + + if (request) { + try { + const response = await createPagerApi(request); + + if (response.statusCode === 200) { + notifications.success({ + message: 'Success', + description: t('channel_creation_done'), + }); + setTimeout(() => { + history.replace(ROUTES.SETTINGS); + }, 2000); + } else { + notifications.error({ + message: 'Error', + description: response.error || t('channel_creation_failed'), + }); + } + } catch (e) { + notifications.error({ + message: 'Error', + description: t('channel_creation_failed'), + }); + } + } + setSavingState(false); + }, [t, notifications, preparePagerRequest]); const onSaveHandler = useCallback( async (value: ChannelType) => { @@ -161,14 +248,80 @@ function CreateAlertChannels({ case WebhookType: onWebhookHandler(); break; + case PagerType: + onPagerHandler(); + break; default: notifications.error({ message: 'Error', - description: 'channel type selected is invalid', + description: t('selected_channel_invalid'), }); } }, - [onSlackHandler, onWebhookHandler, notifications], + [onSlackHandler, t, onPagerHandler, onWebhookHandler, notifications], + ); + + const performChannelTest = useCallback( + async (channelType: ChannelType) => { + setTestingState(true); + try { + let request; + let response; + switch (channelType) { + case WebhookType: + request = prepareWebhookRequest(); + response = await testWebhookApi(request); + break; + case SlackType: + request = prepareSlackRequest(); + response = await testSlackApi(request); + break; + case PagerType: + request = preparePagerRequest(); + if (request) response = await testPagerApi(request); + break; + default: + notifications.error({ + message: 'Error', + description: t('test_unsupported'), + }); + setTestingState(false); + return; + } + + if (response && response.statusCode === 200) { + notifications.success({ + message: 'Success', + description: t('channel_test_done'), + }); + } else { + notifications.error({ + message: 'Error', + description: t('channel_test_failed'), + }); + } + } catch (error) { + notifications.error({ + message: 'Error', + description: t('channel_test_unexpected'), + }); + } + setTestingState(false); + }, + [ + prepareWebhookRequest, + t, + preparePagerRequest, + prepareSlackRequest, + notifications, + ], + ); + + const onTestHandler = useCallback( + async (value: ChannelType) => { + performChannelTest(value); + }, + [performChannelTest], ); return ( @@ -181,11 +334,13 @@ function CreateAlertChannels({ onTestHandler, onSaveHandler, savingState, + testingState, NotificationElement, - title: 'New Notification Channels', + title: t('page_title_create'), initialValue: { type, ...selectedConfig, + ...PagerInitialConfig, }, }} /> diff --git a/frontend/src/container/EditAlertChannels/index.tsx b/frontend/src/container/EditAlertChannels/index.tsx index e4aab19d31..ef8f6a0a2e 100644 --- a/frontend/src/container/EditAlertChannels/index.tsx +++ b/frontend/src/container/EditAlertChannels/index.tsx @@ -1,29 +1,41 @@ import { Form, notification } from 'antd'; +import editPagerApi from 'api/channels/editPager'; import editSlackApi from 'api/channels/editSlack'; import editWebhookApi from 'api/channels/editWebhook'; +import testPagerApi from 'api/channels/testPager'; +import testSlackApi from 'api/channels/testSlack'; +import testWebhookApi from 'api/channels/testWebhook'; import ROUTES from 'constants/routes'; import { ChannelType, + PagerChannel, + PagerType, SlackChannel, SlackType, + ValidatePagerChannel, WebhookChannel, WebhookType, } from 'container/CreateAlertChannels/config'; import FormAlertChannels from 'container/FormAlertChannels'; import history from 'lib/history'; import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; function EditAlertChannels({ initialValue, }: EditAlertChannelsProps): JSX.Element { + // init namespace for translations + const { t } = useTranslation('channels'); + const [formInstance] = Form.useForm(); const [selectedConfig, setSelectedConfig] = useState< - Partial + Partial >({ ...initialValue, }); const [savingState, setSavingState] = useState(false); + const [testingState, setTestingState] = useState(false); const [notifications, NotificationElement] = notification.useNotification(); const { id } = useParams<{ id: string }>(); @@ -35,9 +47,8 @@ function EditAlertChannels({ setType(value as ChannelType); }, []); - const onSlackEditHandler = useCallback(async () => { - setSavingState(true); - const response = await editSlackApi({ + const prepareSlackRequest = useCallback(() => { + return { api_url: selectedConfig?.api_url || '', channel: selectedConfig?.channel || '', name: selectedConfig?.name || '', @@ -45,12 +56,27 @@ function EditAlertChannels({ text: selectedConfig?.text || '', title: selectedConfig?.title || '', id, - }); + }; + }, [id, selectedConfig]); + + const onSlackEditHandler = useCallback(async () => { + setSavingState(true); + + if (selectedConfig?.api_url === '') { + notifications.error({ + message: 'Error', + description: t('webhook_url_required'), + }); + setSavingState(false); + return; + } + + const response = await editSlackApi(prepareSlackRequest()); if (response.statusCode === 200) { notifications.success({ message: 'Success', - description: 'Channels Edited Successfully', + description: t('channel_edit_done'), }); setTimeout(() => { @@ -59,15 +85,27 @@ function EditAlertChannels({ } else { notifications.error({ message: 'Error', - description: response.error || 'error while updating the Channels', + description: response.error || t('channel_edit_failed'), }); } setSavingState(false); - }, [selectedConfig, notifications, id]); + }, [prepareSlackRequest, t, notifications, selectedConfig]); + + const prepareWebhookRequest = useCallback(() => { + const { name, username, password } = selectedConfig; + return { + api_url: selectedConfig?.api_url || '', + name: name || '', + send_resolved: true, + username, + password, + id, + }; + }, [id, selectedConfig]); const onWebhookEditHandler = useCallback(async () => { setSavingState(true); - const { name, username, password } = selectedConfig; + const { username, password } = selectedConfig; const showError = (msg: string): void => { notifications.error({ @@ -77,40 +115,82 @@ function EditAlertChannels({ }; if (selectedConfig?.api_url === '') { - showError('Webhook URL is mandatory'); + showError(t('webhook_url_required')); setSavingState(false); return; } if (username && (!password || password === '')) { - showError('Please enter a password'); + showError(t('username_no_password')); setSavingState(false); return; } - const response = await editWebhookApi({ - api_url: selectedConfig?.api_url || '', - name: name || '', - send_resolved: true, - username, - password, - id, - }); + const response = await editWebhookApi(prepareWebhookRequest()); if (response.statusCode === 200) { notifications.success({ message: 'Success', - description: 'Channels Edited Successfully', + description: t('channel_edit_done'), }); setTimeout(() => { history.replace(ROUTES.SETTINGS); }, 2000); } else { - showError(response.error || 'error while updating the Channels'); + showError(response.error || t('channel_edit_failed')); } setSavingState(false); - }, [selectedConfig, notifications, id]); + }, [prepareWebhookRequest, t, notifications, selectedConfig]); + + const preparePagerRequest = useCallback(() => { + return { + name: selectedConfig.name || '', + routing_key: selectedConfig.routing_key, + client: selectedConfig.client, + client_url: selectedConfig.client_url, + description: selectedConfig.description, + severity: selectedConfig.severity, + component: selectedConfig.component, + class: selectedConfig.class, + group: selectedConfig.group, + details: selectedConfig.details, + detailsArray: JSON.parse(selectedConfig.details || '{}'), + id, + }; + }, [id, selectedConfig]); + + const onPagerEditHandler = useCallback(async () => { + setSavingState(true); + const validationError = ValidatePagerChannel(selectedConfig as PagerChannel); + + if (validationError !== '') { + notifications.error({ + message: 'Error', + description: validationError, + }); + setSavingState(false); + return; + } + const response = await editPagerApi(preparePagerRequest()); + + if (response.statusCode === 200) { + notifications.success({ + message: 'Success', + description: t('channel_edit_done'), + }); + + setTimeout(() => { + history.replace(ROUTES.SETTINGS); + }, 2000); + } else { + notifications.error({ + message: 'Error', + description: response.error || t('channel_edit_failed'), + }); + } + setSavingState(false); + }, [preparePagerRequest, notifications, selectedConfig, t]); const onSaveHandler = useCallback( (value: ChannelType) => { @@ -118,14 +198,75 @@ function EditAlertChannels({ onSlackEditHandler(); } else if (value === WebhookType) { onWebhookEditHandler(); + } else if (value === PagerType) { + onPagerEditHandler(); } }, - [onSlackEditHandler, onWebhookEditHandler], + [onSlackEditHandler, onWebhookEditHandler, onPagerEditHandler], ); - const onTestHandler = useCallback(() => { - console.log('test'); - }, []); + const performChannelTest = useCallback( + async (channelType: ChannelType) => { + setTestingState(true); + try { + let request; + let response; + switch (channelType) { + case WebhookType: + request = prepareWebhookRequest(); + response = await testWebhookApi(request); + break; + case SlackType: + request = prepareSlackRequest(); + response = await testSlackApi(request); + break; + case PagerType: + request = preparePagerRequest(); + if (request) response = await testPagerApi(request); + break; + default: + notifications.error({ + message: 'Error', + description: t('test_unsupported'), + }); + setTestingState(false); + return; + } + + if (response && response.statusCode === 200) { + notifications.success({ + message: 'Success', + description: t('channel_test_done'), + }); + } else { + notifications.error({ + message: 'Error', + description: t('channel_test_failed'), + }); + } + } catch (error) { + notifications.error({ + message: 'Error', + description: t('channel_test_failed'), + }); + } + setTestingState(false); + }, + [ + t, + prepareWebhookRequest, + preparePagerRequest, + prepareSlackRequest, + notifications, + ], + ); + + const onTestHandler = useCallback( + async (value: ChannelType) => { + performChannelTest(value); + }, + [performChannelTest], + ); return ( ); diff --git a/frontend/src/container/EditRules/index.tsx b/frontend/src/container/EditRules/index.tsx index 342fd5c494..e228af0a10 100644 --- a/frontend/src/container/EditRules/index.tsx +++ b/frontend/src/container/EditRules/index.tsx @@ -5,14 +5,14 @@ import Editor from 'components/Editor'; import ROUTES from 'constants/routes'; import { State } from 'hooks/useFetch'; import history from 'lib/history'; -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { PayloadProps } from 'types/api/alerts/get'; import { PayloadProps as PutPayloadProps } from 'types/api/alerts/put'; import { ButtonContainer } from './styles'; function EditRules({ initialData, ruleId }: EditRulesProps): JSX.Element { - const value = useRef(initialData); + const [value, setEditorValue] = useState(initialData); const [notifications, Element] = notification.useNotification(); const [editButtonState, setEditButtonState] = useState>( { @@ -31,7 +31,7 @@ function EditRules({ initialData, ruleId }: EditRulesProps): JSX.Element { loading: true, })); const response = await put({ - data: value.current, + data: value, id: parseInt(ruleId, 10), }); @@ -72,13 +72,13 @@ function EditRules({ initialData, ruleId }: EditRulesProps): JSX.Element { 'Oops! Some issue occured in editing the alert please try again or contact support@signoz.io', }); } - }, [ruleId, notifications]); + }, [value, ruleId, notifications]); return ( <> {Element} - + setEditorValue(value)} value={value} /> + + + + + + + {t('see_trace_graph')} + + + + {t('stack_trace')} + {}} value={stackTraceValue} readOnly /> + + + +
+ + + + ); +} + +interface ErrorDetailsProps { + idPayload: PayloadProps; +} + +export default ErrorDetails; diff --git a/frontend/src/container/ErrorDetails/styles.ts b/frontend/src/container/ErrorDetails/styles.ts new file mode 100644 index 0000000000..d1cd0327a5 --- /dev/null +++ b/frontend/src/container/ErrorDetails/styles.ts @@ -0,0 +1,28 @@ +import { grey } from '@ant-design/colors'; +import styled from 'styled-components'; + +export const DashedContainer = styled.div` + border: ${`1px dashed ${grey[0]}`}; + box-sizing: border-box; + border-radius: 0.25rem; + display: flex; + justify-content: space-between; + padding: 1rem; + margin-top: 1.875rem; + margin-bottom: 1.625rem; + align-items: center; +`; + +export const ButtonContainer = styled.div` + display: flex; + gap: 1rem; +`; + +export const EventContainer = styled.div` + display: flex; + justify-content: space-between; +`; + +export const EditorContainer = styled.div` + margin-top: 1.5rem; +`; diff --git a/frontend/src/container/FormAlertChannels/Settings/LabelFilter.tsx b/frontend/src/container/FormAlertChannels/Settings/LabelFilter.tsx new file mode 100644 index 0000000000..2d71c520ce --- /dev/null +++ b/frontend/src/container/FormAlertChannels/Settings/LabelFilter.tsx @@ -0,0 +1,64 @@ +import { Input, Select } from 'antd'; +import FormItem from 'antd/lib/form/FormItem'; +import { LabelFilterStatement } from 'container/CreateAlertChannels/config'; +import React from 'react'; + +const { Option } = Select; + +// LabelFilterForm supports filters or matchers on alert notifications +// presently un-used but will be introduced to the channel creation at some +// point +function LabelFilterForm({ setFilter }: LabelFilterProps): JSX.Element { + return ( + + + + + { + setFilter((value) => { + const first: LabelFilterStatement = value[0] as LabelFilterStatement; + first.value = event.target.value; + return [first]; + }); + }} + /> + + + ); +} + +export interface LabelFilterProps { + setFilter: React.Dispatch< + React.SetStateAction>> + >; +} + +export default LabelFilterForm; diff --git a/frontend/src/container/FormAlertChannels/Settings/Pager.tsx b/frontend/src/container/FormAlertChannels/Settings/Pager.tsx new file mode 100644 index 0000000000..0e36613096 --- /dev/null +++ b/frontend/src/container/FormAlertChannels/Settings/Pager.tsx @@ -0,0 +1,155 @@ +import { Input } from 'antd'; +import FormItem from 'antd/lib/form/FormItem'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { PagerChannel } from '../../CreateAlertChannels/config'; + +const { TextArea } = Input; + +function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element { + const { t } = useTranslation('channels'); + return ( + <> + + { + setSelectedConfig((value) => ({ + ...value, + routing_key: event.target.value, + })); + }} + /> + + + +