mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-13 14:08:58 +08:00
commit
aeee8b4cb2
@ -131,7 +131,7 @@ services:
|
|||||||
# - ./data/clickhouse-3/:/var/lib/clickhouse/
|
# - ./data/clickhouse-3/:/var/lib/clickhouse/
|
||||||
|
|
||||||
alertmanager:
|
alertmanager:
|
||||||
image: signoz/alertmanager:0.23.2
|
image: signoz/alertmanager:0.23.3
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/alertmanager:/data
|
- ./data/alertmanager:/data
|
||||||
command:
|
command:
|
||||||
@ -144,7 +144,7 @@ services:
|
|||||||
condition: on-failure
|
condition: on-failure
|
||||||
|
|
||||||
query-service:
|
query-service:
|
||||||
image: signoz/query-service:0.27.0
|
image: signoz/query-service:0.28.0
|
||||||
command: [ "-config=/root/config/prometheus.yml" ]
|
command: [ "-config=/root/config/prometheus.yml" ]
|
||||||
# ports:
|
# ports:
|
||||||
# - "6060:6060" # pprof port
|
# - "6060:6060" # pprof port
|
||||||
@ -180,7 +180,7 @@ services:
|
|||||||
<<: *clickhouse-depend
|
<<: *clickhouse-depend
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: signoz/frontend:0.27.0
|
image: signoz/frontend:0.28.0
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
|
@ -24,10 +24,6 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/alertmanager {
|
|
||||||
proxy_pass http://alertmanager:9093/api/v2;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/api/(v1|v3)/logs/(tail|livetail){
|
location ~ ^/api/(v1|v3)/logs/(tail|livetail){
|
||||||
proxy_pass http://query-service:8080;
|
proxy_pass http://query-service:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
@ -34,7 +34,7 @@ services:
|
|||||||
|
|
||||||
alertmanager:
|
alertmanager:
|
||||||
container_name: signoz-alertmanager
|
container_name: signoz-alertmanager
|
||||||
image: signoz/alertmanager:0.23.2
|
image: signoz/alertmanager:0.23.3
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/alertmanager:/data
|
- ./data/alertmanager:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
|
@ -147,7 +147,7 @@ services:
|
|||||||
# - ./user_scripts:/var/lib/clickhouse/user_scripts/
|
# - ./user_scripts:/var/lib/clickhouse/user_scripts/
|
||||||
|
|
||||||
alertmanager:
|
alertmanager:
|
||||||
image: signoz/alertmanager:${ALERTMANAGER_TAG:-0.23.2}
|
image: signoz/alertmanager:${ALERTMANAGER_TAG:-0.23.3}
|
||||||
container_name: signoz-alertmanager
|
container_name: signoz-alertmanager
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/alertmanager:/data
|
- ./data/alertmanager:/data
|
||||||
@ -162,7 +162,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`
|
# 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:
|
query-service:
|
||||||
image: signoz/query-service:${DOCKER_TAG:-0.27.0}
|
image: signoz/query-service:${DOCKER_TAG:-0.28.0}
|
||||||
container_name: signoz-query-service
|
container_name: signoz-query-service
|
||||||
command: [ "-config=/root/config/prometheus.yml" ]
|
command: [ "-config=/root/config/prometheus.yml" ]
|
||||||
# ports:
|
# ports:
|
||||||
@ -197,7 +197,7 @@ services:
|
|||||||
<<: *clickhouse-depend
|
<<: *clickhouse-depend
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: signoz/frontend:${DOCKER_TAG:-0.27.0}
|
image: signoz/frontend:${DOCKER_TAG:-0.28.0}
|
||||||
container_name: signoz-frontend
|
container_name: signoz-frontend
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
depends_on:
|
depends_on:
|
||||||
|
@ -24,10 +24,6 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/alertmanager {
|
|
||||||
proxy_pass http://alertmanager:9093/api/v2;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/api/(v1|v3)/logs/(tail|livetail){
|
location ~ ^/api/(v1|v3)/logs/(tail|livetail){
|
||||||
proxy_pass http://query-service:8080;
|
proxy_pass http://query-service:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
@ -81,6 +81,13 @@ var BasicPlan = basemodel.FeatureSet{
|
|||||||
UsageLimit: -1,
|
UsageLimit: -1,
|
||||||
Route: "",
|
Route: "",
|
||||||
},
|
},
|
||||||
|
basemodel.Feature{
|
||||||
|
Name: basemodel.AlertChannelOpsgenie,
|
||||||
|
Active: true,
|
||||||
|
Usage: 0,
|
||||||
|
UsageLimit: -1,
|
||||||
|
Route: "",
|
||||||
|
},
|
||||||
basemodel.Feature{
|
basemodel.Feature{
|
||||||
Name: basemodel.AlertChannelMsTeams,
|
Name: basemodel.AlertChannelMsTeams,
|
||||||
Active: false,
|
Active: false,
|
||||||
@ -161,6 +168,13 @@ var ProPlan = basemodel.FeatureSet{
|
|||||||
UsageLimit: -1,
|
UsageLimit: -1,
|
||||||
Route: "",
|
Route: "",
|
||||||
},
|
},
|
||||||
|
basemodel.Feature{
|
||||||
|
Name: basemodel.AlertChannelOpsgenie,
|
||||||
|
Active: true,
|
||||||
|
Usage: 0,
|
||||||
|
UsageLimit: -1,
|
||||||
|
Route: "",
|
||||||
|
},
|
||||||
basemodel.Feature{
|
basemodel.Feature{
|
||||||
Name: basemodel.AlertChannelMsTeams,
|
Name: basemodel.AlertChannelMsTeams,
|
||||||
Active: true,
|
Active: true,
|
||||||
@ -241,6 +255,13 @@ var EnterprisePlan = basemodel.FeatureSet{
|
|||||||
UsageLimit: -1,
|
UsageLimit: -1,
|
||||||
Route: "",
|
Route: "",
|
||||||
},
|
},
|
||||||
|
basemodel.Feature{
|
||||||
|
Name: basemodel.AlertChannelOpsgenie,
|
||||||
|
Active: true,
|
||||||
|
Usage: 0,
|
||||||
|
UsageLimit: -1,
|
||||||
|
Route: "",
|
||||||
|
},
|
||||||
basemodel.Feature{
|
basemodel.Feature{
|
||||||
Name: basemodel.AlertChannelMsTeams,
|
Name: basemodel.AlertChannelMsTeams,
|
||||||
Active: true,
|
Active: true,
|
||||||
|
@ -185,7 +185,7 @@
|
|||||||
"react-resizable": "3.0.4",
|
"react-resizable": "3.0.4",
|
||||||
"ts-jest": "^27.1.4",
|
"ts-jest": "^27.1.4",
|
||||||
"ts-node": "^10.2.1",
|
"ts-node": "^10.2.1",
|
||||||
"typescript-plugin-css-modules": "^3.4.0",
|
"typescript-plugin-css-modules": "5.0.1",
|
||||||
"webpack-bundle-analyzer": "^4.5.0",
|
"webpack-bundle-analyzer": "^4.5.0",
|
||||||
"webpack-cli": "^4.9.2"
|
"webpack-cli": "^4.9.2"
|
||||||
},
|
},
|
||||||
|
1
frontend/public/locales/en-GB/logs.json
Normal file
1
frontend/public/locales/en-GB/logs.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{ "fetching_log_lines": "Fetching log lines" }
|
@ -29,6 +29,7 @@
|
|||||||
"NOT_FOUND": "SigNoz | Page Not Found",
|
"NOT_FOUND": "SigNoz | Page Not Found",
|
||||||
"LOGS": "SigNoz | Logs",
|
"LOGS": "SigNoz | Logs",
|
||||||
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
|
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
|
||||||
|
"LIVE_LOGS": "SigNoz | Live Logs",
|
||||||
"HOME_PAGE": "Open source Observability Platform | SigNoz",
|
"HOME_PAGE": "Open source Observability Platform | SigNoz",
|
||||||
"PASSWORD_RESET": "SigNoz | Password Reset",
|
"PASSWORD_RESET": "SigNoz | Password Reset",
|
||||||
"LIST_LICENSES": "SigNoz | List of Licenses",
|
"LIST_LICENSES": "SigNoz | List of Licenses",
|
||||||
|
@ -20,6 +20,9 @@
|
|||||||
"field_slack_recipient": "Recipient",
|
"field_slack_recipient": "Recipient",
|
||||||
"field_slack_title": "Title",
|
"field_slack_title": "Title",
|
||||||
"field_slack_description": "Description",
|
"field_slack_description": "Description",
|
||||||
|
"field_opsgenie_api_key": "API Key",
|
||||||
|
"field_opsgenie_description": "Description",
|
||||||
|
"placeholder_opsgenie_description": "Description",
|
||||||
"field_webhook_username": "User Name (optional)",
|
"field_webhook_username": "User Name (optional)",
|
||||||
"field_webhook_password": "Password (optional)",
|
"field_webhook_password": "Password (optional)",
|
||||||
"field_pager_routing_key": "Routing Key",
|
"field_pager_routing_key": "Routing Key",
|
||||||
@ -31,8 +34,12 @@
|
|||||||
"field_pager_class": "Class",
|
"field_pager_class": "Class",
|
||||||
"field_pager_client": "Client",
|
"field_pager_client": "Client",
|
||||||
"field_pager_client_url": "Client URL",
|
"field_pager_client_url": "Client URL",
|
||||||
|
"field_opsgenie_message": "Message",
|
||||||
|
"field_opsgenie_priority": "Priority",
|
||||||
"placeholder_slack_description": "Description",
|
"placeholder_slack_description": "Description",
|
||||||
"placeholder_pager_description": "Description",
|
"placeholder_pager_description": "Description",
|
||||||
|
"placeholder_opsgenie_message": "Message",
|
||||||
|
"placeholder_opsgenie_priority": "Priority",
|
||||||
"help_pager_client": "Shows up as event source in Pagerduty",
|
"help_pager_client": "Shows up as event source in Pagerduty",
|
||||||
"help_pager_client_url": "Shows up as event source link 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_class": "The class/type of the event",
|
||||||
@ -43,6 +50,9 @@
|
|||||||
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
|
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
|
||||||
"help_webhook_password": "Specify a password or bearer token",
|
"help_webhook_password": "Specify a password or bearer token",
|
||||||
"help_pager_description": "Shows up as description in pagerduty",
|
"help_pager_description": "Shows up as description in pagerduty",
|
||||||
|
"help_opsgenie_message": "Shows up as message in opsgenie",
|
||||||
|
"help_opsgenie_priority": "Priority of the incident",
|
||||||
|
"help_opsgenie_description": "Shows up as description in opsgenie",
|
||||||
"channel_creation_done": "Successfully created the channel",
|
"channel_creation_done": "Successfully created the channel",
|
||||||
"channel_creation_failed": "An unexpected error occurred while creating this channel",
|
"channel_creation_failed": "An unexpected error occurred while creating this channel",
|
||||||
"channel_edit_done": "Channels Edited Successfully",
|
"channel_edit_done": "Channels Edited Successfully",
|
||||||
|
1
frontend/public/locales/en/logs.json
Normal file
1
frontend/public/locales/en/logs.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{ "fetching_log_lines": "Fetching log lines" }
|
@ -29,6 +29,7 @@
|
|||||||
"NOT_FOUND": "SigNoz | Page Not Found",
|
"NOT_FOUND": "SigNoz | Page Not Found",
|
||||||
"LOGS": "SigNoz | Logs",
|
"LOGS": "SigNoz | Logs",
|
||||||
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
|
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
|
||||||
|
"LIVE_LOGS": "SigNoz | Live Logs",
|
||||||
"HOME_PAGE": "Open source Observability Platform | SigNoz",
|
"HOME_PAGE": "Open source Observability Platform | SigNoz",
|
||||||
"PASSWORD_RESET": "SigNoz | Password Reset",
|
"PASSWORD_RESET": "SigNoz | Password Reset",
|
||||||
"LIST_LICENSES": "SigNoz | List of Licenses",
|
"LIST_LICENSES": "SigNoz | List of Licenses",
|
||||||
|
@ -110,6 +110,10 @@ export const LogsExplorer = Loadable(
|
|||||||
() => import(/* webpackChunkName: "Logs Explorer" */ 'pages/LogsExplorer'),
|
() => import(/* webpackChunkName: "Logs Explorer" */ 'pages/LogsExplorer'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const LiveLogs = Loadable(
|
||||||
|
() => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'),
|
||||||
|
);
|
||||||
|
|
||||||
export const Login = Loadable(
|
export const Login = Loadable(
|
||||||
() => import(/* webpackChunkName: "Login" */ 'pages/Login'),
|
() => import(/* webpackChunkName: "Login" */ 'pages/Login'),
|
||||||
);
|
);
|
||||||
|
@ -14,6 +14,7 @@ import {
|
|||||||
GettingStarted,
|
GettingStarted,
|
||||||
LicensePage,
|
LicensePage,
|
||||||
ListAllALertsPage,
|
ListAllALertsPage,
|
||||||
|
LiveLogs,
|
||||||
Login,
|
Login,
|
||||||
Logs,
|
Logs,
|
||||||
LogsExplorer,
|
LogsExplorer,
|
||||||
@ -234,6 +235,13 @@ const routes: AppRoutes[] = [
|
|||||||
key: 'LOGS_EXPLORER',
|
key: 'LOGS_EXPLORER',
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ROUTES.LIVE_LOGS,
|
||||||
|
exact: true,
|
||||||
|
component: LiveLogs,
|
||||||
|
key: 'LIVE_LOGS',
|
||||||
|
isPrivate: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ROUTES.LOGIN,
|
path: ROUTES.LOGIN,
|
||||||
exact: true,
|
exact: true,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { AxiosAlertManagerInstance } from 'api';
|
import axios from 'api';
|
||||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import convertObjectIntoParams from 'lib/query/convertObjectIntoParams';
|
import convertObjectIntoParams from 'lib/query/convertObjectIntoParams';
|
||||||
@ -11,15 +11,15 @@ const getTriggered = async (
|
|||||||
try {
|
try {
|
||||||
const queryParams = convertObjectIntoParams(props);
|
const queryParams = convertObjectIntoParams(props);
|
||||||
|
|
||||||
const response = await AxiosAlertManagerInstance.get(
|
const response = await axios.get(`/alerts?${queryParams}`);
|
||||||
`/alerts?${queryParams}`,
|
|
||||||
);
|
const amData = JSON.parse(response.data.data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
error: null,
|
error: null,
|
||||||
message: response.data.status,
|
message: response.data.status,
|
||||||
payload: response.data,
|
payload: amData.data,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return ErrorResponseHandler(error as AxiosError);
|
return ErrorResponseHandler(error as AxiosError);
|
||||||
|
37
frontend/src/api/channels/createOpsgenie.ts
Normal file
37
frontend/src/api/channels/createOpsgenie.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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/createOpsgenie';
|
||||||
|
|
||||||
|
const create = async (
|
||||||
|
props: Props,
|
||||||
|
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/channels', {
|
||||||
|
name: props.name,
|
||||||
|
opsgenie_configs: [
|
||||||
|
{
|
||||||
|
api_key: props.api_key,
|
||||||
|
description: props.description,
|
||||||
|
priority: props.priority,
|
||||||
|
message: props.message,
|
||||||
|
details: {
|
||||||
|
...props.detailsArray,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
error: null,
|
||||||
|
message: 'Success',
|
||||||
|
payload: response.data.data,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return ErrorResponseHandler(error as AxiosError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default create;
|
38
frontend/src/api/channels/editOpsgenie.ts
Normal file
38
frontend/src/api/channels/editOpsgenie.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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/editOpsgenie';
|
||||||
|
|
||||||
|
const editOpsgenie = async (
|
||||||
|
props: Props,
|
||||||
|
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`/channels/${props.id}`, {
|
||||||
|
name: props.name,
|
||||||
|
opsgenie_configs: [
|
||||||
|
{
|
||||||
|
send_resolved: true,
|
||||||
|
api_key: props.api_key,
|
||||||
|
description: props.description,
|
||||||
|
priority: props.priority,
|
||||||
|
message: props.message,
|
||||||
|
details: {
|
||||||
|
...props.detailsArray,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
error: null,
|
||||||
|
message: 'Success',
|
||||||
|
payload: response.data.data,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return ErrorResponseHandler(error as AxiosError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default editOpsgenie;
|
37
frontend/src/api/channels/testOpsgenie.ts
Normal file
37
frontend/src/api/channels/testOpsgenie.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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/createOpsgenie';
|
||||||
|
|
||||||
|
const testOpsgenie = async (
|
||||||
|
props: Props,
|
||||||
|
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/testChannel', {
|
||||||
|
name: props.name,
|
||||||
|
opsgenie_configs: [
|
||||||
|
{
|
||||||
|
api_key: props.api_key,
|
||||||
|
description: props.description,
|
||||||
|
priority: props.priority,
|
||||||
|
message: props.message,
|
||||||
|
details: {
|
||||||
|
...props.detailsArray,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
error: null,
|
||||||
|
message: 'Success',
|
||||||
|
payload: response.data.data,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return ErrorResponseHandler(error as AxiosError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default testOpsgenie;
|
@ -4,11 +4,11 @@ import { AxiosError } from 'axios';
|
|||||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
import {
|
import {
|
||||||
MetricRangePayloadV3,
|
MetricRangePayloadV3,
|
||||||
MetricsRangeProps,
|
QueryRangePayload,
|
||||||
} from 'types/api/metrics/getQueryRange';
|
} from 'types/api/metrics/getQueryRange';
|
||||||
|
|
||||||
export const getMetricsQueryRange = async (
|
export const getMetricsQueryRange = async (
|
||||||
props: MetricsRangeProps,
|
props: QueryRangePayload,
|
||||||
): Promise<SuccessResponse<MetricRangePayloadV3> | ErrorResponse> => {
|
): Promise<SuccessResponse<MetricRangePayloadV3> | ErrorResponse> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/query_range', props);
|
const response = await axios.post('/query_range', props);
|
||||||
|
5
frontend/src/api/saveView/deleteView.ts
Normal file
5
frontend/src/api/saveView/deleteView.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import axios from 'api';
|
||||||
|
import { DeleteViewPayloadProps } from 'types/api/saveViews/types';
|
||||||
|
|
||||||
|
export const deleteView = (uuid: string): Promise<DeleteViewPayloadProps> =>
|
||||||
|
axios.delete(`explorer/views/${uuid}`);
|
9
frontend/src/api/saveView/getAllViews.ts
Normal file
9
frontend/src/api/saveView/getAllViews.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import axios from 'api';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { AllViewsProps } from 'types/api/saveViews/types';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
export const getAllViews = (
|
||||||
|
sourcepage: DataSource,
|
||||||
|
): Promise<AxiosResponse<AllViewsProps>> =>
|
||||||
|
axios.get(`explorer/views?sourcePage=${sourcepage}`);
|
16
frontend/src/api/saveView/saveView.ts
Normal file
16
frontend/src/api/saveView/saveView.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import axios from 'api';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
|
||||||
|
|
||||||
|
export const saveView = ({
|
||||||
|
compositeQuery,
|
||||||
|
sourcePage,
|
||||||
|
viewName,
|
||||||
|
extraData,
|
||||||
|
}: SaveViewProps): Promise<AxiosResponse<SaveViewPayloadProps>> =>
|
||||||
|
axios.post('explorer/views', {
|
||||||
|
name: viewName,
|
||||||
|
sourcePage,
|
||||||
|
compositeQuery,
|
||||||
|
extraData,
|
||||||
|
});
|
19
frontend/src/api/saveView/updateView.ts
Normal file
19
frontend/src/api/saveView/updateView.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import axios from 'api';
|
||||||
|
import {
|
||||||
|
UpdateViewPayloadProps,
|
||||||
|
UpdateViewProps,
|
||||||
|
} from 'types/api/saveViews/types';
|
||||||
|
|
||||||
|
export const updateView = ({
|
||||||
|
compositeQuery,
|
||||||
|
viewName,
|
||||||
|
extraData,
|
||||||
|
sourcePage,
|
||||||
|
viewKey,
|
||||||
|
}: UpdateViewProps): Promise<UpdateViewPayloadProps> =>
|
||||||
|
axios.put(`explorer/views/${viewKey}`, {
|
||||||
|
name: viewName,
|
||||||
|
compositeQuery,
|
||||||
|
extraData,
|
||||||
|
sourcePage,
|
||||||
|
});
|
278
frontend/src/components/ExplorerCard/ExplorerCard.tsx
Normal file
278
frontend/src/components/ExplorerCard/ExplorerCard.tsx
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Dropdown,
|
||||||
|
MenuProps,
|
||||||
|
Popover,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import axios from 'axios';
|
||||||
|
import TextToolTip from 'components/TextToolTip';
|
||||||
|
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||||
|
import { querySearchParams } from 'constants/queryBuilderQueryNames';
|
||||||
|
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
|
||||||
|
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||||
|
import { useUpdateView } from 'hooks/saveViews/useUpdateView';
|
||||||
|
import useErrorNotification from 'hooks/useErrorNotification';
|
||||||
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
|
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useCopyToClipboard } from 'react-use';
|
||||||
|
|
||||||
|
import { ExploreHeaderToolTip, SaveButtonText } from './constants';
|
||||||
|
import MenuItemGenerator from './MenuItemGenerator';
|
||||||
|
import SaveViewWithName from './SaveViewWithName';
|
||||||
|
import {
|
||||||
|
DropDownOverlay,
|
||||||
|
ExplorerCardHeadContainer,
|
||||||
|
OffSetCol,
|
||||||
|
} from './styles';
|
||||||
|
import { ExplorerCardProps } from './types';
|
||||||
|
import { deleteViewHandler, isQueryUpdatedInView } from './utils';
|
||||||
|
|
||||||
|
function ExplorerCard({
|
||||||
|
sourcepage,
|
||||||
|
children,
|
||||||
|
}: ExplorerCardProps): JSX.Element {
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
|
const [, setCopyUrl] = useCopyToClipboard();
|
||||||
|
const [isQueryUpdated, setIsQueryUpdated] = useState<boolean>(false);
|
||||||
|
const { notifications } = useNotifications();
|
||||||
|
|
||||||
|
const onCopyUrlHandler = (): void => {
|
||||||
|
setCopyUrl(window.location.href);
|
||||||
|
notifications.success({
|
||||||
|
message: 'Copied to clipboard',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
stagedQuery,
|
||||||
|
currentQuery,
|
||||||
|
panelType,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
} = useQueryBuilder();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: viewsData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isRefetching,
|
||||||
|
refetch: refetchAllView,
|
||||||
|
} = useGetAllViews(sourcepage);
|
||||||
|
|
||||||
|
useErrorNotification(error);
|
||||||
|
|
||||||
|
const handlePopOverClose = (): void => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean): void => {
|
||||||
|
setIsOpen(newOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewName =
|
||||||
|
useGetSearchQueryParam(querySearchParams.viewName) || 'Query Builder';
|
||||||
|
|
||||||
|
const viewKey = useGetSearchQueryParam(querySearchParams.viewKey) || '';
|
||||||
|
|
||||||
|
const { mutateAsync: updateViewAsync } = useUpdateView({
|
||||||
|
compositeQuery: mapCompositeQueryFromQuery(currentQuery, panelType),
|
||||||
|
viewKey,
|
||||||
|
extraData: '',
|
||||||
|
sourcePage: sourcepage,
|
||||||
|
viewName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deleteViewAsync } = useDeleteView(viewKey);
|
||||||
|
|
||||||
|
const showErrorNotification = (err: Error): void => {
|
||||||
|
notifications.error({
|
||||||
|
message: axios.isAxiosError(err) ? err.message : SOMETHING_WENT_WRONG,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteHandler = useCallback(() => {
|
||||||
|
deleteViewHandler({
|
||||||
|
deleteViewAsync,
|
||||||
|
notifications,
|
||||||
|
panelType,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
refetchAllView,
|
||||||
|
viewId: viewKey,
|
||||||
|
viewKey,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
deleteViewAsync,
|
||||||
|
notifications,
|
||||||
|
panelType,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
refetchAllView,
|
||||||
|
viewKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onUpdateQueryHandler = (): void => {
|
||||||
|
updateViewAsync(
|
||||||
|
{
|
||||||
|
compositeQuery: mapCompositeQueryFromQuery(currentQuery, panelType),
|
||||||
|
viewKey,
|
||||||
|
extraData: '',
|
||||||
|
sourcePage: sourcepage,
|
||||||
|
viewName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsQueryUpdated(false);
|
||||||
|
notifications.success({
|
||||||
|
message: 'View Updated Successfully',
|
||||||
|
});
|
||||||
|
refetchAllView();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
showErrorNotification(err);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsQueryUpdated(
|
||||||
|
isQueryUpdatedInView({
|
||||||
|
data: viewsData?.data?.data,
|
||||||
|
stagedQuery,
|
||||||
|
viewKey,
|
||||||
|
currentPanelType: panelType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
currentQuery,
|
||||||
|
viewsData?.data?.data,
|
||||||
|
stagedQuery,
|
||||||
|
stagedQuery?.builder.queryData,
|
||||||
|
viewKey,
|
||||||
|
panelType,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const menu = useMemo(
|
||||||
|
(): MenuProps => ({
|
||||||
|
items: viewsData?.data?.data?.map((view) => ({
|
||||||
|
key: view.uuid,
|
||||||
|
label: (
|
||||||
|
<MenuItemGenerator
|
||||||
|
viewName={view.name}
|
||||||
|
viewKey={viewKey}
|
||||||
|
createdBy={view.createdBy}
|
||||||
|
uuid={view.uuid}
|
||||||
|
refetchAllView={refetchAllView}
|
||||||
|
viewData={viewsData.data.data}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
[refetchAllView, viewKey, viewsData?.data?.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const moreOptionMenu = useMemo(
|
||||||
|
(): MenuProps => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: <Typography.Text strong>Delete</Typography.Text>,
|
||||||
|
onClick: onDeleteHandler,
|
||||||
|
icon: <DeleteOutlined />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[onDeleteHandler],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveButtonType = isQueryUpdated ? 'default' : 'primary';
|
||||||
|
const saveButtonIcon = isQueryUpdated ? null : <SaveOutlined />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExplorerCardHeadContainer size="small">
|
||||||
|
<Row align="middle">
|
||||||
|
<Col span={6}>
|
||||||
|
<Space>
|
||||||
|
<Typography>{viewName}</Typography>
|
||||||
|
<TextToolTip
|
||||||
|
url={ExploreHeaderToolTip.url}
|
||||||
|
text={ExploreHeaderToolTip.text}
|
||||||
|
useFilledIcon={false}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<OffSetCol span={10} offset={8}>
|
||||||
|
<Space size="large">
|
||||||
|
{viewsData?.data.data && viewsData?.data.data.length && (
|
||||||
|
<Space>
|
||||||
|
{/* <Typography.Text>Saved Views</Typography.Text> */}
|
||||||
|
<Dropdown.Button
|
||||||
|
menu={menu}
|
||||||
|
loading={isLoading || isRefetching}
|
||||||
|
icon={<DownOutlined />}
|
||||||
|
trigger={['click']}
|
||||||
|
overlayStyle={DropDownOverlay}
|
||||||
|
>
|
||||||
|
Select View
|
||||||
|
</Dropdown.Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
{isQueryUpdated && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={onUpdateQueryHandler}
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Popover
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
|
content={
|
||||||
|
<SaveViewWithName
|
||||||
|
sourcePage={sourcepage}
|
||||||
|
handlePopOverClose={handlePopOverClose}
|
||||||
|
refetchAllView={refetchAllView}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
showArrow={false}
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<Button type={saveButtonType} icon={saveButtonIcon}>
|
||||||
|
{isQueryUpdated
|
||||||
|
? SaveButtonText.SAVE_AS_NEW_VIEW
|
||||||
|
: SaveButtonText.SAVE_VIEW}
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
<ShareAltOutlined onClick={onCopyUrlHandler} />
|
||||||
|
{viewKey && (
|
||||||
|
<Dropdown trigger={['click']} menu={moreOptionMenu}>
|
||||||
|
<MoreOutlined />
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</OffSetCol>
|
||||||
|
</Row>
|
||||||
|
</ExplorerCardHeadContainer>
|
||||||
|
<Card>{children}</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExplorerCard;
|
87
frontend/src/components/ExplorerCard/MenuItemGenerator.tsx
Normal file
87
frontend/src/components/ExplorerCard/MenuItemGenerator.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { Col, Row, Typography } from 'antd';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
|
||||||
|
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||||
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
|
import { MouseEvent, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { MenuItemContainer } from './styles';
|
||||||
|
import { MenuItemLabelGeneratorProps } from './types';
|
||||||
|
import { deleteViewHandler, getViewDetailsUsingViewKey } from './utils';
|
||||||
|
|
||||||
|
function MenuItemGenerator({
|
||||||
|
viewName,
|
||||||
|
viewKey,
|
||||||
|
createdBy,
|
||||||
|
uuid,
|
||||||
|
viewData,
|
||||||
|
refetchAllView,
|
||||||
|
}: MenuItemLabelGeneratorProps): JSX.Element {
|
||||||
|
const { panelType, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||||
|
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||||
|
const { notifications } = useNotifications();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteViewAsync } = useDeleteView(uuid);
|
||||||
|
|
||||||
|
const onDeleteHandler = (event: MouseEvent<HTMLElement>): void => {
|
||||||
|
event.stopPropagation();
|
||||||
|
deleteViewHandler({
|
||||||
|
deleteViewAsync,
|
||||||
|
notifications,
|
||||||
|
panelType,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
refetchAllView,
|
||||||
|
viewId: uuid,
|
||||||
|
viewKey,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMenuItemSelectHandler = useCallback(
|
||||||
|
({ key }: { key: string }): void => {
|
||||||
|
const currentViewDetails = getViewDetailsUsingViewKey(key, viewData);
|
||||||
|
if (!currentViewDetails) return;
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
name,
|
||||||
|
uuid,
|
||||||
|
panelType: currentPanelType,
|
||||||
|
} = currentViewDetails;
|
||||||
|
|
||||||
|
handleExplorerTabChange(currentPanelType, {
|
||||||
|
query,
|
||||||
|
name,
|
||||||
|
uuid,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[viewData, handleExplorerTabChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onLabelClickHandler = (): void => {
|
||||||
|
onMenuItemSelectHandler({
|
||||||
|
key: uuid,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItemContainer onClick={onLabelClickHandler}>
|
||||||
|
<Row justify="space-between">
|
||||||
|
<Col span={22}>
|
||||||
|
<Row>
|
||||||
|
<Typography.Text strong>{viewName}</Typography.Text>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Typography.Text type="secondary">Created by {createdBy}</Typography.Text>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<Col span={2}>
|
||||||
|
<Typography.Link>
|
||||||
|
<DeleteOutlined onClick={onDeleteHandler} />
|
||||||
|
</Typography.Link>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</MenuItemContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MenuItemGenerator;
|
68
frontend/src/components/ExplorerCard/SaveViewWithName.tsx
Normal file
68
frontend/src/components/ExplorerCard/SaveViewWithName.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { Card, Input, Typography } from 'antd';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useSaveView } from 'hooks/saveViews/useSaveView';
|
||||||
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
|
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||||
|
import { ChangeEvent, useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { SaveButton } from './styles';
|
||||||
|
import { SaveViewWithNameProps } from './types';
|
||||||
|
import { saveViewHandler } from './utils';
|
||||||
|
|
||||||
|
function SaveViewWithName({
|
||||||
|
sourcePage,
|
||||||
|
handlePopOverClose,
|
||||||
|
refetchAllView,
|
||||||
|
}: SaveViewWithNameProps): JSX.Element {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const {
|
||||||
|
currentQuery,
|
||||||
|
panelType,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
} = useQueryBuilder();
|
||||||
|
const { notifications } = useNotifications();
|
||||||
|
const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType);
|
||||||
|
|
||||||
|
const { isLoading, mutateAsync: saveViewAsync } = useSaveView({
|
||||||
|
viewName: name,
|
||||||
|
compositeQuery,
|
||||||
|
sourcePage,
|
||||||
|
extraData: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onChangeHandler = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
setName(e.target.value);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSaveHandler = (): void => {
|
||||||
|
saveViewHandler({
|
||||||
|
compositeQuery,
|
||||||
|
handlePopOverClose,
|
||||||
|
extraData: '',
|
||||||
|
notifications,
|
||||||
|
panelType: panelType || PANEL_TYPES.LIST,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
refetchAllView,
|
||||||
|
saveViewAsync,
|
||||||
|
sourcePage,
|
||||||
|
viewName: name,
|
||||||
|
setName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Typography>Name of the View</Typography>
|
||||||
|
<Input placeholder="Enter Name" onChange={onChangeHandler} />
|
||||||
|
<SaveButton onClick={onSaveHandler} type="primary" loading={isLoading}>
|
||||||
|
Save
|
||||||
|
</SaveButton>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SaveViewWithName;
|
32
frontend/src/components/ExplorerCard/__mock__/viewData.ts
Normal file
32
frontend/src/components/ExplorerCard/__mock__/viewData.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||||
|
import { ViewProps } from 'types/api/saveViews/types';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
export const viewMockData: ViewProps[] = [
|
||||||
|
{
|
||||||
|
uuid: 'view1',
|
||||||
|
name: 'View 1',
|
||||||
|
createdBy: 'User 1',
|
||||||
|
category: 'category 1',
|
||||||
|
compositeQuery: {} as ICompositeMetricQuery,
|
||||||
|
createdAt: '2021-07-07T06:31:00.000Z',
|
||||||
|
updatedAt: '2021-07-07T06:33:00.000Z',
|
||||||
|
extraData: '',
|
||||||
|
sourcePage: DataSource.TRACES,
|
||||||
|
tags: [],
|
||||||
|
updatedBy: 'User 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: 'view2',
|
||||||
|
name: 'View 2',
|
||||||
|
createdBy: 'User 2',
|
||||||
|
category: 'category 2',
|
||||||
|
compositeQuery: {} as ICompositeMetricQuery,
|
||||||
|
createdAt: '2021-07-07T06:30:00.000Z',
|
||||||
|
updatedAt: '2021-07-07T06:30:00.000Z',
|
||||||
|
extraData: '',
|
||||||
|
sourcePage: DataSource.TRACES,
|
||||||
|
tags: [],
|
||||||
|
updatedBy: 'User 2',
|
||||||
|
},
|
||||||
|
];
|
10
frontend/src/components/ExplorerCard/constants.ts
Normal file
10
frontend/src/components/ExplorerCard/constants.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export const ExploreHeaderToolTip = {
|
||||||
|
url:
|
||||||
|
'https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=new-query-builder',
|
||||||
|
text: 'More details on how to use query builder',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SaveButtonText = {
|
||||||
|
SAVE_AS_NEW_VIEW: 'Save as new view',
|
||||||
|
SAVE_VIEW: 'Save view',
|
||||||
|
};
|
@ -1,27 +0,0 @@
|
|||||||
import { Card, Space, Typography } from 'antd';
|
|
||||||
import TextToolTip from 'components/TextToolTip';
|
|
||||||
|
|
||||||
function ExplorerCard({ children }: Props): JSX.Element {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={
|
|
||||||
<Space>
|
|
||||||
<Typography>Query Builder</Typography>
|
|
||||||
<TextToolTip
|
|
||||||
url="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=new-query-builder"
|
|
||||||
text="More details on how to use query builder"
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ExplorerCard;
|
|
28
frontend/src/components/ExplorerCard/styles.ts
Normal file
28
frontend/src/components/ExplorerCard/styles.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Button, Card, Col } from 'antd';
|
||||||
|
import styled, { CSSProperties } from 'styled-components';
|
||||||
|
|
||||||
|
export const ExplorerCardHeadContainer = styled(Card)`
|
||||||
|
margin: 1rem 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const OffSetCol = styled(Col)`
|
||||||
|
text-align: right;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SaveButton = styled(Button)`
|
||||||
|
&&& {
|
||||||
|
margin: 1rem 0;
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DropDownOverlay: CSSProperties = {
|
||||||
|
maxHeight: '20rem',
|
||||||
|
overflowY: 'auto',
|
||||||
|
width: '20rem',
|
||||||
|
padding: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MenuItemContainer = styled(Card)`
|
||||||
|
padding: 0;
|
||||||
|
`;
|
@ -0,0 +1,76 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
import { viewMockData } from '../__mock__/viewData';
|
||||||
|
import ExplorerCard from '../ExplorerCard';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: (): { pathname: string } => ({
|
||||||
|
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/queryBuilder/useGetPanelTypesQueryParam', () => ({
|
||||||
|
useGetPanelTypesQueryParam: jest.fn(() => 'mockedPanelType'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/saveViews/useGetAllViews', () => ({
|
||||||
|
useGetAllViews: jest.fn(() => ({
|
||||||
|
data: { data: { data: viewMockData } },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isRefetching: false,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/saveViews/useUpdateView', () => ({
|
||||||
|
useUpdateView: jest.fn(() => ({
|
||||||
|
mutateAsync: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/saveViews/useDeleteView', () => ({
|
||||||
|
useDeleteView: jest.fn(() => ({
|
||||||
|
mutateAsync: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ExplorerCard', () => {
|
||||||
|
it('renders a card with a title and a description', () => {
|
||||||
|
render(
|
||||||
|
<MockQueryClientProvider>
|
||||||
|
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
|
||||||
|
</MockQueryClientProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a save view button', () => {
|
||||||
|
render(
|
||||||
|
<MockQueryClientProvider>
|
||||||
|
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
|
||||||
|
</MockQueryClientProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Save view')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should see all the view listed in dropdown', async () => {
|
||||||
|
const screen = render(
|
||||||
|
<ExplorerCard sourcepage={DataSource.TRACES}>Mock Children</ExplorerCard>,
|
||||||
|
);
|
||||||
|
const selectButton = screen.getByText('Select View');
|
||||||
|
|
||||||
|
fireEvent.click(selectButton);
|
||||||
|
|
||||||
|
const spanElement = screen.getByRole('img', {
|
||||||
|
name: 'down',
|
||||||
|
});
|
||||||
|
fireEvent.click(spanElement);
|
||||||
|
const viewNameText = await screen.findByText('View 2');
|
||||||
|
expect(viewNameText).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,53 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||||
|
|
||||||
|
import { viewMockData } from '../__mock__/viewData';
|
||||||
|
import MenuItemGenerator from '../MenuItemGenerator';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: (): { pathname: string } => ({
|
||||||
|
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('MenuItemGenerator', () => {
|
||||||
|
it('should render MenuItemGenerator component', () => {
|
||||||
|
const screen = render(
|
||||||
|
<MockQueryClientProvider>
|
||||||
|
<MenuItemGenerator
|
||||||
|
viewName={viewMockData[0].name}
|
||||||
|
viewKey={viewMockData[0].uuid}
|
||||||
|
createdBy={viewMockData[0].createdBy}
|
||||||
|
uuid={viewMockData[0].uuid}
|
||||||
|
refetchAllView={jest.fn()}
|
||||||
|
viewData={viewMockData}
|
||||||
|
/>
|
||||||
|
</MockQueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(viewMockData[0].name)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onMenuItemSelectHandler on click of MenuItemGenerator', () => {
|
||||||
|
render(
|
||||||
|
<MockQueryClientProvider>
|
||||||
|
<MenuItemGenerator
|
||||||
|
viewName={viewMockData[0].name}
|
||||||
|
viewKey={viewMockData[0].uuid}
|
||||||
|
createdBy={viewMockData[0].createdBy}
|
||||||
|
uuid={viewMockData[0].uuid}
|
||||||
|
refetchAllView={jest.fn()}
|
||||||
|
viewData={viewMockData}
|
||||||
|
/>
|
||||||
|
</MockQueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const spanElement = screen.getByRole('img', {
|
||||||
|
name: 'delete',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spanElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,63 @@
|
|||||||
|
import { fireEvent, render } from '@testing-library/react';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
import SaveViewWithName from '../SaveViewWithName';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: (): { pathname: string } => ({
|
||||||
|
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('hooks/queryBuilder/useGetPanelTypesQueryParam', () => ({
|
||||||
|
useGetPanelTypesQueryParam: jest.fn(() => 'mockedPanelType'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/saveViews/useSaveView', () => ({
|
||||||
|
useSaveView: jest.fn(() => ({
|
||||||
|
mutateAsync: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SaveViewWithName', () => {
|
||||||
|
it('should render SaveViewWithName component', () => {
|
||||||
|
const screen = render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SaveViewWithName
|
||||||
|
sourcePage={DataSource.TRACES}
|
||||||
|
handlePopOverClose={jest.fn()}
|
||||||
|
refetchAllView={jest.fn()}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call saveViewAsync on click of Save button', () => {
|
||||||
|
const screen = render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SaveViewWithName
|
||||||
|
sourcePage={DataSource.TRACES}
|
||||||
|
handlePopOverClose={jest.fn()}
|
||||||
|
refetchAllView={jest.fn()}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Save'));
|
||||||
|
|
||||||
|
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
77
frontend/src/components/ExplorerCard/types.ts
Normal file
77
frontend/src/components/ExplorerCard/types.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { SetStateAction } from 'react';
|
||||||
|
import { UseMutateAsyncFunction } from 'react-query';
|
||||||
|
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import {
|
||||||
|
DeleteViewPayloadProps,
|
||||||
|
SaveViewPayloadProps,
|
||||||
|
SaveViewProps,
|
||||||
|
ViewProps,
|
||||||
|
} from 'types/api/saveViews/types';
|
||||||
|
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
export interface ExplorerCardProps {
|
||||||
|
sourcepage: DataSource;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetViewDetailsUsingViewKey = (
|
||||||
|
viewKey: string,
|
||||||
|
data: ViewProps[] | undefined,
|
||||||
|
) =>
|
||||||
|
| { query: Query; name: string; uuid: string; panelType: PANEL_TYPES }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export interface IsQueryUpdatedInViewProps {
|
||||||
|
viewKey: string;
|
||||||
|
data: ViewProps[] | undefined;
|
||||||
|
stagedQuery: Query | null;
|
||||||
|
currentPanelType: PANEL_TYPES | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveViewWithNameProps {
|
||||||
|
sourcePage: ExplorerCardProps['sourcepage'];
|
||||||
|
handlePopOverClose: VoidFunction;
|
||||||
|
refetchAllView: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuItemLabelGeneratorProps {
|
||||||
|
viewName: string;
|
||||||
|
viewKey: string;
|
||||||
|
createdBy: string;
|
||||||
|
uuid: string;
|
||||||
|
viewData: ViewProps[];
|
||||||
|
refetchAllView: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveViewHandlerProps {
|
||||||
|
viewName: string;
|
||||||
|
compositeQuery: ICompositeMetricQuery;
|
||||||
|
sourcePage: ExplorerCardProps['sourcepage'];
|
||||||
|
extraData: string;
|
||||||
|
panelType: PANEL_TYPES | null;
|
||||||
|
notifications: NotificationInstance;
|
||||||
|
refetchAllView: SaveViewWithNameProps['refetchAllView'];
|
||||||
|
saveViewAsync: UseMutateAsyncFunction<
|
||||||
|
AxiosResponse<SaveViewPayloadProps>,
|
||||||
|
Error,
|
||||||
|
SaveViewProps,
|
||||||
|
SaveViewPayloadProps
|
||||||
|
>;
|
||||||
|
handlePopOverClose: SaveViewWithNameProps['handlePopOverClose'];
|
||||||
|
redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData'];
|
||||||
|
setName: (value: SetStateAction<string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteViewHandlerProps {
|
||||||
|
deleteViewAsync: UseMutateAsyncFunction<DeleteViewPayloadProps, Error, string>;
|
||||||
|
refetchAllView: MenuItemLabelGeneratorProps['refetchAllView'];
|
||||||
|
redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData'];
|
||||||
|
notifications: NotificationInstance;
|
||||||
|
panelType: PANEL_TYPES | null;
|
||||||
|
viewKey: string;
|
||||||
|
viewId: string;
|
||||||
|
}
|
170
frontend/src/components/ExplorerCard/utils.ts
Normal file
170
frontend/src/components/ExplorerCard/utils.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||||
|
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||||
|
import {
|
||||||
|
queryParamNamesMap,
|
||||||
|
querySearchParams,
|
||||||
|
} from 'constants/queryBuilderQueryNames';
|
||||||
|
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||||
|
import isEqual from 'lodash-es/isEqual';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeleteViewHandlerProps,
|
||||||
|
GetViewDetailsUsingViewKey,
|
||||||
|
IsQueryUpdatedInViewProps,
|
||||||
|
SaveViewHandlerProps,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const showErrorNotification = (
|
||||||
|
notifications: NotificationInstance,
|
||||||
|
err: Error,
|
||||||
|
): void => {
|
||||||
|
notifications.error({
|
||||||
|
message: axios.isAxiosError(err) ? err.message : SOMETHING_WENT_WRONG,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getViewDetailsUsingViewKey: GetViewDetailsUsingViewKey = (
|
||||||
|
viewKey,
|
||||||
|
data,
|
||||||
|
) => {
|
||||||
|
const selectedView = data?.find((view) => view.uuid === viewKey);
|
||||||
|
if (selectedView) {
|
||||||
|
const { compositeQuery, name, uuid } = selectedView;
|
||||||
|
const query = mapQueryDataFromApi(compositeQuery);
|
||||||
|
return { query, name, uuid, panelType: compositeQuery.panelType };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isQueryUpdatedInView = ({
|
||||||
|
viewKey,
|
||||||
|
data,
|
||||||
|
stagedQuery,
|
||||||
|
currentPanelType,
|
||||||
|
}: IsQueryUpdatedInViewProps): boolean => {
|
||||||
|
const currentViewDetails = getViewDetailsUsingViewKey(viewKey, data);
|
||||||
|
if (!currentViewDetails) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { query, panelType } = currentViewDetails;
|
||||||
|
|
||||||
|
// Omitting id from aggregateAttribute and groupBy
|
||||||
|
const updatedCurrentQuery = {
|
||||||
|
...stagedQuery,
|
||||||
|
builder: {
|
||||||
|
...stagedQuery?.builder,
|
||||||
|
queryData: stagedQuery?.builder.queryData.map((queryData) => {
|
||||||
|
const { id, ...rest } = queryData.aggregateAttribute;
|
||||||
|
const newAggregateAttribute = rest;
|
||||||
|
const newGroupByAttributes = queryData.groupBy.map((groupByAttribute) => {
|
||||||
|
const { id, ...rest } = groupByAttribute;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
const newItems = queryData.filters.items.map((item) => {
|
||||||
|
const { id, ...newItem } = item;
|
||||||
|
if (item.key) {
|
||||||
|
const { id, ...rest } = item.key;
|
||||||
|
return {
|
||||||
|
...newItem,
|
||||||
|
key: rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return newItem;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...queryData,
|
||||||
|
aggregateAttribute: newAggregateAttribute,
|
||||||
|
groupBy: newGroupByAttributes,
|
||||||
|
filters: {
|
||||||
|
...queryData.filters,
|
||||||
|
items: newItems,
|
||||||
|
},
|
||||||
|
limit: queryData.limit ? queryData.limit : 0,
|
||||||
|
offset: queryData.offset ? queryData.offset : 0,
|
||||||
|
pageSize: queryData.pageSize ? queryData.pageSize : 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
panelType !== currentPanelType ||
|
||||||
|
!isEqual(query.builder, updatedCurrentQuery?.builder) ||
|
||||||
|
!isEqual(query.clickhouse_sql, updatedCurrentQuery?.clickhouse_sql) ||
|
||||||
|
!isEqual(query.promql, updatedCurrentQuery?.promql)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveViewHandler = ({
|
||||||
|
saveViewAsync,
|
||||||
|
refetchAllView,
|
||||||
|
notifications,
|
||||||
|
handlePopOverClose,
|
||||||
|
viewName,
|
||||||
|
compositeQuery,
|
||||||
|
sourcePage,
|
||||||
|
extraData,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
panelType,
|
||||||
|
setName,
|
||||||
|
}: SaveViewHandlerProps): void => {
|
||||||
|
saveViewAsync(
|
||||||
|
{
|
||||||
|
viewName,
|
||||||
|
compositeQuery,
|
||||||
|
sourcePage,
|
||||||
|
extraData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
refetchAllView();
|
||||||
|
redirectWithQueryBuilderData(mapQueryDataFromApi(compositeQuery), {
|
||||||
|
[queryParamNamesMap.panelTypes]: panelType,
|
||||||
|
[querySearchParams.viewName]: viewName,
|
||||||
|
[querySearchParams.viewKey]: data.data.data,
|
||||||
|
});
|
||||||
|
notifications.success({
|
||||||
|
message: 'View Saved Successfully',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
showErrorNotification(notifications, err);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
handlePopOverClose();
|
||||||
|
setName('');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteViewHandler = ({
|
||||||
|
deleteViewAsync,
|
||||||
|
refetchAllView,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
notifications,
|
||||||
|
panelType,
|
||||||
|
viewKey,
|
||||||
|
viewId,
|
||||||
|
}: DeleteViewHandlerProps): void => {
|
||||||
|
deleteViewAsync(viewKey, {
|
||||||
|
onSuccess: () => {
|
||||||
|
if (viewId === viewKey) {
|
||||||
|
redirectWithQueryBuilderData(initialQueriesMap.traces, {
|
||||||
|
[querySearchParams.viewName]: 'Query Builder',
|
||||||
|
[queryParamNamesMap.panelTypes]: panelType,
|
||||||
|
[querySearchParams.viewKey]: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
notifications.success({
|
||||||
|
message: 'View Deleted Successfully',
|
||||||
|
});
|
||||||
|
refetchAllView();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
showErrorNotification(notifications, err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
@ -21,8 +21,6 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
// interfaces
|
|
||||||
import { ILog } from 'types/api/logs/log';
|
|
||||||
|
|
||||||
// styles
|
// styles
|
||||||
import {
|
import {
|
||||||
@ -31,19 +29,17 @@ import {
|
|||||||
RawLogContent,
|
RawLogContent,
|
||||||
RawLogViewContainer,
|
RawLogViewContainer,
|
||||||
} from './styles';
|
} from './styles';
|
||||||
|
import { RawLogViewProps } from './types';
|
||||||
|
|
||||||
const convert = new Convert();
|
const convert = new Convert();
|
||||||
|
|
||||||
interface RawLogViewProps {
|
function RawLogView({
|
||||||
isActiveLog?: boolean;
|
isActiveLog,
|
||||||
isReadOnly?: boolean;
|
isReadOnly,
|
||||||
data: ILog;
|
data,
|
||||||
linesPerRow: number;
|
linesPerRow,
|
||||||
}
|
isTextOverflowEllipsisDisabled,
|
||||||
|
}: RawLogViewProps): JSX.Element {
|
||||||
function RawLogView(props: RawLogViewProps): JSX.Element {
|
|
||||||
const { isActiveLog = false, isReadOnly = false, data, linesPerRow } = props;
|
|
||||||
|
|
||||||
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
|
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
|
||||||
data.id,
|
data.id,
|
||||||
);
|
);
|
||||||
@ -143,6 +139,7 @@ function RawLogView(props: RawLogViewProps): JSX.Element {
|
|||||||
<RawLogContent
|
<RawLogContent
|
||||||
$isReadOnly={isReadOnly}
|
$isReadOnly={isReadOnly}
|
||||||
$isActiveLog={isActiveLog}
|
$isActiveLog={isActiveLog}
|
||||||
|
$isTextOverflowEllipsisDisabled={isTextOverflowEllipsisDisabled}
|
||||||
linesPerRow={linesPerRow}
|
linesPerRow={linesPerRow}
|
||||||
dangerouslySetInnerHTML={html}
|
dangerouslySetInnerHTML={html}
|
||||||
/>
|
/>
|
||||||
@ -181,6 +178,7 @@ function RawLogView(props: RawLogViewProps): JSX.Element {
|
|||||||
RawLogView.defaultProps = {
|
RawLogView.defaultProps = {
|
||||||
isActiveLog: false,
|
isActiveLog: false,
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
|
isTextOverflowEllipsisDisabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RawLogView;
|
export default RawLogView;
|
||||||
|
@ -3,10 +3,12 @@ import { Col, Row, Space } from 'antd';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { getActiveLogBackground, getDefaultLogBackground } from 'utils/logs';
|
import { getActiveLogBackground, getDefaultLogBackground } from 'utils/logs';
|
||||||
|
|
||||||
|
import { RawLogContentProps } from './types';
|
||||||
|
|
||||||
export const RawLogViewContainer = styled(Row)<{
|
export const RawLogViewContainer = styled(Row)<{
|
||||||
$isDarkMode: boolean;
|
$isDarkMode: boolean;
|
||||||
$isReadOnly: boolean;
|
$isReadOnly?: boolean;
|
||||||
$isActiveLog: boolean;
|
$isActiveLog?: boolean;
|
||||||
}>`
|
}>`
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -31,32 +33,29 @@ export const ExpandIconWrapper = styled(Col)`
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface RawLogContentProps {
|
|
||||||
linesPerRow: number;
|
|
||||||
$isReadOnly: boolean;
|
|
||||||
$isActiveLog: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RawLogContent = styled.div<RawLogContentProps>`
|
export const RawLogContent = styled.div<RawLogContentProps>`
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
font-family: Fira Code, monospace;
|
font-family: Fira Code, monospace;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
|
|
||||||
overflow: hidden;
|
${({ $isTextOverflowEllipsisDisabled, linesPerRow }): string =>
|
||||||
text-overflow: ellipsis;
|
$isTextOverflowEllipsisDisabled
|
||||||
display: -webkit-box;
|
? 'white-space: nowrap'
|
||||||
-webkit-line-clamp: ${(props): number => props.linesPerRow};
|
: `overflow: hidden;
|
||||||
line-clamp: ${(props): number => props.linesPerRow};
|
text-overflow: ellipsis;
|
||||||
-webkit-box-orient: vertical;
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: ${linesPerRow};
|
||||||
|
line-clamp: ${linesPerRow};
|
||||||
|
-webkit-box-orient: vertical;`};
|
||||||
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 2rem;
|
line-height: 2rem;
|
||||||
|
|
||||||
cursor: ${(props): string =>
|
cursor: ${({ $isActiveLog, $isReadOnly }): string =>
|
||||||
props.$isActiveLog || props.$isReadOnly ? 'initial' : 'pointer'};
|
$isActiveLog || $isReadOnly ? 'initial' : 'pointer'};
|
||||||
|
|
||||||
${(props): string =>
|
${({ $isActiveLog, $isReadOnly }): string =>
|
||||||
props.$isReadOnly && !props.$isActiveLog ? 'padding: 0 1.5rem;' : ''}
|
$isReadOnly && $isActiveLog ? 'padding: 0 1.5rem;' : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ActionButtonsWrapper = styled(Space)`
|
export const ActionButtonsWrapper = styled(Space)`
|
||||||
|
16
frontend/src/components/Logs/RawLogView/types.ts
Normal file
16
frontend/src/components/Logs/RawLogView/types.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ILog } from 'types/api/logs/log';
|
||||||
|
|
||||||
|
export interface RawLogViewProps {
|
||||||
|
isActiveLog?: boolean;
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
isTextOverflowEllipsisDisabled?: boolean;
|
||||||
|
data: ILog;
|
||||||
|
linesPerRow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawLogContentProps {
|
||||||
|
linesPerRow: number;
|
||||||
|
$isReadOnly?: boolean;
|
||||||
|
$isActiveLog?: boolean;
|
||||||
|
$isTextOverflowEllipsisDisabled?: boolean;
|
||||||
|
}
|
@ -18,12 +18,24 @@ function TextToolTip({
|
|||||||
}: TextToolTipProps): JSX.Element {
|
}: TextToolTipProps): JSX.Element {
|
||||||
const isDarkMode = useIsDarkMode();
|
const isDarkMode = useIsDarkMode();
|
||||||
|
|
||||||
|
const onClickHandler = (
|
||||||
|
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
|
||||||
|
): void => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
const overlay = useMemo(
|
const overlay = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<div>
|
<div>
|
||||||
{`${text} `}
|
{`${text} `}
|
||||||
{url && (
|
{url && (
|
||||||
<a href={url} rel="noopener noreferrer" target="_blank">
|
<a
|
||||||
|
// Stopping event propagation on click so that parent click listener are not triggered
|
||||||
|
onClick={onClickHandler}
|
||||||
|
href={url}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
{urlText || 'here'}
|
{urlText || 'here'}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
@ -6,6 +6,7 @@ export enum FeatureKeys {
|
|||||||
ALERT_CHANNEL_SLACK = 'ALERT_CHANNEL_SLACK',
|
ALERT_CHANNEL_SLACK = 'ALERT_CHANNEL_SLACK',
|
||||||
ALERT_CHANNEL_WEBHOOK = 'ALERT_CHANNEL_WEBHOOK',
|
ALERT_CHANNEL_WEBHOOK = 'ALERT_CHANNEL_WEBHOOK',
|
||||||
ALERT_CHANNEL_PAGERDUTY = 'ALERT_CHANNEL_PAGERDUTY',
|
ALERT_CHANNEL_PAGERDUTY = 'ALERT_CHANNEL_PAGERDUTY',
|
||||||
|
ALERT_CHANNEL_OPSGENIE = 'ALERT_CHANNEL_OPSGENIE',
|
||||||
ALERT_CHANNEL_MSTEAMS = 'ALERT_CHANNEL_MSTEAMS',
|
ALERT_CHANNEL_MSTEAMS = 'ALERT_CHANNEL_MSTEAMS',
|
||||||
DurationSort = 'DurationSort',
|
DurationSort = 'DurationSort',
|
||||||
TimestampSort = 'TimestampSort',
|
TimestampSort = 'TimestampSort',
|
||||||
|
5
frontend/src/constants/liveTail.ts
Normal file
5
frontend/src/constants/liveTail.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const LIVE_TAIL_HEARTBEAT_TIMEOUT = 600000;
|
||||||
|
|
||||||
|
export const LIVE_TAIL_GRAPH_INTERVAL = 60000;
|
||||||
|
|
||||||
|
export const MAX_LOGS_LIST_SIZE = 1000;
|
@ -12,3 +12,8 @@ export const PANEL_TYPES_COMPONENT_MAP = {
|
|||||||
[PANEL_TYPES.LIST]: null,
|
[PANEL_TYPES.LIST]: null,
|
||||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const AVAILABLE_EXPORT_PANEL_TYPES = [
|
||||||
|
PANEL_TYPES.TIME_SERIES,
|
||||||
|
PANEL_TYPES.TABLE,
|
||||||
|
];
|
||||||
|
@ -6,6 +6,8 @@ type QueryParamNames =
|
|||||||
| 'selectedFields'
|
| 'selectedFields'
|
||||||
| 'linesPerRow';
|
| 'linesPerRow';
|
||||||
|
|
||||||
|
export type QuerySearchParamNames = 'viewName' | 'viewKey';
|
||||||
|
|
||||||
export const queryParamNamesMap: Record<QueryParamNames, QueryParamNames> = {
|
export const queryParamNamesMap: Record<QueryParamNames, QueryParamNames> = {
|
||||||
compositeQuery: 'compositeQuery',
|
compositeQuery: 'compositeQuery',
|
||||||
panelTypes: 'panelTypes',
|
panelTypes: 'panelTypes',
|
||||||
@ -14,3 +16,11 @@ export const queryParamNamesMap: Record<QueryParamNames, QueryParamNames> = {
|
|||||||
selectedFields: 'selectedFields',
|
selectedFields: 'selectedFields',
|
||||||
linesPerRow: 'linesPerRow',
|
linesPerRow: 'linesPerRow',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const querySearchParams: Record<
|
||||||
|
QuerySearchParamNames,
|
||||||
|
QuerySearchParamNames
|
||||||
|
> = {
|
||||||
|
viewName: 'viewName',
|
||||||
|
viewKey: 'viewKey',
|
||||||
|
};
|
||||||
|
@ -29,6 +29,7 @@ const ROUTES = {
|
|||||||
NOT_FOUND: '/not-found',
|
NOT_FOUND: '/not-found',
|
||||||
LOGS: '/logs',
|
LOGS: '/logs',
|
||||||
LOGS_EXPLORER: '/logs-explorer',
|
LOGS_EXPLORER: '/logs-explorer',
|
||||||
|
LIVE_LOGS: '/logs-explorer/live',
|
||||||
HOME_PAGE: '/',
|
HOME_PAGE: '/',
|
||||||
PASSWORD_RESET: '/password-reset',
|
PASSWORD_RESET: '/password-reset',
|
||||||
LIST_LICENSES: '/licenses',
|
LIST_LICENSES: '/licenses',
|
||||||
|
@ -52,6 +52,8 @@ const themeColors = {
|
|||||||
gamboge: '#D89614',
|
gamboge: '#D89614',
|
||||||
bckgGrey: '#1d1d1d',
|
bckgGrey: '#1d1d1d',
|
||||||
lightBlue: '#177ddc',
|
lightBlue: '#177ddc',
|
||||||
|
buttonSuccessRgb: '73, 170, 25',
|
||||||
|
red: '#E84749',
|
||||||
};
|
};
|
||||||
|
|
||||||
export { themeColors };
|
export { themeColors };
|
||||||
|
@ -40,6 +40,30 @@ export interface PagerChannel extends Channel {
|
|||||||
details?: string;
|
details?: string;
|
||||||
detailsArray?: Record<string, string>;
|
detailsArray?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpsgenieChannel configures alert manager to send
|
||||||
|
// events to opsgenie
|
||||||
|
export interface OpsgenieChannel extends Channel {
|
||||||
|
// ref: https://prometheus.io/docs/alerting/latest/configuration/#opsgenie_config
|
||||||
|
api_key: string;
|
||||||
|
|
||||||
|
message?: string;
|
||||||
|
|
||||||
|
// A description of the incident
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// A backlink to the sender of the notification.
|
||||||
|
source?: string;
|
||||||
|
|
||||||
|
// A set of arbitrary key/value pairs that provide further detail
|
||||||
|
// about the alert.
|
||||||
|
details?: string;
|
||||||
|
detailsArray?: Record<string, string>;
|
||||||
|
|
||||||
|
// Priority level of alert. Possible values are P1, P2, P3, P4, and P5.
|
||||||
|
priority?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const ValidatePagerChannel = (p: PagerChannel): string => {
|
export const ValidatePagerChannel = (p: PagerChannel): string => {
|
||||||
if (!p) {
|
if (!p) {
|
||||||
return 'Received unexpected input for this channel, please contact your administrator ';
|
return 'Received unexpected input for this channel, please contact your administrator ';
|
||||||
@ -63,16 +87,14 @@ export const ValidatePagerChannel = (p: PagerChannel): string => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelType =
|
export enum ChannelType {
|
||||||
| 'slack'
|
Slack = 'slack',
|
||||||
| 'email'
|
Email = 'email',
|
||||||
| 'webhook'
|
Webhook = 'webhook',
|
||||||
| 'pagerduty'
|
Pagerduty = 'pagerduty',
|
||||||
| 'msteams';
|
Opsgenie = 'opsgenie',
|
||||||
export const SlackType: ChannelType = 'slack';
|
MsTeams = 'msteams',
|
||||||
export const WebhookType: ChannelType = 'webhook';
|
}
|
||||||
export const PagerType: ChannelType = 'pagerduty';
|
|
||||||
export const MsTeamsType: ChannelType = 'msteams';
|
|
||||||
|
|
||||||
// LabelFilterStatement will be used for preparing filter conditions / matchers
|
// LabelFilterStatement will be used for preparing filter conditions / matchers
|
||||||
export interface LabelFilterStatement {
|
export interface LabelFilterStatement {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PagerChannel } from './config';
|
import { OpsgenieChannel, PagerChannel } from './config';
|
||||||
|
|
||||||
export const PagerInitialConfig: Partial<PagerChannel> = {
|
export const PagerInitialConfig: Partial<PagerChannel> = {
|
||||||
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
||||||
@ -22,3 +22,31 @@ export const PagerInitialConfig: Partial<PagerChannel> = {
|
|||||||
num_resolved: '{{ .Alerts.Resolved | len }}',
|
num_resolved: '{{ .Alerts.Resolved | len }}',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
|
||||||
|
message: '{{ .CommonLabels.alertname }}',
|
||||||
|
description: `{{ if gt (len .Alerts.Firing) 0 -}}
|
||||||
|
Alerts Firing:
|
||||||
|
{{ range .Alerts.Firing }}
|
||||||
|
- Message: {{ .Annotations.description }}
|
||||||
|
Labels:
|
||||||
|
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
|
||||||
|
{{ end }} Annotations:
|
||||||
|
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
|
||||||
|
{{ end }} Source: {{ .GeneratorURL }}
|
||||||
|
{{ end }}
|
||||||
|
{{- end }}
|
||||||
|
{{ if gt (len .Alerts.Resolved) 0 -}}
|
||||||
|
Alerts Resolved:
|
||||||
|
{{ range .Alerts.Resolved }}
|
||||||
|
- Message: {{ .Annotations.description }}
|
||||||
|
Labels:
|
||||||
|
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
|
||||||
|
{{ end }} Annotations:
|
||||||
|
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
|
||||||
|
{{ end }} Source: {{ .GeneratorURL }}
|
||||||
|
{{ end }}
|
||||||
|
{{- end }}`,
|
||||||
|
priority:
|
||||||
|
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
|
||||||
|
};
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import createMsTeamsApi from 'api/channels/createMsTeams';
|
import createMsTeamsApi from 'api/channels/createMsTeams';
|
||||||
|
import createOpsgenie from 'api/channels/createOpsgenie';
|
||||||
import createPagerApi from 'api/channels/createPager';
|
import createPagerApi from 'api/channels/createPager';
|
||||||
import createSlackApi from 'api/channels/createSlack';
|
import createSlackApi from 'api/channels/createSlack';
|
||||||
import createWebhookApi from 'api/channels/createWebhook';
|
import createWebhookApi from 'api/channels/createWebhook';
|
||||||
import testMsTeamsApi from 'api/channels/testMsTeams';
|
import testMsTeamsApi from 'api/channels/testMsTeams';
|
||||||
|
import testOpsGenie from 'api/channels/testOpsgenie';
|
||||||
import testPagerApi from 'api/channels/testPager';
|
import testPagerApi from 'api/channels/testPager';
|
||||||
import testSlackApi from 'api/channels/testSlack';
|
import testSlackApi from 'api/channels/testSlack';
|
||||||
import testWebhookApi from 'api/channels/testWebhook';
|
import testWebhookApi from 'api/channels/testWebhook';
|
||||||
@ -17,19 +19,17 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import {
|
import {
|
||||||
ChannelType,
|
ChannelType,
|
||||||
MsTeamsChannel,
|
MsTeamsChannel,
|
||||||
MsTeamsType,
|
OpsgenieChannel,
|
||||||
PagerChannel,
|
PagerChannel,
|
||||||
PagerType,
|
|
||||||
SlackChannel,
|
SlackChannel,
|
||||||
SlackType,
|
|
||||||
ValidatePagerChannel,
|
ValidatePagerChannel,
|
||||||
WebhookChannel,
|
WebhookChannel,
|
||||||
WebhookType,
|
|
||||||
} from './config';
|
} from './config';
|
||||||
import { PagerInitialConfig } from './defaults';
|
import { OpsgenieInitialConfig, PagerInitialConfig } from './defaults';
|
||||||
|
import { isChannelType } from './utils';
|
||||||
|
|
||||||
function CreateAlertChannels({
|
function CreateAlertChannels({
|
||||||
preType = 'slack',
|
preType = ChannelType.Slack,
|
||||||
}: CreateAlertChannelsProps): JSX.Element {
|
}: CreateAlertChannelsProps): JSX.Element {
|
||||||
// init namespace for translations
|
// init namespace for translations
|
||||||
const { t } = useTranslation('channels');
|
const { t } = useTranslation('channels');
|
||||||
@ -37,7 +37,13 @@ function CreateAlertChannels({
|
|||||||
const [formInstance] = Form.useForm();
|
const [formInstance] = Form.useForm();
|
||||||
|
|
||||||
const [selectedConfig, setSelectedConfig] = useState<
|
const [selectedConfig, setSelectedConfig] = useState<
|
||||||
Partial<SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel>
|
Partial<
|
||||||
|
SlackChannel &
|
||||||
|
WebhookChannel &
|
||||||
|
PagerChannel &
|
||||||
|
MsTeamsChannel &
|
||||||
|
OpsgenieChannel
|
||||||
|
>
|
||||||
>({
|
>({
|
||||||
text: `{{ range .Alerts -}}
|
text: `{{ range .Alerts -}}
|
||||||
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
|
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
|
||||||
@ -71,7 +77,7 @@ function CreateAlertChannels({
|
|||||||
const currentType = type;
|
const currentType = type;
|
||||||
setType(value as ChannelType);
|
setType(value as ChannelType);
|
||||||
|
|
||||||
if (value === PagerType && currentType !== value) {
|
if (value === ChannelType.Pagerduty && currentType !== value) {
|
||||||
// reset config to pager defaults
|
// reset config to pager defaults
|
||||||
setSelectedConfig({
|
setSelectedConfig({
|
||||||
name: selectedConfig?.name,
|
name: selectedConfig?.name,
|
||||||
@ -79,6 +85,13 @@ function CreateAlertChannels({
|
|||||||
...PagerInitialConfig,
|
...PagerInitialConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value === ChannelType.Opsgenie && currentType !== value) {
|
||||||
|
setSelectedConfig((selectedConfig) => ({
|
||||||
|
...selectedConfig,
|
||||||
|
...OpsgenieInitialConfig,
|
||||||
|
}));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[type, selectedConfig],
|
[type, selectedConfig],
|
||||||
);
|
);
|
||||||
@ -239,6 +252,45 @@ function CreateAlertChannels({
|
|||||||
setSavingState(false);
|
setSavingState(false);
|
||||||
}, [t, notifications, preparePagerRequest]);
|
}, [t, notifications, preparePagerRequest]);
|
||||||
|
|
||||||
|
const prepareOpsgenieRequest = useCallback(
|
||||||
|
() => ({
|
||||||
|
api_key: selectedConfig?.api_key || '',
|
||||||
|
name: selectedConfig?.name || '',
|
||||||
|
send_resolved: true,
|
||||||
|
description: selectedConfig?.description || '',
|
||||||
|
message: selectedConfig?.message || '',
|
||||||
|
priority: selectedConfig?.priority || '',
|
||||||
|
}),
|
||||||
|
[selectedConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onOpsgenieHandler = useCallback(async () => {
|
||||||
|
setSavingState(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await createOpsgenie(prepareOpsgenieRequest());
|
||||||
|
|
||||||
|
if (response.statusCode === 200) {
|
||||||
|
notifications.success({
|
||||||
|
message: 'Success',
|
||||||
|
description: t('channel_creation_done'),
|
||||||
|
});
|
||||||
|
history.replace(ROUTES.ALL_CHANNELS);
|
||||||
|
} else {
|
||||||
|
notifications.error({
|
||||||
|
message: 'Error',
|
||||||
|
description: response.error || t('channel_creation_failed'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error({
|
||||||
|
message: 'Error',
|
||||||
|
description: t('channel_creation_failed'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSavingState(false);
|
||||||
|
}, [prepareOpsgenieRequest, t, notifications]);
|
||||||
|
|
||||||
const prepareMsTeamsRequest = useCallback(
|
const prepareMsTeamsRequest = useCallback(
|
||||||
() => ({
|
() => ({
|
||||||
webhook_url: selectedConfig?.webhook_url || '',
|
webhook_url: selectedConfig?.webhook_url || '',
|
||||||
@ -280,26 +332,31 @@ function CreateAlertChannels({
|
|||||||
const onSaveHandler = useCallback(
|
const onSaveHandler = useCallback(
|
||||||
async (value: ChannelType) => {
|
async (value: ChannelType) => {
|
||||||
const functionMapper = {
|
const functionMapper = {
|
||||||
[SlackType]: onSlackHandler,
|
[ChannelType.Slack]: onSlackHandler,
|
||||||
[WebhookType]: onWebhookHandler,
|
[ChannelType.Webhook]: onWebhookHandler,
|
||||||
[PagerType]: onPagerHandler,
|
[ChannelType.Pagerduty]: onPagerHandler,
|
||||||
[MsTeamsType]: onMsTeamsHandler,
|
[ChannelType.Opsgenie]: onOpsgenieHandler,
|
||||||
|
[ChannelType.MsTeams]: onMsTeamsHandler,
|
||||||
};
|
};
|
||||||
const functionToCall = functionMapper[value];
|
|
||||||
|
|
||||||
if (functionToCall) {
|
if (isChannelType(value)) {
|
||||||
functionToCall();
|
const functionToCall = functionMapper[value as keyof typeof functionMapper];
|
||||||
} else {
|
|
||||||
notifications.error({
|
if (functionToCall) {
|
||||||
message: 'Error',
|
functionToCall();
|
||||||
description: t('selected_channel_invalid'),
|
} else {
|
||||||
});
|
notifications.error({
|
||||||
|
message: 'Error',
|
||||||
|
description: t('selected_channel_invalid'),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
onSlackHandler,
|
onSlackHandler,
|
||||||
onWebhookHandler,
|
onWebhookHandler,
|
||||||
onPagerHandler,
|
onPagerHandler,
|
||||||
|
onOpsgenieHandler,
|
||||||
onMsTeamsHandler,
|
onMsTeamsHandler,
|
||||||
notifications,
|
notifications,
|
||||||
t,
|
t,
|
||||||
@ -313,22 +370,26 @@ function CreateAlertChannels({
|
|||||||
let request;
|
let request;
|
||||||
let response;
|
let response;
|
||||||
switch (channelType) {
|
switch (channelType) {
|
||||||
case WebhookType:
|
case ChannelType.Webhook:
|
||||||
request = prepareWebhookRequest();
|
request = prepareWebhookRequest();
|
||||||
response = await testWebhookApi(request);
|
response = await testWebhookApi(request);
|
||||||
break;
|
break;
|
||||||
case SlackType:
|
case ChannelType.Slack:
|
||||||
request = prepareSlackRequest();
|
request = prepareSlackRequest();
|
||||||
response = await testSlackApi(request);
|
response = await testSlackApi(request);
|
||||||
break;
|
break;
|
||||||
case PagerType:
|
case ChannelType.Pagerduty:
|
||||||
request = preparePagerRequest();
|
request = preparePagerRequest();
|
||||||
if (request) response = await testPagerApi(request);
|
if (request) response = await testPagerApi(request);
|
||||||
break;
|
break;
|
||||||
case MsTeamsType:
|
case ChannelType.MsTeams:
|
||||||
request = prepareMsTeamsRequest();
|
request = prepareMsTeamsRequest();
|
||||||
response = await testMsTeamsApi(request);
|
response = await testMsTeamsApi(request);
|
||||||
break;
|
break;
|
||||||
|
case ChannelType.Opsgenie:
|
||||||
|
request = prepareOpsgenieRequest();
|
||||||
|
response = await testOpsGenie(request);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
notifications.error({
|
notifications.error({
|
||||||
message: 'Error',
|
message: 'Error',
|
||||||
@ -361,6 +422,7 @@ function CreateAlertChannels({
|
|||||||
prepareWebhookRequest,
|
prepareWebhookRequest,
|
||||||
t,
|
t,
|
||||||
preparePagerRequest,
|
preparePagerRequest,
|
||||||
|
prepareOpsgenieRequest,
|
||||||
prepareSlackRequest,
|
prepareSlackRequest,
|
||||||
prepareMsTeamsRequest,
|
prepareMsTeamsRequest,
|
||||||
notifications,
|
notifications,
|
||||||
@ -390,6 +452,7 @@ function CreateAlertChannels({
|
|||||||
type,
|
type,
|
||||||
...selectedConfig,
|
...selectedConfig,
|
||||||
...PagerInitialConfig,
|
...PagerInitialConfig,
|
||||||
|
...OpsgenieInitialConfig,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
4
frontend/src/container/CreateAlertChannels/utils.ts
Normal file
4
frontend/src/container/CreateAlertChannels/utils.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { ChannelType } from './config';
|
||||||
|
|
||||||
|
export const isChannelType = (type: string): type is ChannelType =>
|
||||||
|
Object.values(ChannelType).includes(type as ChannelType);
|
@ -1,9 +1,11 @@
|
|||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import editMsTeamsApi from 'api/channels/editMsTeams';
|
import editMsTeamsApi from 'api/channels/editMsTeams';
|
||||||
|
import editOpsgenie from 'api/channels/editOpsgenie';
|
||||||
import editPagerApi from 'api/channels/editPager';
|
import editPagerApi from 'api/channels/editPager';
|
||||||
import editSlackApi from 'api/channels/editSlack';
|
import editSlackApi from 'api/channels/editSlack';
|
||||||
import editWebhookApi from 'api/channels/editWebhook';
|
import editWebhookApi from 'api/channels/editWebhook';
|
||||||
import testMsTeamsApi from 'api/channels/testMsTeams';
|
import testMsTeamsApi from 'api/channels/testMsTeams';
|
||||||
|
import testOpsgenie from 'api/channels/testOpsgenie';
|
||||||
import testPagerApi from 'api/channels/testPager';
|
import testPagerApi from 'api/channels/testPager';
|
||||||
import testSlackApi from 'api/channels/testSlack';
|
import testSlackApi from 'api/channels/testSlack';
|
||||||
import testWebhookApi from 'api/channels/testWebhook';
|
import testWebhookApi from 'api/channels/testWebhook';
|
||||||
@ -11,14 +13,11 @@ import ROUTES from 'constants/routes';
|
|||||||
import {
|
import {
|
||||||
ChannelType,
|
ChannelType,
|
||||||
MsTeamsChannel,
|
MsTeamsChannel,
|
||||||
MsTeamsType,
|
OpsgenieChannel,
|
||||||
PagerChannel,
|
PagerChannel,
|
||||||
PagerType,
|
|
||||||
SlackChannel,
|
SlackChannel,
|
||||||
SlackType,
|
|
||||||
ValidatePagerChannel,
|
ValidatePagerChannel,
|
||||||
WebhookChannel,
|
WebhookChannel,
|
||||||
WebhookType,
|
|
||||||
} from 'container/CreateAlertChannels/config';
|
} from 'container/CreateAlertChannels/config';
|
||||||
import FormAlertChannels from 'container/FormAlertChannels';
|
import FormAlertChannels from 'container/FormAlertChannels';
|
||||||
import { useNotifications } from 'hooks/useNotifications';
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
@ -35,7 +34,13 @@ function EditAlertChannels({
|
|||||||
|
|
||||||
const [formInstance] = Form.useForm();
|
const [formInstance] = Form.useForm();
|
||||||
const [selectedConfig, setSelectedConfig] = useState<
|
const [selectedConfig, setSelectedConfig] = useState<
|
||||||
Partial<SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel>
|
Partial<
|
||||||
|
SlackChannel &
|
||||||
|
WebhookChannel &
|
||||||
|
PagerChannel &
|
||||||
|
MsTeamsChannel &
|
||||||
|
OpsgenieChannel
|
||||||
|
>
|
||||||
>({
|
>({
|
||||||
...initialValue,
|
...initialValue,
|
||||||
});
|
});
|
||||||
@ -45,7 +50,7 @@ function EditAlertChannels({
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
const [type, setType] = useState<ChannelType>(
|
const [type, setType] = useState<ChannelType>(
|
||||||
initialValue?.type ? (initialValue.type as ChannelType) : SlackType,
|
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onTypeChangeHandler = useCallback((value: string) => {
|
const onTypeChangeHandler = useCallback((value: string) => {
|
||||||
@ -193,6 +198,48 @@ function EditAlertChannels({
|
|||||||
setSavingState(false);
|
setSavingState(false);
|
||||||
}, [preparePagerRequest, notifications, selectedConfig, t]);
|
}, [preparePagerRequest, notifications, selectedConfig, t]);
|
||||||
|
|
||||||
|
const prepareOpsgenieRequest = useCallback(
|
||||||
|
() => ({
|
||||||
|
name: selectedConfig.name || '',
|
||||||
|
api_key: selectedConfig.api_key || '',
|
||||||
|
message: selectedConfig.message || '',
|
||||||
|
description: selectedConfig.description || '',
|
||||||
|
priority: selectedConfig.priority || '',
|
||||||
|
id,
|
||||||
|
}),
|
||||||
|
[id, selectedConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onOpsgenieEditHandler = useCallback(async () => {
|
||||||
|
setSavingState(true);
|
||||||
|
|
||||||
|
if (selectedConfig?.api_key === '') {
|
||||||
|
notifications.error({
|
||||||
|
message: 'Error',
|
||||||
|
description: t('api_key_required'),
|
||||||
|
});
|
||||||
|
setSavingState(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await editOpsgenie(prepareOpsgenieRequest());
|
||||||
|
|
||||||
|
if (response.statusCode === 200) {
|
||||||
|
notifications.success({
|
||||||
|
message: 'Success',
|
||||||
|
description: t('channel_edit_done'),
|
||||||
|
});
|
||||||
|
|
||||||
|
history.replace(ROUTES.ALL_CHANNELS);
|
||||||
|
} else {
|
||||||
|
notifications.error({
|
||||||
|
message: 'Error',
|
||||||
|
description: response.error || t('channel_edit_failed'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSavingState(false);
|
||||||
|
}, [prepareOpsgenieRequest, t, notifications, selectedConfig]);
|
||||||
|
|
||||||
const prepareMsTeamsRequest = useCallback(
|
const prepareMsTeamsRequest = useCallback(
|
||||||
() => ({
|
() => ({
|
||||||
webhook_url: selectedConfig?.webhook_url || '',
|
webhook_url: selectedConfig?.webhook_url || '',
|
||||||
@ -237,14 +284,16 @@ function EditAlertChannels({
|
|||||||
|
|
||||||
const onSaveHandler = useCallback(
|
const onSaveHandler = useCallback(
|
||||||
(value: ChannelType) => {
|
(value: ChannelType) => {
|
||||||
if (value === SlackType) {
|
if (value === ChannelType.Slack) {
|
||||||
onSlackEditHandler();
|
onSlackEditHandler();
|
||||||
} else if (value === WebhookType) {
|
} else if (value === ChannelType.Webhook) {
|
||||||
onWebhookEditHandler();
|
onWebhookEditHandler();
|
||||||
} else if (value === PagerType) {
|
} else if (value === ChannelType.Pagerduty) {
|
||||||
onPagerEditHandler();
|
onPagerEditHandler();
|
||||||
} else if (value === MsTeamsType) {
|
} else if (value === ChannelType.MsTeams) {
|
||||||
onMsTeamsEditHandler();
|
onMsTeamsEditHandler();
|
||||||
|
} else if (value === ChannelType.Opsgenie) {
|
||||||
|
onOpsgenieEditHandler();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@ -252,6 +301,7 @@ function EditAlertChannels({
|
|||||||
onWebhookEditHandler,
|
onWebhookEditHandler,
|
||||||
onPagerEditHandler,
|
onPagerEditHandler,
|
||||||
onMsTeamsEditHandler,
|
onMsTeamsEditHandler,
|
||||||
|
onOpsgenieEditHandler,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -262,22 +312,26 @@ function EditAlertChannels({
|
|||||||
let request;
|
let request;
|
||||||
let response;
|
let response;
|
||||||
switch (channelType) {
|
switch (channelType) {
|
||||||
case WebhookType:
|
case ChannelType.Webhook:
|
||||||
request = prepareWebhookRequest();
|
request = prepareWebhookRequest();
|
||||||
response = await testWebhookApi(request);
|
response = await testWebhookApi(request);
|
||||||
break;
|
break;
|
||||||
case SlackType:
|
case ChannelType.Slack:
|
||||||
request = prepareSlackRequest();
|
request = prepareSlackRequest();
|
||||||
response = await testSlackApi(request);
|
response = await testSlackApi(request);
|
||||||
break;
|
break;
|
||||||
case PagerType:
|
case ChannelType.Pagerduty:
|
||||||
request = preparePagerRequest();
|
request = preparePagerRequest();
|
||||||
if (request) response = await testPagerApi(request);
|
if (request) response = await testPagerApi(request);
|
||||||
break;
|
break;
|
||||||
case MsTeamsType:
|
case ChannelType.MsTeams:
|
||||||
request = prepareMsTeamsRequest();
|
request = prepareMsTeamsRequest();
|
||||||
if (request) response = await testMsTeamsApi(request);
|
if (request) response = await testMsTeamsApi(request);
|
||||||
break;
|
break;
|
||||||
|
case ChannelType.Opsgenie:
|
||||||
|
request = prepareOpsgenieRequest();
|
||||||
|
if (request) response = await testOpsgenie(request);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
notifications.error({
|
notifications.error({
|
||||||
message: 'Error',
|
message: 'Error',
|
||||||
@ -312,6 +366,7 @@ function EditAlertChannels({
|
|||||||
preparePagerRequest,
|
preparePagerRequest,
|
||||||
prepareSlackRequest,
|
prepareSlackRequest,
|
||||||
prepareMsTeamsRequest,
|
prepareMsTeamsRequest,
|
||||||
|
prepareOpsgenieRequest,
|
||||||
notifications,
|
notifications,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,74 @@
|
|||||||
|
import { Form, Input } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { OpsgenieChannel } from '../../CreateAlertChannels/config';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
|
||||||
|
const { t } = useTranslation('channels');
|
||||||
|
|
||||||
|
const handleInputChange = (field: string) => (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
|
): void => {
|
||||||
|
setSelectedConfig((value) => ({
|
||||||
|
...value,
|
||||||
|
[field]: event.target.value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item name="api_key" label={t('field_opsgenie_api_key')} required>
|
||||||
|
<Input onChange={handleInputChange('api_key')} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="message"
|
||||||
|
help={t('help_opsgenie_message')}
|
||||||
|
label={t('field_opsgenie_message')}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
onChange={handleInputChange('message')}
|
||||||
|
placeholder={t('placeholder_opsgenie_message')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="description"
|
||||||
|
help={t('help_opsgenie_description')}
|
||||||
|
label={t('field_opsgenie_description')}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
onChange={handleInputChange('description')}
|
||||||
|
placeholder={t('placeholder_opsgenie_description')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="priority"
|
||||||
|
help={t('help_opsgenie_priority')}
|
||||||
|
label={t('field_opsgenie_priority')}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
onChange={handleInputChange('priority')}
|
||||||
|
placeholder={t('placeholder_opsgenie_priority')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpsgenieFormProps {
|
||||||
|
setSelectedConfig: React.Dispatch<
|
||||||
|
React.SetStateAction<Partial<OpsgenieChannel>>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OpsgenieForm;
|
@ -5,13 +5,10 @@ import { FeatureKeys } from 'constants/features';
|
|||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import {
|
import {
|
||||||
ChannelType,
|
ChannelType,
|
||||||
MsTeamsType,
|
OpsgenieChannel,
|
||||||
PagerChannel,
|
PagerChannel,
|
||||||
PagerType,
|
|
||||||
SlackChannel,
|
SlackChannel,
|
||||||
SlackType,
|
|
||||||
WebhookChannel,
|
WebhookChannel,
|
||||||
WebhookType,
|
|
||||||
} from 'container/CreateAlertChannels/config';
|
} from 'container/CreateAlertChannels/config';
|
||||||
import useFeatureFlags from 'hooks/useFeatureFlag';
|
import useFeatureFlags from 'hooks/useFeatureFlag';
|
||||||
import { isFeatureKeys } from 'hooks/useFeatureFlag/utils';
|
import { isFeatureKeys } from 'hooks/useFeatureFlag/utils';
|
||||||
@ -20,6 +17,7 @@ import { Dispatch, ReactElement, SetStateAction } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import MsTeamsSettings from './Settings/MsTeams';
|
import MsTeamsSettings from './Settings/MsTeams';
|
||||||
|
import OpsgenieSettings from './Settings/Opsgenie';
|
||||||
import PagerSettings from './Settings/Pager';
|
import PagerSettings from './Settings/Pager';
|
||||||
import SlackSettings from './Settings/Slack';
|
import SlackSettings from './Settings/Slack';
|
||||||
import WebhookSettings from './Settings/Webhook';
|
import WebhookSettings from './Settings/Webhook';
|
||||||
@ -61,14 +59,16 @@ function FormAlertChannels({
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SlackType:
|
case ChannelType.Slack:
|
||||||
return <SlackSettings setSelectedConfig={setSelectedConfig} />;
|
return <SlackSettings setSelectedConfig={setSelectedConfig} />;
|
||||||
case WebhookType:
|
case ChannelType.Webhook:
|
||||||
return <WebhookSettings setSelectedConfig={setSelectedConfig} />;
|
return <WebhookSettings setSelectedConfig={setSelectedConfig} />;
|
||||||
case PagerType:
|
case ChannelType.Pagerduty:
|
||||||
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
|
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
|
||||||
case MsTeamsType:
|
case ChannelType.MsTeams:
|
||||||
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
|
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
|
||||||
|
case ChannelType.Opsgenie:
|
||||||
|
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -102,6 +102,9 @@ function FormAlertChannels({
|
|||||||
<Select.Option value="pagerduty" key="pagerduty">
|
<Select.Option value="pagerduty" key="pagerduty">
|
||||||
Pagerduty
|
Pagerduty
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
|
<Select.Option value="opsgenie" key="opsgenie">
|
||||||
|
Opsgenie
|
||||||
|
</Select.Option>
|
||||||
{!isOssFeature?.active && (
|
{!isOssFeature?.active && (
|
||||||
<Select.Option value="msteams" key="msteams">
|
<Select.Option value="msteams" key="msteams">
|
||||||
<div>
|
<div>
|
||||||
@ -147,7 +150,9 @@ interface FormAlertChannelsProps {
|
|||||||
formInstance: FormInstance;
|
formInstance: FormInstance;
|
||||||
type: ChannelType;
|
type: ChannelType;
|
||||||
setSelectedConfig: Dispatch<
|
setSelectedConfig: Dispatch<
|
||||||
SetStateAction<Partial<SlackChannel & WebhookChannel & PagerChannel>>
|
SetStateAction<
|
||||||
|
Partial<SlackChannel & WebhookChannel & PagerChannel & OpsgenieChannel>
|
||||||
|
>
|
||||||
>;
|
>;
|
||||||
onTypeChangeHandler: (value: ChannelType) => void;
|
onTypeChangeHandler: (value: ChannelType) => void;
|
||||||
onSaveHandler: (props: ChannelType) => void;
|
onSaveHandler: (props: ChannelType) => void;
|
||||||
|
@ -134,7 +134,7 @@ function GridCardGraph({
|
|||||||
return (
|
return (
|
||||||
<span ref={graphRef}>
|
<span ref={graphRef}>
|
||||||
<WidgetGraphComponent
|
<WidgetGraphComponent
|
||||||
enableModel={false}
|
enableModel
|
||||||
enableWidgetHeader
|
enableWidgetHeader
|
||||||
widget={widget}
|
widget={widget}
|
||||||
queryResponse={queryResponse}
|
queryResponse={queryResponse}
|
||||||
|
55
frontend/src/container/LiveLogs/BackButton/index.tsx
Normal file
55
frontend/src/container/LiveLogs/BackButton/index.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import {
|
||||||
|
initialQueryBuilderFormValuesMap,
|
||||||
|
PANEL_TYPES,
|
||||||
|
} from 'constants/queryBuilder';
|
||||||
|
import { queryParamNamesMap } from 'constants/queryBuilderQueryNames';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
import { constructCompositeQuery } from '../constants';
|
||||||
|
|
||||||
|
function BackButton(): JSX.Element {
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const { updateAllQueriesOperators, resetQuery } = useQueryBuilder();
|
||||||
|
|
||||||
|
const compositeQuery = useGetCompositeQueryParam();
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (!compositeQuery) return;
|
||||||
|
|
||||||
|
const nextCompositeQuery = constructCompositeQuery({
|
||||||
|
query: compositeQuery,
|
||||||
|
initialQueryData: initialQueryBuilderFormValuesMap.logs,
|
||||||
|
customQueryData: { disabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedQuery = updateAllQueriesOperators(
|
||||||
|
nextCompositeQuery,
|
||||||
|
PANEL_TYPES.LIST,
|
||||||
|
DataSource.LOGS,
|
||||||
|
);
|
||||||
|
|
||||||
|
resetQuery(updatedQuery);
|
||||||
|
|
||||||
|
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(updatedQuery));
|
||||||
|
|
||||||
|
const path = `${ROUTES.LOGS_EXPLORER}?${queryParamNamesMap.compositeQuery}=${JSONCompositeQuery}`;
|
||||||
|
|
||||||
|
history.push(path);
|
||||||
|
}, [history, compositeQuery, resetQuery, updateAllQueriesOperators]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
|
||||||
|
Exit live view
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BackButton;
|
78
frontend/src/container/LiveLogs/FiltersInput/index.tsx
Normal file
78
frontend/src/container/LiveLogs/FiltersInput/index.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { Col } from 'antd';
|
||||||
|
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useEventSource } from 'providers/EventSource';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
IBuilderQuery,
|
||||||
|
Query,
|
||||||
|
TagFilter,
|
||||||
|
} from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import { getQueryWithoutFilterId } from '../utils';
|
||||||
|
import {
|
||||||
|
ContainerStyled,
|
||||||
|
FilterSearchInputStyled,
|
||||||
|
SearchButtonStyled,
|
||||||
|
} from './styles';
|
||||||
|
|
||||||
|
function FiltersInput(): JSX.Element {
|
||||||
|
const {
|
||||||
|
stagedQuery,
|
||||||
|
handleSetQueryData,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
currentQuery,
|
||||||
|
} = useQueryBuilder();
|
||||||
|
|
||||||
|
const { initialLoading, handleSetInitialLoading } = useEventSource();
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(filters: TagFilter) => {
|
||||||
|
const listQueryData = stagedQuery?.builder.queryData[0];
|
||||||
|
|
||||||
|
if (!listQueryData) return;
|
||||||
|
|
||||||
|
const queryData: IBuilderQuery = {
|
||||||
|
...listQueryData,
|
||||||
|
filters,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSetQueryData(0, queryData);
|
||||||
|
},
|
||||||
|
[stagedQuery, handleSetQueryData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useMemo(() => {
|
||||||
|
if (stagedQuery && stagedQuery.builder.queryData.length > 0) {
|
||||||
|
return stagedQuery?.builder.queryData[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialQueriesMap.logs.builder.queryData[0];
|
||||||
|
}, [stagedQuery]);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
if (initialLoading) {
|
||||||
|
handleSetInitialLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preparedQuery: Query = getQueryWithoutFilterId(currentQuery);
|
||||||
|
|
||||||
|
redirectWithQueryBuilderData(preparedQuery);
|
||||||
|
}, [
|
||||||
|
initialLoading,
|
||||||
|
currentQuery,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
handleSetInitialLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContainerStyled>
|
||||||
|
<Col flex={1}>
|
||||||
|
<FilterSearchInputStyled query={query} onChange={handleChange} />
|
||||||
|
</Col>
|
||||||
|
<SearchButtonStyled onSearch={handleSearch} />
|
||||||
|
</ContainerStyled>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FiltersInput;
|
24
frontend/src/container/LiveLogs/FiltersInput/styles.ts
Normal file
24
frontend/src/container/LiveLogs/FiltersInput/styles.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Input, Row } from 'antd';
|
||||||
|
import { themeColors } from 'constants/theme';
|
||||||
|
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
export const FilterSearchInputStyled = styled(QueryBuilderSearch)`
|
||||||
|
z-index: 1;
|
||||||
|
.ant-select-selector {
|
||||||
|
width: 100%;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ContainerStyled = styled(Row)`
|
||||||
|
color: ${themeColors.white};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SearchButtonStyled = styled(Input.Search)`
|
||||||
|
width: 2rem;
|
||||||
|
.ant-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
69
frontend/src/container/LiveLogs/ListViewPanel/index.tsx
Normal file
69
frontend/src/container/LiveLogs/ListViewPanel/index.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Button, Popover, Select } from 'antd';
|
||||||
|
import Spinner from 'components/Spinner';
|
||||||
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
|
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||||
|
import {
|
||||||
|
defaultSelectStyle,
|
||||||
|
logsOptions,
|
||||||
|
viewModeOptionList,
|
||||||
|
} from 'pages/Logs/config';
|
||||||
|
import PopoverContent from 'pages/Logs/PopoverContent';
|
||||||
|
import { useEventSource } from 'providers/EventSource';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
import { SpinnerWrapper, Wrapper } from './styles';
|
||||||
|
|
||||||
|
function ListViewPanel(): JSX.Element {
|
||||||
|
const { config } = useOptionsMenu({
|
||||||
|
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||||
|
dataSource: DataSource.LOGS,
|
||||||
|
aggregateOperator: StringOperators.NOOP,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isConnectionLoading } = useEventSource();
|
||||||
|
|
||||||
|
const isFormatButtonVisible = logsOptions.includes(config.format?.value);
|
||||||
|
|
||||||
|
const renderPopoverContent = useCallback(() => {
|
||||||
|
if (!config.maxLines) return null;
|
||||||
|
const linedPerRow = config.maxLines.value as number;
|
||||||
|
const handleLinesPerRowChange = config.maxLines.onChange as (
|
||||||
|
value: unknown,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverContent
|
||||||
|
linesPerRow={linedPerRow}
|
||||||
|
handleLinesPerRowChange={handleLinesPerRowChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Select
|
||||||
|
style={defaultSelectStyle}
|
||||||
|
value={config.format?.value}
|
||||||
|
onChange={config.format?.onChange}
|
||||||
|
>
|
||||||
|
{viewModeOptionList.map((option) => (
|
||||||
|
<Select.Option key={option.value}>{option.label}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{isFormatButtonVisible && (
|
||||||
|
<Popover placement="right" content={renderPopoverContent}>
|
||||||
|
<Button>Format</Button>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
{isConnectionLoading && (
|
||||||
|
<SpinnerWrapper>
|
||||||
|
<Spinner style={{ height: 'auto' }} />
|
||||||
|
</SpinnerWrapper>
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ListViewPanel;
|
11
frontend/src/container/LiveLogs/ListViewPanel/styles.ts
Normal file
11
frontend/src/container/LiveLogs/ListViewPanel/styles.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
export const Wrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SpinnerWrapper = styled.div`
|
||||||
|
margin-left: auto;
|
||||||
|
`;
|
203
frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx
Normal file
203
frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import { Col } from 'antd';
|
||||||
|
import Spinner from 'components/Spinner';
|
||||||
|
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { themeColors } from 'constants/theme';
|
||||||
|
import GoToTop from 'container/GoToTop';
|
||||||
|
import FiltersInput from 'container/LiveLogs/FiltersInput';
|
||||||
|
import LiveLogsTopNav from 'container/LiveLogsTopNav';
|
||||||
|
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||||
|
import { useEventSourceEvent } from 'hooks/useEventSourceEvent';
|
||||||
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
|
import { useEventSource } from 'providers/EventSource';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { prepareQueryRangePayload } from 'store/actions/dashboard/prepareQueryRangePayload';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import { ILog } from 'types/api/logs/log';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||||
|
|
||||||
|
import { idObject } from '../constants';
|
||||||
|
import ListViewPanel from '../ListViewPanel';
|
||||||
|
import LiveLogsList from '../LiveLogsList';
|
||||||
|
import { QueryHistoryState } from '../types';
|
||||||
|
import { prepareQueryByFilter } from '../utils';
|
||||||
|
import { ContentWrapper, LiveLogsChart, Wrapper } from './styles';
|
||||||
|
|
||||||
|
function LiveLogsContainer(): JSX.Element {
|
||||||
|
const location = useLocation();
|
||||||
|
const [logs, setLogs] = useState<ILog[]>([]);
|
||||||
|
|
||||||
|
const { stagedQuery } = useQueryBuilder();
|
||||||
|
|
||||||
|
const queryLocationState = location.state as QueryHistoryState;
|
||||||
|
|
||||||
|
const batchedEventsRef = useRef<ILog[]>([]);
|
||||||
|
|
||||||
|
const { notifications } = useNotifications();
|
||||||
|
|
||||||
|
const { selectedTime: globalSelectedTime } = useSelector<
|
||||||
|
AppState,
|
||||||
|
GlobalReducer
|
||||||
|
>((state) => state.globalTime);
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleStartOpenConnection,
|
||||||
|
handleCloseConnection,
|
||||||
|
initialLoading,
|
||||||
|
isConnectionLoading,
|
||||||
|
} = useEventSource();
|
||||||
|
|
||||||
|
const compositeQuery = useGetCompositeQueryParam();
|
||||||
|
|
||||||
|
const updateLogs = useCallback((newLogs: ILog[]) => {
|
||||||
|
setLogs((prevState) =>
|
||||||
|
[...newLogs, ...prevState].slice(0, MAX_LOGS_LIST_SIZE),
|
||||||
|
);
|
||||||
|
|
||||||
|
batchedEventsRef.current = [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const debouncedUpdateLogs = useDebouncedFn(() => {
|
||||||
|
const reversedData = batchedEventsRef.current.reverse();
|
||||||
|
updateLogs(reversedData);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const batchLiveLog = useCallback(
|
||||||
|
(log: ILog): void => {
|
||||||
|
batchedEventsRef.current.push(log);
|
||||||
|
|
||||||
|
debouncedUpdateLogs();
|
||||||
|
},
|
||||||
|
[debouncedUpdateLogs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGetLiveLogs = useCallback(
|
||||||
|
(event: MessageEvent<string>) => {
|
||||||
|
const data: ILog = JSON.parse(event.data);
|
||||||
|
|
||||||
|
batchLiveLog(data);
|
||||||
|
},
|
||||||
|
[batchLiveLog],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleError = useCallback(() => {
|
||||||
|
notifications.error({ message: 'Sorry, something went wrong' });
|
||||||
|
}, [notifications]);
|
||||||
|
|
||||||
|
useEventSourceEvent('message', handleGetLiveLogs);
|
||||||
|
useEventSourceEvent('error', handleError);
|
||||||
|
|
||||||
|
const getPreparedQuery = useCallback(
|
||||||
|
(query: Query): Query => {
|
||||||
|
const firstLogId: string | null = logs.length ? logs[0].id : null;
|
||||||
|
|
||||||
|
const preparedQuery: Query = prepareQueryByFilter(
|
||||||
|
query,
|
||||||
|
idObject,
|
||||||
|
firstLogId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return preparedQuery;
|
||||||
|
},
|
||||||
|
[logs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openConnection = useCallback(
|
||||||
|
(query: Query) => {
|
||||||
|
const { queryPayload } = prepareQueryRangePayload({
|
||||||
|
query,
|
||||||
|
graphType: PANEL_TYPES.LIST,
|
||||||
|
selectedTime: 'GLOBAL_TIME',
|
||||||
|
globalSelectedInterval: globalSelectedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const encodedQueryPayload = encodeURIComponent(JSON.stringify(queryPayload));
|
||||||
|
const queryString = `q=${encodedQueryPayload}`;
|
||||||
|
|
||||||
|
handleStartOpenConnection({ queryString });
|
||||||
|
},
|
||||||
|
[globalSelectedTime, handleStartOpenConnection],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartNewConnection = useCallback(
|
||||||
|
(query: Query) => {
|
||||||
|
handleCloseConnection();
|
||||||
|
|
||||||
|
const preparedQuery = getPreparedQuery(query);
|
||||||
|
|
||||||
|
openConnection(preparedQuery);
|
||||||
|
},
|
||||||
|
[getPreparedQuery, handleCloseConnection, openConnection],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!compositeQuery) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(initialLoading && !isConnectionLoading) ||
|
||||||
|
compositeQuery.id !== stagedQuery?.id
|
||||||
|
) {
|
||||||
|
handleStartNewConnection(compositeQuery);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
compositeQuery,
|
||||||
|
initialLoading,
|
||||||
|
stagedQuery,
|
||||||
|
isConnectionLoading,
|
||||||
|
openConnection,
|
||||||
|
handleStartNewConnection,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prefetchedList = queryLocationState?.listQueryPayload[0]?.list;
|
||||||
|
|
||||||
|
if (prefetchedList) {
|
||||||
|
const prefetchedLogs: ILog[] = prefetchedList
|
||||||
|
.map((item) => ({
|
||||||
|
...item.data,
|
||||||
|
timestamp: item.timestamp,
|
||||||
|
}))
|
||||||
|
.reverse();
|
||||||
|
|
||||||
|
updateLogs(prefetchedLogs);
|
||||||
|
}
|
||||||
|
}, [queryLocationState, updateLogs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<LiveLogsTopNav />
|
||||||
|
<ContentWrapper gutter={[0, 20]} style={{ color: themeColors.lightWhite }}>
|
||||||
|
<Col span={24}>
|
||||||
|
<FiltersInput />
|
||||||
|
</Col>
|
||||||
|
{initialLoading && logs.length === 0 ? (
|
||||||
|
<Col span={24}>
|
||||||
|
<Spinner style={{ height: 'auto' }} tip="Fetching Logs" />
|
||||||
|
</Col>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Col span={24}>
|
||||||
|
<LiveLogsChart
|
||||||
|
initialData={queryLocationState?.graphQueryPayload || null}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<ListViewPanel />
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<LiveLogsList logs={logs} />
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<GoToTop />
|
||||||
|
</ContentWrapper>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LiveLogsContainer;
|
17
frontend/src/container/LiveLogs/LiveLogsContainer/styles.ts
Normal file
17
frontend/src/container/LiveLogs/LiveLogsContainer/styles.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Row } from 'antd';
|
||||||
|
import { themeColors } from 'constants/theme';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import LiveLogsListChart from '../LiveLogsListChart';
|
||||||
|
|
||||||
|
export const LiveLogsChart = styled(LiveLogsListChart)`
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ContentWrapper = styled(Row)`
|
||||||
|
color: rgba(${(themeColors.white, 0.85)});
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Wrapper = styled.div`
|
||||||
|
padding-bottom: 4rem;
|
||||||
|
`;
|
131
frontend/src/container/LiveLogs/LiveLogsList/index.tsx
Normal file
131
frontend/src/container/LiveLogs/LiveLogsList/index.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { Card, Typography } from 'antd';
|
||||||
|
import ListLogView from 'components/Logs/ListLogView';
|
||||||
|
import RawLogView from 'components/Logs/RawLogView';
|
||||||
|
import Spinner from 'components/Spinner';
|
||||||
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
|
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
|
||||||
|
import InfinityTableView from 'container/LogsExplorerList/InfinityTableView';
|
||||||
|
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
|
||||||
|
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
|
||||||
|
import { Heading } from 'container/LogsTable/styles';
|
||||||
|
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||||
|
import { contentStyle } from 'container/Trace/Search/config';
|
||||||
|
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||||
|
import useFontFaceObserver from 'hooks/useFontObserver';
|
||||||
|
import { useEventSource } from 'providers/EventSource';
|
||||||
|
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
// interfaces
|
||||||
|
import { ILog } from 'types/api/logs/log';
|
||||||
|
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
import { LiveLogsListProps } from './types';
|
||||||
|
|
||||||
|
function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
|
||||||
|
const ref = useRef<VirtuosoHandle>(null);
|
||||||
|
|
||||||
|
const { t } = useTranslation(['logs']);
|
||||||
|
|
||||||
|
const { isConnectionLoading } = useEventSource();
|
||||||
|
|
||||||
|
const { activeLogId } = useCopyLogLink();
|
||||||
|
|
||||||
|
const { options } = useOptionsMenu({
|
||||||
|
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||||
|
dataSource: DataSource.LOGS,
|
||||||
|
aggregateOperator: StringOperators.NOOP,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeLogIndex = useMemo(
|
||||||
|
() => logs.findIndex(({ id }) => id === activeLogId),
|
||||||
|
[logs, activeLogId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useFontFaceObserver(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
family: 'Fira Code',
|
||||||
|
weight: '300',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options.format === 'raw',
|
||||||
|
{
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedFields = convertKeysToColumnFields(options.selectColumns);
|
||||||
|
|
||||||
|
const getItemContent = useCallback(
|
||||||
|
(_: number, log: ILog): JSX.Element => {
|
||||||
|
if (options.format === 'raw') {
|
||||||
|
return (
|
||||||
|
<RawLogView key={log.id} data={log} linesPerRow={options.maxLines} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListLogView key={log.id} logData={log} selectedFields={selectedFields} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[options.format, options.maxLines, selectedFields],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeLogId || activeLogIndex < 0) return;
|
||||||
|
|
||||||
|
ref?.current?.scrollToIndex({
|
||||||
|
index: activeLogIndex,
|
||||||
|
align: 'start',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}, [activeLogId, activeLogIndex]);
|
||||||
|
|
||||||
|
const isLoadingList = isConnectionLoading && logs.length === 0;
|
||||||
|
|
||||||
|
if (isLoadingList) {
|
||||||
|
return <Spinner style={{ height: 'auto' }} tip="Fetching Logs" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{options.format !== OptionFormatTypes.TABLE && (
|
||||||
|
<Heading>
|
||||||
|
<Typography.Text>Event</Typography.Text>
|
||||||
|
</Heading>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{logs.length === 0 && <Typography>{t('fetching_log_lines')}</Typography>}
|
||||||
|
|
||||||
|
{logs.length !== 0 && (
|
||||||
|
<InfinityWrapperStyled>
|
||||||
|
{options.format === 'table' ? (
|
||||||
|
<InfinityTableView
|
||||||
|
ref={ref}
|
||||||
|
isLoading={false}
|
||||||
|
tableViewProps={{
|
||||||
|
logs,
|
||||||
|
fields: selectedFields,
|
||||||
|
linesPerRow: options.maxLines,
|
||||||
|
appendTo: 'end',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card style={{ width: '100%' }} bodyStyle={{ ...contentStyle }}>
|
||||||
|
<Virtuoso
|
||||||
|
ref={ref}
|
||||||
|
useWindowScroll
|
||||||
|
data={logs}
|
||||||
|
totalCount={logs.length}
|
||||||
|
itemContent={getItemContent}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</InfinityWrapperStyled>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(LiveLogsList);
|
5
frontend/src/container/LiveLogs/LiveLogsList/types.ts
Normal file
5
frontend/src/container/LiveLogs/LiveLogsList/types.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { ILog } from 'types/api/logs/log';
|
||||||
|
|
||||||
|
export type LiveLogsListProps = {
|
||||||
|
logs: ILog[];
|
||||||
|
};
|
70
frontend/src/container/LiveLogs/LiveLogsListChart/index.tsx
Normal file
70
frontend/src/container/LiveLogs/LiveLogsListChart/index.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { LIVE_TAIL_GRAPH_INTERVAL } from 'constants/liveTail';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import LogsExplorerChart from 'container/LogsExplorerChart';
|
||||||
|
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useEventSource } from 'providers/EventSource';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { QueryData } from 'types/api/widgets/getQuery';
|
||||||
|
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
import { LiveLogsListChartProps } from './types';
|
||||||
|
|
||||||
|
function LiveLogsListChart({
|
||||||
|
className,
|
||||||
|
initialData,
|
||||||
|
}: LiveLogsListChartProps): JSX.Element {
|
||||||
|
const { stagedQuery } = useQueryBuilder();
|
||||||
|
const { isConnectionOpen } = useEventSource();
|
||||||
|
|
||||||
|
const listChartQuery: Query | null = useMemo(() => {
|
||||||
|
if (!stagedQuery) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...stagedQuery,
|
||||||
|
builder: {
|
||||||
|
...stagedQuery.builder,
|
||||||
|
queryData: stagedQuery.builder.queryData.map((item) => ({
|
||||||
|
...item,
|
||||||
|
disabled: false,
|
||||||
|
aggregateOperator: LogsAggregatorOperator.COUNT,
|
||||||
|
filters: {
|
||||||
|
...item.filters,
|
||||||
|
items: item.filters.items.filter((item) => item.key?.key !== 'id'),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [stagedQuery]);
|
||||||
|
|
||||||
|
const { data, isFetching } = useGetExplorerQueryRange(
|
||||||
|
listChartQuery,
|
||||||
|
PANEL_TYPES.TIME_SERIES,
|
||||||
|
{
|
||||||
|
enabled: isConnectionOpen,
|
||||||
|
refetchInterval: LIVE_TAIL_GRAPH_INTERVAL,
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
{ dataSource: DataSource.LOGS },
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartData: QueryData[] = useMemo(() => {
|
||||||
|
if (initialData) return initialData;
|
||||||
|
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
return data.payload.data.result;
|
||||||
|
}, [data, initialData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogsExplorerChart
|
||||||
|
isLoading={initialData ? false : isFetching}
|
||||||
|
data={chartData}
|
||||||
|
isLabelEnabled={false}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LiveLogsListChart;
|
@ -0,0 +1,6 @@
|
|||||||
|
import { QueryData } from 'types/api/widgets/getQuery';
|
||||||
|
|
||||||
|
export type LiveLogsListChartProps = {
|
||||||
|
className?: string;
|
||||||
|
initialData: QueryData[] | null;
|
||||||
|
};
|
50
frontend/src/container/LiveLogs/constants.ts
Normal file
50
frontend/src/container/LiveLogs/constants.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
initialQueriesMap,
|
||||||
|
initialQueryBuilderFormValuesMap,
|
||||||
|
} from 'constants/queryBuilder';
|
||||||
|
import { FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
|
||||||
|
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
export const defaultLiveQueryDataConfig: Partial<IBuilderQuery> = {
|
||||||
|
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||||
|
disabled: true,
|
||||||
|
pageSize: 10,
|
||||||
|
orderBy: [{ columnName: 'timestamp', order: FILTERS.DESC }],
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetDefaultCompositeQueryParams = {
|
||||||
|
query: Query;
|
||||||
|
initialQueryData: IBuilderQuery;
|
||||||
|
customQueryData?: Partial<IBuilderQuery>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const constructCompositeQuery = ({
|
||||||
|
query,
|
||||||
|
initialQueryData,
|
||||||
|
customQueryData,
|
||||||
|
}: GetDefaultCompositeQueryParams): Query => ({
|
||||||
|
...query,
|
||||||
|
builder: {
|
||||||
|
...query.builder,
|
||||||
|
queryData: query.builder.queryData.map((item) => ({
|
||||||
|
...initialQueryData,
|
||||||
|
...item,
|
||||||
|
...customQueryData,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const liveLogsCompositeQuery = constructCompositeQuery({
|
||||||
|
query: initialQueriesMap.logs,
|
||||||
|
initialQueryData: initialQueryBuilderFormValuesMap.logs,
|
||||||
|
customQueryData: defaultLiveQueryDataConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const idObject: BaseAutocompleteData = {
|
||||||
|
key: 'id',
|
||||||
|
type: '',
|
||||||
|
dataType: 'string',
|
||||||
|
isColumn: true,
|
||||||
|
};
|
6
frontend/src/container/LiveLogs/types.ts
Normal file
6
frontend/src/container/LiveLogs/types.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||||
|
|
||||||
|
export type QueryHistoryState = {
|
||||||
|
graphQueryPayload: QueryData[];
|
||||||
|
listQueryPayload: QueryDataV3[];
|
||||||
|
};
|
71
frontend/src/container/LiveLogs/utils.ts
Normal file
71
frontend/src/container/LiveLogs/utils.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { OPERATORS } from 'constants/queryBuilder';
|
||||||
|
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import {
|
||||||
|
Query,
|
||||||
|
TagFilter,
|
||||||
|
TagFilterItem,
|
||||||
|
} from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
const getIdFilter = (filtersItems: TagFilterItem[]): TagFilterItem | null =>
|
||||||
|
filtersItems.find((item) => item.key?.key === 'id') || null;
|
||||||
|
|
||||||
|
const getFilter = (
|
||||||
|
filters: TagFilter,
|
||||||
|
tagFilter: BaseAutocompleteData,
|
||||||
|
value: string,
|
||||||
|
): TagFilter => {
|
||||||
|
let newItems = filters.items;
|
||||||
|
|
||||||
|
const isExistIdFilter = getIdFilter(newItems);
|
||||||
|
|
||||||
|
if (isExistIdFilter) {
|
||||||
|
newItems = newItems.map((item) =>
|
||||||
|
item.key?.key === 'id' ? { ...item, value } : item,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newItems = [
|
||||||
|
...newItems,
|
||||||
|
{ value, key: tagFilter, op: OPERATORS['>'], id: uuid() },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items: newItems, op: filters.op };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prepareQueryByFilter = (
|
||||||
|
query: Query,
|
||||||
|
tagFilter: BaseAutocompleteData,
|
||||||
|
value: string | null,
|
||||||
|
): Query => {
|
||||||
|
const preparedQuery: Query = {
|
||||||
|
...query,
|
||||||
|
builder: {
|
||||||
|
...query.builder,
|
||||||
|
queryData: query.builder.queryData.map((item) => ({
|
||||||
|
...item,
|
||||||
|
filters: value ? getFilter(item.filters, tagFilter, value) : item.filters,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return preparedQuery;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getQueryWithoutFilterId = (query: Query): Query => {
|
||||||
|
const preparedQuery: Query = {
|
||||||
|
...query,
|
||||||
|
builder: {
|
||||||
|
...query.builder,
|
||||||
|
queryData: query.builder.queryData.map((item) => ({
|
||||||
|
...item,
|
||||||
|
filters: {
|
||||||
|
...item.filters,
|
||||||
|
items: item.filters.items.filter((item) => item.key?.key !== 'id'),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return preparedQuery;
|
||||||
|
};
|
71
frontend/src/container/LiveLogsTopNav/index.tsx
Normal file
71
frontend/src/container/LiveLogsTopNav/index.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { PauseCircleFilled, PlayCircleFilled } from '@ant-design/icons';
|
||||||
|
import { Space } from 'antd';
|
||||||
|
import BackButton from 'container/LiveLogs/BackButton';
|
||||||
|
import { getQueryWithoutFilterId } from 'container/LiveLogs/utils';
|
||||||
|
import LocalTopNav from 'container/LocalTopNav';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useEventSource } from 'providers/EventSource';
|
||||||
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { LiveButtonStyled } from './styles';
|
||||||
|
|
||||||
|
function LiveLogsTopNav(): JSX.Element {
|
||||||
|
const {
|
||||||
|
isConnectionOpen,
|
||||||
|
isConnectionLoading,
|
||||||
|
initialLoading,
|
||||||
|
handleCloseConnection,
|
||||||
|
handleSetInitialLoading,
|
||||||
|
} = useEventSource();
|
||||||
|
|
||||||
|
const { redirectWithQueryBuilderData, currentQuery } = useQueryBuilder();
|
||||||
|
|
||||||
|
const isPlaying = isConnectionOpen || isConnectionLoading || initialLoading;
|
||||||
|
|
||||||
|
const onLiveButtonClick = useCallback(() => {
|
||||||
|
if (initialLoading) {
|
||||||
|
handleSetInitialLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!isConnectionOpen && isConnectionLoading) || isConnectionOpen) {
|
||||||
|
handleCloseConnection();
|
||||||
|
} else {
|
||||||
|
const preparedQuery = getQueryWithoutFilterId(currentQuery);
|
||||||
|
redirectWithQueryBuilderData(preparedQuery);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
initialLoading,
|
||||||
|
isConnectionOpen,
|
||||||
|
isConnectionLoading,
|
||||||
|
currentQuery,
|
||||||
|
handleSetInitialLoading,
|
||||||
|
handleCloseConnection,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const liveButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<Space size={16}>
|
||||||
|
<LiveButtonStyled
|
||||||
|
icon={isPlaying ? <PauseCircleFilled /> : <PlayCircleFilled />}
|
||||||
|
danger={isPlaying}
|
||||||
|
onClick={onLiveButtonClick}
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
{isPlaying ? 'Pause' : 'Resume'}
|
||||||
|
</LiveButtonStyled>
|
||||||
|
<BackButton />
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
[isPlaying, onLiveButtonClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocalTopNav
|
||||||
|
actions={liveButton}
|
||||||
|
renderPermissions={{ isDateTimeEnabled: false }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(LiveLogsTopNav);
|
20
frontend/src/container/LiveLogsTopNav/styles.ts
Normal file
20
frontend/src/container/LiveLogsTopNav/styles.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Button, ButtonProps } from 'antd';
|
||||||
|
import { themeColors } from 'constants/theme';
|
||||||
|
import styled, { css, FlattenSimpleInterpolation } from 'styled-components';
|
||||||
|
|
||||||
|
export const LiveButtonStyled = styled(Button)<ButtonProps>`
|
||||||
|
background-color: rgba(${themeColors.buttonSuccessRgb}, 0.9);
|
||||||
|
|
||||||
|
${({ danger }): FlattenSimpleInterpolation =>
|
||||||
|
!danger
|
||||||
|
? css`
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(${themeColors.buttonSuccessRgb}, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: rgba(${themeColors.buttonSuccessRgb}, 0.7) !important;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
: css``}
|
||||||
|
`;
|
34
frontend/src/container/LocalTopNav/index.tsx
Normal file
34
frontend/src/container/LocalTopNav/index.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Col, Row, Space } from 'antd';
|
||||||
|
|
||||||
|
import ShowBreadcrumbs from '../TopNav/Breadcrumbs';
|
||||||
|
import DateTimeSelector from '../TopNav/DateTimeSelection';
|
||||||
|
import { Container } from './styles';
|
||||||
|
import { LocalTopNavProps } from './types';
|
||||||
|
|
||||||
|
function LocalTopNav({
|
||||||
|
actions,
|
||||||
|
renderPermissions,
|
||||||
|
}: LocalTopNavProps): JSX.Element | null {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Col span={16}>
|
||||||
|
<ShowBreadcrumbs />
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={8}>
|
||||||
|
<Row justify="end">
|
||||||
|
<Space align="start" size={30} direction="horizontal">
|
||||||
|
{actions}
|
||||||
|
{renderPermissions?.isDateTimeEnabled && (
|
||||||
|
<div>
|
||||||
|
<DateTimeSelector />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LocalTopNav;
|
9
frontend/src/container/LocalTopNav/styles.ts
Normal file
9
frontend/src/container/LocalTopNav/styles.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Row } from 'antd';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
export const Container = styled(Row)`
|
||||||
|
&&& {
|
||||||
|
margin-top: 2rem;
|
||||||
|
min-height: 8vh;
|
||||||
|
}
|
||||||
|
`;
|
6
frontend/src/container/LocalTopNav/types.ts
Normal file
6
frontend/src/container/LocalTopNav/types.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type LocalTopNavProps = {
|
||||||
|
actions?: ReactNode;
|
||||||
|
renderPermissions?: { isDateTimeEnabled: boolean };
|
||||||
|
};
|
@ -91,7 +91,7 @@ function TableView({
|
|||||||
const columns: ColumnsType<DataType> = [
|
const columns: ColumnsType<DataType> = [
|
||||||
{
|
{
|
||||||
title: 'Action',
|
title: 'Action',
|
||||||
width: 15,
|
width: 30,
|
||||||
render: (fieldData: Record<string, string>): JSX.Element | null => {
|
render: (fieldData: Record<string, string>): JSX.Element | null => {
|
||||||
const fieldKey = fieldData.field.split('.').slice(-1);
|
const fieldKey = fieldData.field.split('.').slice(-1);
|
||||||
if (!RESTRICTED_FIELDS.includes(fieldKey[0])) {
|
if (!RESTRICTED_FIELDS.includes(fieldKey[0])) {
|
||||||
|
@ -154,7 +154,13 @@ function LogsContextList({
|
|||||||
|
|
||||||
const getItemContent = useCallback(
|
const getItemContent = useCallback(
|
||||||
(_: number, log: ILog): JSX.Element => (
|
(_: number, log: ILog): JSX.Element => (
|
||||||
<RawLogView isReadOnly key={log.id} data={log} linesPerRow={1} />
|
<RawLogView
|
||||||
|
isReadOnly
|
||||||
|
isTextOverflowEllipsisDisabled
|
||||||
|
key={log.id}
|
||||||
|
data={log}
|
||||||
|
linesPerRow={1}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
@ -3,4 +3,6 @@ import { QueryData } from 'types/api/widgets/getQuery';
|
|||||||
export type LogsExplorerChartProps = {
|
export type LogsExplorerChartProps = {
|
||||||
data: QueryData[];
|
data: QueryData[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isLabelEnabled?: boolean;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import Graph from 'components/Graph';
|
import Graph from 'components/Graph';
|
||||||
import Spinner from 'components/Spinner';
|
import Spinner from 'components/Spinner';
|
||||||
|
import { themeColors } from 'constants/theme';
|
||||||
import getChartData, { GetChartDataProps } from 'lib/getChartData';
|
import getChartData, { GetChartDataProps } from 'lib/getChartData';
|
||||||
import { colors } from 'lib/getRandomColor';
|
import { colors } from 'lib/getRandomColor';
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { LogsExplorerChartProps } from './LogsExplorerChart.interfaces';
|
import { LogsExplorerChartProps } from './LogsExplorerChart.interfaces';
|
||||||
import { CardStyled } from './LogsExplorerChart.styled';
|
import { CardStyled } from './LogsExplorerChart.styled';
|
||||||
@ -10,17 +11,22 @@ import { CardStyled } from './LogsExplorerChart.styled';
|
|||||||
function LogsExplorerChart({
|
function LogsExplorerChart({
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isLabelEnabled = true,
|
||||||
|
className,
|
||||||
}: LogsExplorerChartProps): JSX.Element {
|
}: LogsExplorerChartProps): JSX.Element {
|
||||||
const handleCreateDatasets: Required<GetChartDataProps>['createDataset'] = (
|
const handleCreateDatasets: Required<GetChartDataProps>['createDataset'] = useCallback(
|
||||||
element,
|
(element, index, allLabels) => ({
|
||||||
index,
|
data: element,
|
||||||
allLabels,
|
backgroundColor: colors[index % colors.length] || themeColors.red,
|
||||||
) => ({
|
borderColor: colors[index % colors.length] || themeColors.red,
|
||||||
label: allLabels[index],
|
...(isLabelEnabled
|
||||||
data: element,
|
? {
|
||||||
backgroundColor: colors[index % colors.length] || 'red',
|
label: allLabels[index],
|
||||||
borderColor: colors[index % colors.length] || 'red',
|
}
|
||||||
});
|
: {}),
|
||||||
|
}),
|
||||||
|
[isLabelEnabled],
|
||||||
|
);
|
||||||
|
|
||||||
const graphData = useMemo(
|
const graphData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -32,11 +38,11 @@ function LogsExplorerChart({
|
|||||||
],
|
],
|
||||||
createDataset: handleCreateDatasets,
|
createDataset: handleCreateDatasets,
|
||||||
}),
|
}),
|
||||||
[data],
|
[data, handleCreateDatasets],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardStyled>
|
<CardStyled className={className}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Spinner size="default" height="100%" />
|
<Spinner size="default" height="100%" />
|
||||||
) : (
|
) : (
|
||||||
|
@ -9,7 +9,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
|||||||
import { memo, useCallback, useMemo, useState } from 'react';
|
import { memo, useCallback, useMemo, useState } from 'react';
|
||||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
import { EditButton, TitleWrapper } from './styles';
|
import { EditButton, LogContainer, TitleWrapper } from './styles';
|
||||||
import { LogsExplorerContextProps } from './types';
|
import { LogsExplorerContextProps } from './types';
|
||||||
import useInitialQuery from './useInitialQuery';
|
import useInitialQuery from './useInitialQuery';
|
||||||
|
|
||||||
@ -96,7 +96,15 @@ function LogsExplorerContext({
|
|||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
{...contextListParams}
|
{...contextListParams}
|
||||||
/>
|
/>
|
||||||
<RawLogView isActiveLog isReadOnly data={log} linesPerRow={1} />
|
<LogContainer>
|
||||||
|
<RawLogView
|
||||||
|
isActiveLog
|
||||||
|
isReadOnly
|
||||||
|
isTextOverflowEllipsisDisabled
|
||||||
|
data={log}
|
||||||
|
linesPerRow={1}
|
||||||
|
/>
|
||||||
|
</LogContainer>
|
||||||
<LogsContextList
|
<LogsContextList
|
||||||
order={FILTERS.DESC}
|
order={FILTERS.DESC}
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
@ -28,3 +28,7 @@ export const EditButton = styled(Button)<{ $isDarkMode: boolean }>`
|
|||||||
? getAlphaColor(themeColors.white)[45]
|
? getAlphaColor(themeColors.white)[45]
|
||||||
: getAlphaColor(themeColors.black)[45]};
|
: getAlphaColor(themeColors.black)[45]};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const LogContainer = styled.div`
|
||||||
|
overflow-x: auto;
|
||||||
|
`;
|
||||||
|
@ -10,6 +10,7 @@ import { getDraggedColumns } from 'hooks/useDragColumns/utils';
|
|||||||
import {
|
import {
|
||||||
cloneElement,
|
cloneElement,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
|
memo,
|
||||||
ReactElement,
|
ReactElement,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -67,7 +68,6 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
|||||||
onAddToQuery,
|
onAddToQuery,
|
||||||
} = useActiveLog();
|
} = useActiveLog();
|
||||||
|
|
||||||
const { onEndReached } = infitiyTableProps;
|
|
||||||
const { dataSource, columns } = useTableView({
|
const { dataSource, columns } = useTableView({
|
||||||
...tableViewProps,
|
...tableViewProps,
|
||||||
onClickExpand: onSetActiveLog,
|
onClickExpand: onSetActiveLog,
|
||||||
@ -158,8 +158,11 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
|||||||
}}
|
}}
|
||||||
itemContent={itemContent}
|
itemContent={itemContent}
|
||||||
fixedHeaderContent={tableHeader}
|
fixedHeaderContent={tableHeader}
|
||||||
endReached={onEndReached}
|
|
||||||
totalCount={dataSource.length}
|
totalCount={dataSource.length}
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
{...(infitiyTableProps?.onEndReached
|
||||||
|
? { endReached: infitiyTableProps.onEndReached }
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeContextLog && (
|
{activeContextLog && (
|
||||||
@ -179,4 +182,4 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default InfinityTable;
|
export default memo(InfinityTable);
|
||||||
|
@ -3,7 +3,7 @@ import { UseTableViewProps } from 'components/Logs/TableView/types';
|
|||||||
export type InfinityTableProps = {
|
export type InfinityTableProps = {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
tableViewProps: Omit<UseTableViewProps, 'onOpenLogsContext' | 'onClickExpand'>;
|
tableViewProps: Omit<UseTableViewProps, 'onOpenLogsContext' | 'onClickExpand'>;
|
||||||
infitiyTableProps: {
|
infitiyTableProps?: {
|
||||||
onEndReached: (index: number) => void;
|
onEndReached: (index: number) => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,22 +1,19 @@
|
|||||||
import { Tabs, TabsProps } from 'antd';
|
import { Tabs, TabsProps } from 'antd';
|
||||||
import TabLabel from 'components/TabLabel';
|
import TabLabel from 'components/TabLabel';
|
||||||
import { QueryParams } from 'constants/query';
|
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||||
import {
|
import {
|
||||||
initialAutocompleteData,
|
|
||||||
initialFilters,
|
initialFilters,
|
||||||
initialQueriesMap,
|
initialQueriesMap,
|
||||||
initialQueryBuilderFormValues,
|
initialQueryBuilderFormValues,
|
||||||
PANEL_TYPES,
|
PANEL_TYPES,
|
||||||
} from 'constants/queryBuilder';
|
} from 'constants/queryBuilder';
|
||||||
import { queryParamNamesMap } from 'constants/queryBuilderQueryNames';
|
import { queryParamNamesMap } from 'constants/queryBuilderQueryNames';
|
||||||
import ROUTES from 'constants/routes';
|
|
||||||
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
|
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
|
||||||
import ExportPanel from 'container/ExportPanel';
|
import ExportPanel from 'container/ExportPanel';
|
||||||
import GoToTop from 'container/GoToTop';
|
import GoToTop from 'container/GoToTop';
|
||||||
import LogsExplorerChart from 'container/LogsExplorerChart';
|
import LogsExplorerChart from 'container/LogsExplorerChart';
|
||||||
import LogsExplorerList from 'container/LogsExplorerList';
|
import LogsExplorerList from 'container/LogsExplorerList';
|
||||||
import LogsExplorerTable from 'container/LogsExplorerTable';
|
import LogsExplorerTable from 'container/LogsExplorerTable';
|
||||||
import { SIGNOZ_VALUE } from 'container/QueryBuilder/filters/OrderByFilter/constants';
|
|
||||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||||
import { addEmptyWidgetInDashboardJSONWithQuery } from 'hooks/dashboard/utils';
|
import { addEmptyWidgetInDashboardJSONWithQuery } from 'hooks/dashboard/utils';
|
||||||
@ -25,12 +22,13 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
|||||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import useAxiosError from 'hooks/useAxiosError';
|
import useAxiosError from 'hooks/useAxiosError';
|
||||||
|
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||||
import { useNotifications } from 'hooks/useNotifications';
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||||
import { getPaginationQueryData } from 'lib/newQueryBuilder/getPaginationQueryData';
|
import { getPaginationQueryData } from 'lib/newQueryBuilder/getPaginationQueryData';
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { generatePath, useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||||
import { ILog } from 'types/api/logs/log';
|
import { ILog } from 'types/api/logs/log';
|
||||||
@ -40,8 +38,9 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
TagFilter,
|
TagFilter,
|
||||||
} from 'types/api/queryBuilder/queryBuilderData';
|
} from 'types/api/queryBuilder/queryBuilderData';
|
||||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||||
|
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||||
|
|
||||||
import { ActionsWrapper } from './LogsExplorerViews.styled';
|
import { ActionsWrapper } from './LogsExplorerViews.styled';
|
||||||
|
|
||||||
@ -67,10 +66,10 @@ function LogsExplorerViews(): JSX.Element {
|
|||||||
stagedQuery,
|
stagedQuery,
|
||||||
panelType,
|
panelType,
|
||||||
updateAllQueriesOperators,
|
updateAllQueriesOperators,
|
||||||
updateQueriesData,
|
|
||||||
redirectWithQueryBuilderData,
|
|
||||||
} = useQueryBuilder();
|
} = useQueryBuilder();
|
||||||
|
|
||||||
|
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [logs, setLogs] = useState<ILog[]>([]);
|
const [logs, setLogs] = useState<ILog[]>([]);
|
||||||
@ -120,7 +119,7 @@ function LogsExplorerViews(): JSX.Element {
|
|||||||
|
|
||||||
const modifiedQueryData: IBuilderQuery = {
|
const modifiedQueryData: IBuilderQuery = {
|
||||||
...listQuery,
|
...listQuery,
|
||||||
aggregateOperator: StringOperators.COUNT,
|
aggregateOperator: LogsAggregatorOperator.COUNT,
|
||||||
};
|
};
|
||||||
|
|
||||||
const modifiedQuery: Query = {
|
const modifiedQuery: Query = {
|
||||||
@ -172,42 +171,6 @@ function LogsExplorerViews(): JSX.Element {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const getUpdateQuery = useCallback(
|
|
||||||
(newPanelType: PANEL_TYPES): Query => {
|
|
||||||
let query = updateAllQueriesOperators(
|
|
||||||
currentQuery,
|
|
||||||
newPanelType,
|
|
||||||
DataSource.TRACES,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newPanelType === PANEL_TYPES.LIST) {
|
|
||||||
query = updateQueriesData(query, 'queryData', (item) => ({
|
|
||||||
...item,
|
|
||||||
orderBy: item.orderBy.filter((item) => item.columnName !== SIGNOZ_VALUE),
|
|
||||||
aggregateAttribute: initialAutocompleteData,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return query;
|
|
||||||
},
|
|
||||||
[currentQuery, updateAllQueriesOperators, updateQueriesData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChangeView = useCallback(
|
|
||||||
(type: string) => {
|
|
||||||
const newPanelType = type as PANEL_TYPES;
|
|
||||||
|
|
||||||
if (newPanelType === panelType) return;
|
|
||||||
|
|
||||||
const query = getUpdateQuery(newPanelType);
|
|
||||||
|
|
||||||
redirectWithQueryBuilderData(query, {
|
|
||||||
[queryParamNamesMap.panelTypes]: newPanelType,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[panelType, getUpdateQuery, redirectWithQueryBuilderData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getRequestData = useCallback(
|
const getRequestData = useCallback(
|
||||||
(
|
(
|
||||||
query: Query | null,
|
query: Query | null,
|
||||||
@ -299,11 +262,16 @@ function LogsExplorerViews(): JSX.Element {
|
|||||||
|
|
||||||
const handleExport = useCallback(
|
const handleExport = useCallback(
|
||||||
(dashboard: Dashboard | null): void => {
|
(dashboard: Dashboard | null): void => {
|
||||||
if (!dashboard) return;
|
if (!dashboard || !panelType) return;
|
||||||
|
|
||||||
|
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||||
|
? panelType
|
||||||
|
: PANEL_TYPES.TIME_SERIES;
|
||||||
|
|
||||||
const updatedDashboard = addEmptyWidgetInDashboardJSONWithQuery(
|
const updatedDashboard = addEmptyWidgetInDashboardJSONWithQuery(
|
||||||
dashboard,
|
dashboard,
|
||||||
exportDefaultQuery,
|
exportDefaultQuery,
|
||||||
|
panelTypeParam,
|
||||||
);
|
);
|
||||||
|
|
||||||
updateDashboard(updatedDashboard, {
|
updateDashboard(updatedDashboard, {
|
||||||
@ -332,11 +300,11 @@ function LogsExplorerViews(): JSX.Element {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboardEditView = `${generatePath(ROUTES.DASHBOARD, {
|
const dashboardEditView = generateExportToDashboardLink({
|
||||||
dashboardId: data?.payload?.uuid,
|
query: exportDefaultQuery,
|
||||||
})}/new?${QueryParams.graphType}=graph&${QueryParams.widgetId}=empty&${
|
panelType: panelTypeParam,
|
||||||
queryParamNamesMap.compositeQuery
|
dashboardId: data.payload?.uuid || '',
|
||||||
}=${encodeURIComponent(JSON.stringify(exportDefaultQuery))}`;
|
});
|
||||||
|
|
||||||
history.push(dashboardEditView);
|
history.push(dashboardEditView);
|
||||||
},
|
},
|
||||||
@ -347,6 +315,7 @@ function LogsExplorerViews(): JSX.Element {
|
|||||||
exportDefaultQuery,
|
exportDefaultQuery,
|
||||||
history,
|
history,
|
||||||
notifications,
|
notifications,
|
||||||
|
panelType,
|
||||||
updateDashboard,
|
updateDashboard,
|
||||||
handleAxisError,
|
handleAxisError,
|
||||||
],
|
],
|
||||||
@ -356,9 +325,9 @@ function LogsExplorerViews(): JSX.Element {
|
|||||||
const shouldChangeView = isMultipleQueries || isGroupByExist;
|
const shouldChangeView = isMultipleQueries || isGroupByExist;
|
||||||
|
|
||||||
if (panelType === PANEL_TYPES.LIST && shouldChangeView) {
|
if (panelType === PANEL_TYPES.LIST && shouldChangeView) {
|
||||||
handleChangeView(PANEL_TYPES.TIME_SERIES);
|
handleExplorerTabChange(PANEL_TYPES.TIME_SERIES);
|
||||||
}
|
}
|
||||||
}, [panelType, isMultipleQueries, isGroupByExist, handleChangeView]);
|
}, [panelType, isMultipleQueries, isGroupByExist, handleExplorerTabChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentParams = data?.params as Omit<LogTimeRange, 'pageSize'>;
|
const currentParams = data?.params as Omit<LogTimeRange, 'pageSize'>;
|
||||||
@ -512,7 +481,7 @@ function LogsExplorerViews(): JSX.Element {
|
|||||||
items={tabsItems}
|
items={tabsItems}
|
||||||
defaultActiveKey={panelType || PANEL_TYPES.LIST}
|
defaultActiveKey={panelType || PANEL_TYPES.LIST}
|
||||||
activeKey={panelType || PANEL_TYPES.LIST}
|
activeKey={panelType || PANEL_TYPES.LIST}
|
||||||
onChange={handleChangeView}
|
onChange={handleExplorerTabChange}
|
||||||
destroyInactiveTabPane
|
destroyInactiveTabPane
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
91
frontend/src/container/LogsTopNav/index.tsx
Normal file
91
frontend/src/container/LogsTopNav/index.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { PlayCircleFilled } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
initialQueryBuilderFormValuesMap,
|
||||||
|
PANEL_TYPES,
|
||||||
|
} from 'constants/queryBuilder';
|
||||||
|
import { queryParamNamesMap } from 'constants/queryBuilderQueryNames';
|
||||||
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import {
|
||||||
|
constructCompositeQuery,
|
||||||
|
defaultLiveQueryDataConfig,
|
||||||
|
} from 'container/LiveLogs/constants';
|
||||||
|
import { QueryHistoryState } from 'container/LiveLogs/types';
|
||||||
|
import LocalTopNav from 'container/LocalTopNav';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useQueryClient } from 'react-query';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
|
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||||
|
|
||||||
|
import { LiveButtonStyled } from './styles';
|
||||||
|
|
||||||
|
function LogsTopNav(): JSX.Element {
|
||||||
|
const history = useHistory();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { stagedQuery, panelType } = useQueryBuilder();
|
||||||
|
|
||||||
|
const handleGoLive = useCallback(() => {
|
||||||
|
if (!stagedQuery) return;
|
||||||
|
|
||||||
|
let queryHistoryState: QueryHistoryState | null = null;
|
||||||
|
|
||||||
|
const compositeQuery = constructCompositeQuery({
|
||||||
|
query: stagedQuery,
|
||||||
|
initialQueryData: initialQueryBuilderFormValuesMap.logs,
|
||||||
|
customQueryData: defaultLiveQueryDataConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isListView =
|
||||||
|
panelType === PANEL_TYPES.LIST && stagedQuery.builder.queryData[0];
|
||||||
|
|
||||||
|
if (isListView) {
|
||||||
|
const [graphQuery, listQuery] = queryClient.getQueriesData<
|
||||||
|
SuccessResponse<MetricRangePayloadProps> | ErrorResponse
|
||||||
|
>({
|
||||||
|
queryKey: REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
queryHistoryState = {
|
||||||
|
graphQueryPayload:
|
||||||
|
graphQuery && graphQuery[1]
|
||||||
|
? graphQuery[1].payload?.data.result || []
|
||||||
|
: [],
|
||||||
|
listQueryPayload:
|
||||||
|
listQuery && listQuery[1]
|
||||||
|
? listQuery[1].payload?.data.newResult.data.result || []
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(compositeQuery));
|
||||||
|
|
||||||
|
const path = `${ROUTES.LIVE_LOGS}?${queryParamNamesMap.compositeQuery}=${JSONCompositeQuery}`;
|
||||||
|
|
||||||
|
history.push(path, queryHistoryState);
|
||||||
|
}, [history, panelType, queryClient, stagedQuery]);
|
||||||
|
|
||||||
|
const liveButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<LiveButtonStyled
|
||||||
|
icon={<PlayCircleFilled />}
|
||||||
|
onClick={handleGoLive}
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
Go Live
|
||||||
|
</LiveButtonStyled>
|
||||||
|
),
|
||||||
|
[handleGoLive],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<LocalTopNav
|
||||||
|
actions={liveButton}
|
||||||
|
renderPermissions={{ isDateTimeEnabled: true }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LogsTopNav;
|
20
frontend/src/container/LogsTopNav/styles.ts
Normal file
20
frontend/src/container/LogsTopNav/styles.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Button, ButtonProps } from 'antd';
|
||||||
|
import { themeColors } from 'constants/theme';
|
||||||
|
import styled, { css, FlattenSimpleInterpolation } from 'styled-components';
|
||||||
|
|
||||||
|
export const LiveButtonStyled = styled(Button)<ButtonProps>`
|
||||||
|
background-color: rgba(${themeColors.buttonSuccessRgb}, 0.9);
|
||||||
|
|
||||||
|
${({ danger }): FlattenSimpleInterpolation =>
|
||||||
|
!danger
|
||||||
|
? css`
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(${themeColors.buttonSuccessRgb}, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: rgba(${themeColors.buttonSuccessRgb}, 0.7) !important;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
: css``}
|
||||||
|
`;
|
@ -1,3 +1,5 @@
|
|||||||
|
import { RadioChangeEvent } from 'antd';
|
||||||
|
import { useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { FieldTitle } from '../styles';
|
import { FieldTitle } from '../styles';
|
||||||
@ -7,6 +9,15 @@ import { FormatFieldWrapper, RadioButton, RadioGroup } from './styles';
|
|||||||
function FormatField({ config }: FormatFieldProps): JSX.Element | null {
|
function FormatField({ config }: FormatFieldProps): JSX.Element | null {
|
||||||
const { t } = useTranslation(['trace']);
|
const { t } = useTranslation(['trace']);
|
||||||
|
|
||||||
|
const onChange = useCallback(
|
||||||
|
(event: RadioChangeEvent) => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
config.onChange(event.target.value);
|
||||||
|
},
|
||||||
|
[config],
|
||||||
|
);
|
||||||
|
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -16,7 +27,7 @@ function FormatField({ config }: FormatFieldProps): JSX.Element | null {
|
|||||||
size="small"
|
size="small"
|
||||||
buttonStyle="solid"
|
buttonStyle="solid"
|
||||||
value={config.value}
|
value={config.value}
|
||||||
onChange={config.onChange}
|
onChange={onChange}
|
||||||
>
|
>
|
||||||
<RadioButton value="raw">{t('options_menu.raw')}</RadioButton>
|
<RadioButton value="raw">{t('options_menu.raw')}</RadioButton>
|
||||||
<RadioButton value="list">{t('options_menu.default')}</RadioButton>
|
<RadioButton value="list">{t('options_menu.default')}</RadioButton>
|
||||||
|
@ -14,7 +14,9 @@ export interface InitialOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type OptionsMenuConfig = {
|
export type OptionsMenuConfig = {
|
||||||
format?: Pick<RadioProps, 'value' | 'onChange'>;
|
format?: Pick<RadioProps, 'value'> & {
|
||||||
|
onChange: (value: LogViewMode) => void;
|
||||||
|
};
|
||||||
maxLines?: Pick<InputNumberProps, 'value' | 'onChange'>;
|
maxLines?: Pick<InputNumberProps, 'value' | 'onChange'>;
|
||||||
addColumn?: Pick<
|
addColumn?: Pick<
|
||||||
SelectProps,
|
SelectProps,
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { RadioChangeEvent } from 'antd';
|
|
||||||
import getFromLocalstorage from 'api/browser/localstorage/get';
|
import getFromLocalstorage from 'api/browser/localstorage/get';
|
||||||
import setToLocalstorage from 'api/browser/localstorage/set';
|
import setToLocalstorage from 'api/browser/localstorage/set';
|
||||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
|
import { LogViewMode } from 'container/LogsTable';
|
||||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||||
import useDebounce from 'hooks/useDebounce';
|
import useDebounce from 'hooks/useDebounce';
|
||||||
import { useNotifications } from 'hooks/useNotifications';
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
@ -213,10 +213,10 @@ const useOptionsMenu = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleFormatChange = useCallback(
|
const handleFormatChange = useCallback(
|
||||||
(event: RadioChangeEvent) => {
|
(value: LogViewMode) => {
|
||||||
const optionsData: OptionsQuery = {
|
const optionsData: OptionsQuery = {
|
||||||
...optionsQueryData,
|
...optionsQueryData,
|
||||||
format: event.target.value,
|
format: value,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleRedirectWithOptionsData(optionsData);
|
handleRedirectWithOptionsData(optionsData);
|
||||||
|
@ -35,6 +35,7 @@ function QueryBuilderSearch({
|
|||||||
query,
|
query,
|
||||||
onChange,
|
onChange,
|
||||||
whereClauseConfig,
|
whereClauseConfig,
|
||||||
|
className,
|
||||||
}: QueryBuilderSearchProps): JSX.Element {
|
}: QueryBuilderSearchProps): JSX.Element {
|
||||||
const {
|
const {
|
||||||
updateTag,
|
updateTag,
|
||||||
@ -163,6 +164,7 @@ function QueryBuilderSearch({
|
|||||||
placeholder={PLACEHOLDER}
|
placeholder={PLACEHOLDER}
|
||||||
value={queryTags}
|
value={queryTags}
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
|
className={className}
|
||||||
disabled={isMetricsDataSource && !query.aggregateAttribute.key}
|
disabled={isMetricsDataSource && !query.aggregateAttribute.key}
|
||||||
style={selectStyle}
|
style={selectStyle}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
@ -186,10 +188,12 @@ interface QueryBuilderSearchProps {
|
|||||||
query: IBuilderQuery;
|
query: IBuilderQuery;
|
||||||
onChange: (value: TagFilter) => void;
|
onChange: (value: TagFilter) => void;
|
||||||
whereClauseConfig?: WhereClauseConfig;
|
whereClauseConfig?: WhereClauseConfig;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
QueryBuilderSearch.defaultProps = {
|
QueryBuilderSearch.defaultProps = {
|
||||||
whereClauseConfig: undefined,
|
whereClauseConfig: undefined,
|
||||||
|
className: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CustomTagProps {
|
export interface CustomTagProps {
|
||||||
|
@ -21,6 +21,7 @@ const breadcrumbNameMap = {
|
|||||||
[ROUTES.ALL_DASHBOARD]: 'Dashboard',
|
[ROUTES.ALL_DASHBOARD]: 'Dashboard',
|
||||||
[ROUTES.LOGS]: 'Logs',
|
[ROUTES.LOGS]: 'Logs',
|
||||||
[ROUTES.LOGS_EXPLORER]: 'Logs Explorer',
|
[ROUTES.LOGS_EXPLORER]: 'Logs Explorer',
|
||||||
|
[ROUTES.LIVE_LOGS]: 'Live View',
|
||||||
[ROUTES.PIPELINES]: 'Pipelines',
|
[ROUTES.PIPELINES]: 'Pipelines',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -84,3 +84,5 @@ export const routesToSkip = [
|
|||||||
ROUTES.LIST_ALL_ALERT,
|
ROUTES.LIST_ALL_ALERT,
|
||||||
ROUTES.PIPELINES,
|
ROUTES.PIPELINES,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||||
|
@ -3,10 +3,10 @@ import ROUTES from 'constants/routes';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { matchPath, useHistory } from 'react-router-dom';
|
import { matchPath, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import NewExplorerCTA from '../NewExplorerCTA';
|
||||||
import ShowBreadcrumbs from './Breadcrumbs';
|
import ShowBreadcrumbs from './Breadcrumbs';
|
||||||
import DateTimeSelector from './DateTimeSelection';
|
import DateTimeSelector from './DateTimeSelection';
|
||||||
import { routesToSkip } from './DateTimeSelection/config';
|
import { routesToDisable, routesToSkip } from './DateTimeSelection/config';
|
||||||
import NewExplorerCTA from './NewExplorerCTA';
|
|
||||||
import { Container } from './styles';
|
import { Container } from './styles';
|
||||||
|
|
||||||
function TopNav(): JSX.Element | null {
|
function TopNav(): JSX.Element | null {
|
||||||
@ -20,12 +20,20 @@ function TopNav(): JSX.Element | null {
|
|||||||
[location.pathname],
|
[location.pathname],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isDisabled = useMemo(
|
||||||
|
() =>
|
||||||
|
routesToDisable.some((route) =>
|
||||||
|
matchPath(location.pathname, { path: route, exact: true }),
|
||||||
|
),
|
||||||
|
[location.pathname],
|
||||||
|
);
|
||||||
|
|
||||||
const isSignUpPage = useMemo(
|
const isSignUpPage = useMemo(
|
||||||
() => matchPath(location.pathname, { path: ROUTES.SIGN_UP, exact: true }),
|
() => matchPath(location.pathname, { path: ROUTES.SIGN_UP, exact: true }),
|
||||||
[location.pathname],
|
[location.pathname],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isSignUpPage) {
|
if (isSignUpPage || isDisabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,7 +48,6 @@ function TopNav(): JSX.Element | null {
|
|||||||
<Row justify="end">
|
<Row justify="end">
|
||||||
<Space align="start" size={60} direction="horizontal">
|
<Space align="start" size={60} direction="horizontal">
|
||||||
<NewExplorerCTA />
|
<NewExplorerCTA />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<DateTimeSelector />
|
<DateTimeSelector />
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,6 +5,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
|||||||
export const addEmptyWidgetInDashboardJSONWithQuery = (
|
export const addEmptyWidgetInDashboardJSONWithQuery = (
|
||||||
dashboard: Dashboard,
|
dashboard: Dashboard,
|
||||||
query: Query,
|
query: Query,
|
||||||
|
panelTypes?: PANEL_TYPES,
|
||||||
): Dashboard => ({
|
): Dashboard => ({
|
||||||
...dashboard,
|
...dashboard,
|
||||||
data: {
|
data: {
|
||||||
@ -30,7 +31,7 @@ export const addEmptyWidgetInDashboardJSONWithQuery = (
|
|||||||
opacity: '',
|
opacity: '',
|
||||||
title: '',
|
title: '',
|
||||||
timePreferance: 'GLOBAL_TIME',
|
timePreferance: 'GLOBAL_TIME',
|
||||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
panelTypes: panelTypes || PANEL_TYPES.TIME_SERIES,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
15
frontend/src/hooks/queryBuilder/useGetSearchQueryParam.ts
Normal file
15
frontend/src/hooks/queryBuilder/useGetSearchQueryParam.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { QuerySearchParamNames } from 'constants/queryBuilderQueryNames';
|
||||||
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export const useGetSearchQueryParam = (
|
||||||
|
searchParams: QuerySearchParamNames,
|
||||||
|
): string | null => {
|
||||||
|
const urlQuery = useUrlQuery();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const searchQuery = urlQuery.get(searchParams);
|
||||||
|
|
||||||
|
return searchQuery ? JSON.parse(searchQuery) : null;
|
||||||
|
}, [urlQuery, searchParams]);
|
||||||
|
};
|
@ -8,7 +8,7 @@ import { useQueryBuilder } from './useQueryBuilder';
|
|||||||
export type UseShareBuilderUrlParams = { defaultValue: Query };
|
export type UseShareBuilderUrlParams = { defaultValue: Query };
|
||||||
|
|
||||||
export const useShareBuilderUrl = (defaultQuery: Query): void => {
|
export const useShareBuilderUrl = (defaultQuery: Query): void => {
|
||||||
const { redirectWithQueryBuilderData, resetStagedQuery } = useQueryBuilder();
|
const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||||
const urlQuery = useUrlQuery();
|
const urlQuery = useUrlQuery();
|
||||||
|
|
||||||
const compositeQuery = useGetCompositeQueryParam();
|
const compositeQuery = useGetCompositeQueryParam();
|
||||||
@ -18,11 +18,4 @@ export const useShareBuilderUrl = (defaultQuery: Query): void => {
|
|||||||
redirectWithQueryBuilderData(defaultQuery);
|
redirectWithQueryBuilderData(defaultQuery);
|
||||||
}
|
}
|
||||||
}, [defaultQuery, urlQuery, redirectWithQueryBuilderData, compositeQuery]);
|
}, [defaultQuery, urlQuery, redirectWithQueryBuilderData, compositeQuery]);
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => (): void => {
|
|
||||||
resetStagedQuery();
|
|
||||||
},
|
|
||||||
[resetStagedQuery],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
11
frontend/src/hooks/saveViews/useDeleteView.ts
Normal file
11
frontend/src/hooks/saveViews/useDeleteView.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { deleteView } from 'api/saveView/deleteView';
|
||||||
|
import { useMutation, UseMutationResult } from 'react-query';
|
||||||
|
import { DeleteViewPayloadProps } from 'types/api/saveViews/types';
|
||||||
|
|
||||||
|
export const useDeleteView = (
|
||||||
|
uuid: string,
|
||||||
|
): UseMutationResult<DeleteViewPayloadProps, Error, string> =>
|
||||||
|
useMutation({
|
||||||
|
mutationKey: [uuid],
|
||||||
|
mutationFn: () => deleteView(uuid),
|
||||||
|
});
|
13
frontend/src/hooks/saveViews/useGetAllViews.ts
Normal file
13
frontend/src/hooks/saveViews/useGetAllViews.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { getAllViews } from 'api/saveView/getAllViews';
|
||||||
|
import { AxiosError, AxiosResponse } from 'axios';
|
||||||
|
import { useQuery, UseQueryResult } from 'react-query';
|
||||||
|
import { AllViewsProps } from 'types/api/saveViews/types';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
export const useGetAllViews = (
|
||||||
|
sourcepage: DataSource,
|
||||||
|
): UseQueryResult<AxiosResponse<AllViewsProps>, AxiosError> =>
|
||||||
|
useQuery<AxiosResponse<AllViewsProps>, AxiosError>({
|
||||||
|
queryKey: [{ sourcepage }],
|
||||||
|
queryFn: () => getAllViews(sourcepage),
|
||||||
|
});
|
26
frontend/src/hooks/saveViews/useSaveView.ts
Normal file
26
frontend/src/hooks/saveViews/useSaveView.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { saveView } from 'api/saveView/saveView';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { useMutation, UseMutationResult } from 'react-query';
|
||||||
|
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
|
||||||
|
|
||||||
|
export const useSaveView = ({
|
||||||
|
compositeQuery,
|
||||||
|
sourcePage,
|
||||||
|
viewName,
|
||||||
|
extraData,
|
||||||
|
}: SaveViewProps): UseMutationResult<
|
||||||
|
AxiosResponse<SaveViewPayloadProps>,
|
||||||
|
Error,
|
||||||
|
SaveViewProps,
|
||||||
|
SaveViewPayloadProps
|
||||||
|
> =>
|
||||||
|
useMutation({
|
||||||
|
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
|
||||||
|
mutationFn: () =>
|
||||||
|
saveView({
|
||||||
|
compositeQuery,
|
||||||
|
sourcePage,
|
||||||
|
viewName,
|
||||||
|
extraData,
|
||||||
|
}),
|
||||||
|
});
|
30
frontend/src/hooks/saveViews/useUpdateView.ts
Normal file
30
frontend/src/hooks/saveViews/useUpdateView.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { updateView } from 'api/saveView/updateView';
|
||||||
|
import { useMutation, UseMutationResult } from 'react-query';
|
||||||
|
import {
|
||||||
|
UpdateViewPayloadProps,
|
||||||
|
UpdateViewProps,
|
||||||
|
} from 'types/api/saveViews/types';
|
||||||
|
|
||||||
|
export const useUpdateView = ({
|
||||||
|
compositeQuery,
|
||||||
|
viewName,
|
||||||
|
extraData,
|
||||||
|
sourcePage,
|
||||||
|
viewKey,
|
||||||
|
}: UpdateViewProps): UseMutationResult<
|
||||||
|
UpdateViewPayloadProps,
|
||||||
|
Error,
|
||||||
|
UpdateViewProps,
|
||||||
|
UpdateViewPayloadProps
|
||||||
|
> =>
|
||||||
|
useMutation({
|
||||||
|
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
|
||||||
|
mutationFn: () =>
|
||||||
|
updateView({
|
||||||
|
compositeQuery,
|
||||||
|
viewName,
|
||||||
|
extraData,
|
||||||
|
sourcePage,
|
||||||
|
viewKey,
|
||||||
|
}),
|
||||||
|
});
|
@ -2,20 +2,29 @@ import { EventListener, EventSourceEventMap } from 'event-source-polyfill';
|
|||||||
import { useEventSource } from 'providers/EventSource';
|
import { useEventSource } from 'providers/EventSource';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export const useEventSourceEvent = (
|
type EventMap = {
|
||||||
eventName: keyof EventSourceEventMap,
|
message: MessageEvent;
|
||||||
listener: EventListener,
|
open: Event;
|
||||||
|
error: Event;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEventSourceEvent = <T extends keyof EventSourceEventMap>(
|
||||||
|
eventName: T,
|
||||||
|
listener: (event: EventMap[T]) => void,
|
||||||
): void => {
|
): void => {
|
||||||
const { eventSourceInstance } = useEventSource();
|
const { eventSourceInstance } = useEventSource();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (eventSourceInstance) {
|
if (eventSourceInstance) {
|
||||||
eventSourceInstance.addEventListener(eventName, listener);
|
eventSourceInstance.addEventListener(eventName, listener as EventListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
if (eventSourceInstance) {
|
if (eventSourceInstance) {
|
||||||
eventSourceInstance.removeEventListener(eventName, listener);
|
eventSourceInstance.removeEventListener(
|
||||||
|
eventName,
|
||||||
|
listener as EventListener,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [eventName, eventSourceInstance, listener]);
|
}, [eventName, eventSourceInstance, listener]);
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user