mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: move more codes
This commit is contained in:
parent
123a0a0f50
commit
aa165fcc46
99 changed files with 29078 additions and 37998 deletions
5
.env
5
.env
|
|
@ -2,7 +2,8 @@
|
|||
IMAGE_NAME=ghcr.io/hyperdxio/hyperdx
|
||||
LOCAL_IMAGE_NAME=ghcr.io/hyperdxio/hyperdx-local
|
||||
LOCAL_IMAGE_NAME_DOCKERHUB=hyperdx/hyperdx-local
|
||||
IMAGE_VERSION=1.10.0
|
||||
IMAGE_VERSION=1.9.0
|
||||
V2_BETA_IMAGE_VERSION=2-beta
|
||||
|
||||
# Set up domain URLs
|
||||
HYPERDX_API_PORT=8000
|
||||
|
|
@ -12,3 +13,5 @@ HYPERDX_APP_URL=http://localhost
|
|||
HYPERDX_LOG_LEVEL=debug
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 # port is fixed
|
||||
|
||||
# TEMPORARY: local development
|
||||
HYPERDX_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
|
|
|
|||
8
.github/workflows/main.yml
vendored
8
.github/workflows/main.yml
vendored
|
|
@ -22,12 +22,6 @@ jobs:
|
|||
uses: bahmutov/npm-install@v1
|
||||
- name: Install core libs
|
||||
run: sudo apt-get install --yes curl bc
|
||||
- name: Install vector
|
||||
run: |
|
||||
mkdir -p vector
|
||||
curl -sSfL --proto '=https' --tlsv1.2 https://packages.timber.io/vector/0.41.1/vector-0.41.1-x86_64-unknown-linux-musl.tar.gz | tar xzf - -C vector --strip-components=2
|
||||
cp ./vector/bin/vector /usr/local/bin/vector
|
||||
vector --version
|
||||
- name: Run lint + type check
|
||||
run: make ci-lint
|
||||
unit:
|
||||
|
|
@ -44,7 +38,9 @@ jobs:
|
|||
uses: bahmutov/npm-install@v1
|
||||
- name: Run unit tests
|
||||
run: make ci-unit
|
||||
# TEMPORARILY DISABLED
|
||||
integration:
|
||||
if: false
|
||||
timeout-minutes: 8
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
|
|
|
|||
2
.github/workflows/push.yml
vendored
2
.github/workflows/push.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
name: Push Downstream
|
||||
name: Push Downstream V1
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
|
|
|||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -6,9 +6,18 @@
|
|||
# logs
|
||||
**/*.log
|
||||
**/npm-debug.log*
|
||||
**/lerna-debug.log*
|
||||
|
||||
# yarn
|
||||
**/yarn-debug.log*
|
||||
**/yarn-error.log*
|
||||
**/lerna-debug.log*
|
||||
.yarn/*
|
||||
!.yarn/cache
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# dotenv environment variable files
|
||||
**/.env.development.local
|
||||
|
|
|
|||
BIN
.yarn/releases/yarn-4.5.1.cjs
vendored
Executable file
BIN
.yarn/releases/yarn-4.5.1.cjs
vendored
Executable file
Binary file not shown.
5
.yarnrc
5
.yarnrc
|
|
@ -1,5 +0,0 @@
|
|||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
yarn-path ".yarn/releases/yarn-1.22.18.cjs"
|
||||
3
.yarnrc.yml
Normal file
3
.yarnrc.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.5.1.cjs
|
||||
12
Makefile
12
Makefile
|
|
@ -89,6 +89,18 @@ build-local:
|
|||
version:
|
||||
sh ./version.sh
|
||||
|
||||
.PHONY: release-v2-beta
|
||||
release-v2-beta:
|
||||
docker buildx build --squash . -f ./docker/local/Dockerfile \
|
||||
--build-context clickhouse=./docker/clickhouse \
|
||||
--build-context otel-collector=./docker/otel-collector \
|
||||
--build-context local=./docker/local \
|
||||
--build-context api=./packages/api \
|
||||
--build-context app=./packages/app \
|
||||
--platform ${BUILD_PLATFORMS} \
|
||||
-t ${LOCAL_IMAGE_NAME_DOCKERHUB}:${V2_BETA_IMAGE_VERSION} \
|
||||
-t ${LOCAL_IMAGE_NAME}:${V2_BETA_IMAGE_VERSION} --push
|
||||
|
||||
.PHONY: release
|
||||
release:
|
||||
docker buildx build --platform ${BUILD_PLATFORMS} ./docker/hostmetrics -t ${IMAGE_NAME}:${LATEST_VERSION}-hostmetrics --target prod --push &
|
||||
|
|
|
|||
|
|
@ -14,13 +14,10 @@ services:
|
|||
- ./docker/otel-collector/config.yaml:/etc/otelcol-contrib/config.yaml
|
||||
ports:
|
||||
- '23133:13133' # health_check extension
|
||||
# - '1888:1888' # pprof extension
|
||||
# - '24225:24225' # fluentd receiver
|
||||
# - '4317:4317' # OTLP gRPC receiver
|
||||
# - '4318:4318' # OTLP http receiver
|
||||
# - '55679:55679' # zpages extension
|
||||
# - '8888:8888' # metrics extension
|
||||
# - '9411:9411' # zipkin
|
||||
networks:
|
||||
- internal
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ services:
|
|||
depends_on:
|
||||
- otel-collector
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector-contrib:0.112.0
|
||||
image: otel/opentelemetry-collector-contrib:0.113.0
|
||||
environment:
|
||||
CLICKHOUSE_SERVER_ENDPOINT: 'ch-server:9000'
|
||||
HYPERDX_API_KEY: ${HYPERDX_API_KEY}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# TODO: migrate to V2
|
||||
version: '3'
|
||||
services:
|
||||
go-parser:
|
||||
|
|
@ -83,13 +84,10 @@ services:
|
|||
INGESTOR_API_URL: 'http://ingestor:8002'
|
||||
ports:
|
||||
- '13133:13133' # health_check extension
|
||||
- '1888:1888' # pprof extension
|
||||
- '24225:24225' # fluentd receiver
|
||||
- '4317:4317' # OTLP gRPC receiver
|
||||
- '4318:4318' # OTLP http receiver
|
||||
- '55679:55679' # zpages extension
|
||||
- '8888:8888' # metrics extension
|
||||
- '9411:9411' # zipkin
|
||||
restart: always
|
||||
networks:
|
||||
- internal
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<listen_host>0.0.0.0</listen_host>
|
||||
<http_port>8123</http_port>
|
||||
<tcp_port>9000</tcp_port>
|
||||
<interserver_http_host>ch_server</interserver_http_host>
|
||||
<interserver_http_host>ch-server</interserver_http_host>
|
||||
<interserver_http_port>9009</interserver_http_port>
|
||||
|
||||
<max_connections>4096</max_connections>
|
||||
|
|
@ -143,7 +143,7 @@
|
|||
<hdx_cluster>
|
||||
<shard>
|
||||
<replica>
|
||||
<host>ch_server</host>
|
||||
<host>ch-server</host>
|
||||
<port>9000</port>
|
||||
</replica>
|
||||
</shard>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
## base #############################################################################################
|
||||
FROM timberio/vector:0.41.1-alpine AS base
|
||||
FROM timberio/vector:0.39.0-alpine AS base
|
||||
|
||||
RUN mkdir -p /var/lib/vector
|
||||
VOLUME ["/var/lib/vector"]
|
||||
|
|
|
|||
|
|
@ -824,11 +824,18 @@ if is_object(.r) {
|
|||
'''
|
||||
|
||||
[transforms.post_spans]
|
||||
type = "route"
|
||||
type = "filter"
|
||||
inputs = ["spans"]
|
||||
reroute_unmatched = true
|
||||
[transforms.post_spans.route]
|
||||
go = '''"${ENABLE_GO_PARSER:-false}" == "true" && !is_nullish(.b."db.statement")'''
|
||||
condition = '''
|
||||
("${ENABLE_GO_PARSER:-false}" == "true" && is_nullish(.b."db.statement")) || "${ENABLE_GO_PARSER:-false}" == "false"
|
||||
'''
|
||||
|
||||
[transforms.go_spans]
|
||||
type = "filter"
|
||||
inputs = ["spans"]
|
||||
condition = '''
|
||||
"${ENABLE_GO_PARSER:-false}" == "true" && !is_nullish(.b."db.statement")
|
||||
'''
|
||||
# --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
[sinks.go_parser]
|
||||
type = "http"
|
||||
uri = "${GO_PARSER_API_URL}"
|
||||
inputs = ["post_spans.go"] # only send spans for now
|
||||
inputs = ["go_spans"] # only send spans for now
|
||||
compression = "gzip"
|
||||
encoding.codec = "json"
|
||||
batch.max_bytes = 10485760 # 10MB, required for rrweb payloads
|
||||
|
|
@ -16,7 +16,7 @@ batch.timeout_secs = 1
|
|||
[sinks.dev_hdx_aggregator]
|
||||
type = "http"
|
||||
uri = "${AGGREGATOR_API_URL}"
|
||||
inputs = ["post_spans._unmatched", "post_logs"]
|
||||
inputs = ["post_spans", "post_logs"]
|
||||
compression = "gzip"
|
||||
encoding.codec = "json"
|
||||
batch.max_bytes = 10485760 # 10MB, required for rrweb payloads
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
# - Clickhouse
|
||||
# - Mongo
|
||||
# - Otel Collector (otelcol)
|
||||
# - Ingestor (Vector)
|
||||
# - API (Node)
|
||||
# - Aggregator (Node)
|
||||
# - App (Frontend)
|
||||
# - Redis Cache
|
||||
|
||||
|
|
@ -14,32 +12,35 @@
|
|||
# - Allow persisting settings on disk
|
||||
# - Limiting persisted data with some auto rotation
|
||||
|
||||
# Get Node base image to copy over Node binaries
|
||||
ARG NODE_VERSION=18.20.3
|
||||
ARG CLICKHOUSE_VERSION=24
|
||||
ARG OTEL_COLLECTOR_VERSION=0.113.0
|
||||
# Get Node base image to copy over Node binaries
|
||||
FROM node:${NODE_VERSION}-alpine AS node
|
||||
|
||||
# == API Builder Image ==
|
||||
FROM node:${NODE_VERSION}-alpine AS api_builder
|
||||
# FROM node:${NODE_VERSION}-alpine AS api_builder
|
||||
|
||||
WORKDIR /app/api
|
||||
COPY ./yarn.lock .
|
||||
COPY --from=api ./package.json .
|
||||
RUN yarn install --frozen-lockfile && yarn cache clean
|
||||
# WORKDIR /app/api
|
||||
# COPY ./yarn.lock ./.yarnrc.yml .
|
||||
# COPY ./.yarn ./.yarn
|
||||
# COPY --from=api ./package.json .
|
||||
# RUN yarn install && yarn cache clean
|
||||
|
||||
COPY --from=api ./tsconfig.json .
|
||||
COPY --from=api ./src ./src
|
||||
RUN yarn run build
|
||||
# COPY --from=api ./tsconfig.json .
|
||||
# COPY --from=api ./src ./src
|
||||
# RUN yarn run build
|
||||
|
||||
# == App Builder Image ==
|
||||
FROM node:${NODE_VERSION}-alpine AS app_builder
|
||||
|
||||
WORKDIR /app/app
|
||||
|
||||
COPY ./yarn.lock ./.yarnrc .
|
||||
COPY ./yarn.lock ./.yarnrc.yml .
|
||||
COPY ./.yarn ./.yarn
|
||||
COPY --from=app ./package.json .
|
||||
|
||||
RUN yarn install --frozen-lockfile && yarn cache clean
|
||||
RUN yarn install && yarn cache clean
|
||||
|
||||
COPY --from=app ./.eslintrc.js ./next.config.js ./tsconfig.json ./next.config.js ./mdx.d.ts ./.eslintrc.js ./
|
||||
COPY --from=app ./src ./src
|
||||
|
|
@ -53,92 +54,67 @@ ENV NEXT_PUBLIC_IS_LOCAL_MODE true
|
|||
RUN yarn build
|
||||
|
||||
# == Clickhouse/Base Image ==
|
||||
FROM clickhouse/clickhouse-server:${CLICKHOUSE_VERSION}-alpine AS clickhouse_base
|
||||
|
||||
FROM clickhouse/clickhouse-server:23.11.1-alpine AS clickhouse_base
|
||||
# == Otel Collector Image ==
|
||||
FROM otel/opentelemetry-collector-contrib:${OTEL_COLLECTOR_VERSION} AS otel_collector_base
|
||||
|
||||
# Pull in the target platform
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Clean up 390MB of unused CH binaries
|
||||
RUN rm -rf /usr/bin/clickhouse-odbc-bridge /usr/bin/clickhouse-library-bridge /usr/bin/clickhouse-diagnostics
|
||||
FROM scratch as base
|
||||
COPY --from=clickhouse_base / /
|
||||
COPY --from=otel_collector_base /otelcol-contrib /usr/local/bin/otelcol-contrib
|
||||
|
||||
# ===
|
||||
# === Install Deps
|
||||
# ===
|
||||
|
||||
# == Install Otel Collector Deps ==
|
||||
EXPOSE 1888 4317 4318 55679 13133
|
||||
|
||||
RUN apk update
|
||||
RUN apk add wget shadow
|
||||
# Check the target platform and choose the appropriate package
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
wget https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v0.90.1/otelcol-contrib_0.90.1_linux_arm64.apk; \
|
||||
else \
|
||||
wget https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v0.90.1/otelcol-contrib_0.90.1_linux_amd64.apk; \
|
||||
fi && \
|
||||
apk add --allow-untrusted otelcol-contrib_0.90.1_linux_*.apk && \
|
||||
rm -rf otelcol-contrib_0.90.1_linux_*.apk
|
||||
|
||||
# == Install Node Deps ==
|
||||
|
||||
COPY --from=node /usr/lib /usr/lib
|
||||
COPY --from=node /usr/local/lib /usr/local/lib
|
||||
COPY --from=node /usr/local/include /usr/local/include
|
||||
COPY --from=node /usr/local/bin /usr/local/bin
|
||||
|
||||
RUN npm install -g yarn --force
|
||||
|
||||
# == Install Vector Deps ==
|
||||
RUN apk add curl
|
||||
RUN mkdir -p vector
|
||||
RUN curl -sSfL --proto '=https' --tlsv1.2 https://packages.timber.io/vector/0.34.0/vector-0.34.0-x86_64-unknown-linux-musl.tar.gz | tar xzf - -C vector --strip-components=2 && \
|
||||
mv ./vector/bin/vector /usr/local/bin/vector && \
|
||||
rm -rf ./vector
|
||||
# RUN npm install -g yarn --force
|
||||
|
||||
# == Install MongoDB v4 Deps ==
|
||||
RUN echo 'http://dl-cdn.alpinelinux.org/alpine/v3.9/main' >> /etc/apk/repositories
|
||||
RUN echo 'http://dl-cdn.alpinelinux.org/alpine/v3.9/community' >> /etc/apk/repositories
|
||||
RUN apk update
|
||||
RUN apk add mongodb yaml-cpp=0.6.2-r2
|
||||
# RUN echo 'http://dl-cdn.alpinelinux.org/alpine/v3.9/main' >> /etc/apk/repositories
|
||||
# RUN echo 'http://dl-cdn.alpinelinux.org/alpine/v3.9/community' >> /etc/apk/repositories
|
||||
# RUN apk update
|
||||
# RUN apk add mongodb yaml-cpp=0.6.2-r2
|
||||
RUN apk add curl
|
||||
|
||||
# == Install Redis ==
|
||||
# If this version pinning fails, this is likely due to the version being dropped
|
||||
# from APK
|
||||
RUN apk add 'redis<7.0.14'
|
||||
# RUN apk add 'redis<7.0.14'
|
||||
|
||||
# ===
|
||||
# === Set Up Services
|
||||
# ===
|
||||
|
||||
# Set up Vector
|
||||
COPY --from=ingestor ./*.toml /etc/vector/
|
||||
EXPOSE 8002 8686
|
||||
|
||||
# Set up Clickhouse
|
||||
COPY --from=clickhouse ./local/*.xml /etc/clickhouse-server
|
||||
# overwrite default config
|
||||
COPY --from=local ./clickhouseConfig.xml /etc/clickhouse-server/config.xml
|
||||
|
||||
# Set up Mongo
|
||||
RUN mkdir -p /data/db
|
||||
# RUN mkdir -p /data/db
|
||||
|
||||
# Set up Otel Collector
|
||||
COPY --from=otel-collector ./config.yaml /etc/otelcol-contrib/config.yaml
|
||||
COPY --from=otel-collector ./config.local.yaml /etc/otelcol-contrib/config.yaml
|
||||
|
||||
# Set up API
|
||||
WORKDIR /app/api
|
||||
# Set up API (NOT USED YET)
|
||||
# WORKDIR /app/api
|
||||
|
||||
COPY --from=api_builder ./app/api/build ./build
|
||||
COPY ./yarn.lock .
|
||||
COPY --from=api_builder ./app/api/package.json .
|
||||
# COPY --from=api_builder ./app/api/build ./build
|
||||
# COPY ./yarn.lock ./.yarnrc.yml .
|
||||
# COPY ./.yarn ./.yarn
|
||||
# COPY --from=api_builder ./app/api/package.json .
|
||||
|
||||
# Only install prod dependencies
|
||||
RUN yarn install --production --frozen-lockfile && yarn cache clean
|
||||
# # Only install prod dependencies
|
||||
# RUN yarn workspaces focus --production && yarn cache clean
|
||||
|
||||
EXPOSE 8000
|
||||
# # Remove dev dependencies
|
||||
# RUN rm -rf ./.yarn
|
||||
|
||||
# Set up App
|
||||
WORKDIR /app/app
|
||||
|
|
@ -146,7 +122,8 @@ COPY --from=app_builder ./app/app/public .
|
|||
COPY --from=app_builder ./app/app/.next/standalone .
|
||||
COPY --from=app_builder ./app/app/.next/static ./.next/static
|
||||
|
||||
EXPOSE 8080
|
||||
# Expose ports
|
||||
EXPOSE 8000 8080 4317 4318 13133 8123 9000
|
||||
|
||||
# Set up start script
|
||||
COPY --from=local ./entry.sh /etc/local/entry.sh
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
docker build --squash -t hdx-oss-dev-local -f ./docker/local/Dockerfile \
|
||||
--build-context clickhouse=./docker/clickhouse \
|
||||
--build-context otel-collector=./docker/otel-collector \
|
||||
--build-context ingestor=./docker/ingestor \
|
||||
--build-context local=./docker/local \
|
||||
--build-context api=./packages/api \
|
||||
--build-context app=./packages/app \
|
||||
|
|
|
|||
|
|
@ -7,15 +7,10 @@
|
|||
<errorlog>/var/log/clickhouse-server/clickhouse-server.err.log</errorlog>
|
||||
</logger>
|
||||
|
||||
<query_log>
|
||||
<database>system</database>
|
||||
<table>query_log</table>
|
||||
</query_log>
|
||||
|
||||
<listen_host>0.0.0.0</listen_host>
|
||||
<http_port>8123</http_port>
|
||||
<tcp_port>9000</tcp_port>
|
||||
<interserver_http_host>ch_server</interserver_http_host>
|
||||
<interserver_http_host>ch-server</interserver_http_host>
|
||||
<interserver_http_port>9009</interserver_http_port>
|
||||
|
||||
<max_connections>4096</max_connections>
|
||||
|
|
@ -36,11 +31,41 @@
|
|||
<default_database>default</default_database>
|
||||
<timezone>UTC</timezone>
|
||||
<mlock_executable>false</mlock_executable>
|
||||
|
||||
<!-- Query log. Used only for queries with setting log_queries = 1. -->
|
||||
<query_log>
|
||||
<database>system</database>
|
||||
<table>query_log</table>
|
||||
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||
</query_log>
|
||||
|
||||
<!-- Metric log contains rows with current values of ProfileEvents, CurrentMetrics collected with "collect_interval_milliseconds" interval. -->
|
||||
<metric_log>
|
||||
<database>system</database>
|
||||
<table>metric_log</table>
|
||||
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||
<collect_interval_milliseconds>1000</collect_interval_milliseconds>
|
||||
</metric_log>
|
||||
|
||||
<!--
|
||||
Asynchronous metric log contains values of metrics from
|
||||
system.asynchronous_metrics.
|
||||
-->
|
||||
<asynchronous_metric_log>
|
||||
<database>system</database>
|
||||
<table>asynchronous_metric_log</table>
|
||||
<!--
|
||||
Asynchronous metrics are updated once a minute, so there is
|
||||
no need to flush more often.
|
||||
-->
|
||||
<flush_interval_milliseconds>7000</flush_interval_milliseconds>
|
||||
</asynchronous_metric_log>
|
||||
|
||||
<!--
|
||||
OpenTelemetry log contains OpenTelemetry trace spans.
|
||||
-->
|
||||
<!-- <opentelemetry_span_log> -->
|
||||
<!--
|
||||
<opentelemetry_span_log>
|
||||
<!--
|
||||
The default table creation code is insufficient, this <engine> spec
|
||||
is a workaround. There is no 'event_time' for this log, but two times,
|
||||
start and finish. It is sorted by finish time, to avoid inserting
|
||||
|
|
@ -50,7 +75,7 @@
|
|||
global order that we can use to e.g. retry insertion into some external
|
||||
system.
|
||||
-->
|
||||
<!-- <engine>
|
||||
<engine>
|
||||
engine MergeTree
|
||||
partition by toYYYYMM(finish_date)
|
||||
order by (finish_date, finish_time_us, trace_id)
|
||||
|
|
@ -58,18 +83,64 @@
|
|||
<database>system</database>
|
||||
<table>opentelemetry_span_log</table>
|
||||
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||
</opentelemetry_span_log> -->
|
||||
</opentelemetry_span_log>
|
||||
|
||||
<!-- <remote_servers>
|
||||
<hdx_cluster>
|
||||
<shard>
|
||||
<replica>
|
||||
<host>ch_server</host>
|
||||
<port>9000</port>
|
||||
</replica>
|
||||
</shard>
|
||||
</hdx_cluster>
|
||||
</remote_servers> -->
|
||||
|
||||
<!-- Crash log. Stores stack traces for fatal errors.
|
||||
This table is normally empty. -->
|
||||
<crash_log>
|
||||
<database>system</database>
|
||||
<table>crash_log</table>
|
||||
|
||||
<partition_by />
|
||||
<flush_interval_milliseconds>1000</flush_interval_milliseconds>
|
||||
</crash_log>
|
||||
|
||||
<!-- Profiling on Processors level. -->
|
||||
<processors_profile_log>
|
||||
<database>system</database>
|
||||
<table>processors_profile_log</table>
|
||||
|
||||
<partition_by>toYYYYMM(event_date)</partition_by>
|
||||
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||
</processors_profile_log>
|
||||
|
||||
<!-- Uncomment if use part log.
|
||||
Part log contains information about all actions with parts in MergeTree tables (creation, deletion, merges, downloads).-->
|
||||
<part_log>
|
||||
<database>system</database>
|
||||
<table>part_log</table>
|
||||
<partition_by>toYYYYMM(event_date)</partition_by>
|
||||
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||
</part_log>
|
||||
|
||||
<!-- Trace log. Stores stack traces collected by query profilers.
|
||||
See query_profiler_real_time_period_ns and query_profiler_cpu_time_period_ns settings. -->
|
||||
<trace_log>
|
||||
<database>system</database>
|
||||
<table>trace_log</table>
|
||||
|
||||
<partition_by>toYYYYMM(event_date)</partition_by>
|
||||
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||
</trace_log>
|
||||
|
||||
<!-- Query thread log. Has information about all threads participated in query execution.
|
||||
Used only for queries with setting log_query_threads = 1. -->
|
||||
<query_thread_log>
|
||||
<database>system</database>
|
||||
<table>query_thread_log</table>
|
||||
<partition_by>toYYYYMM(event_date)</partition_by>
|
||||
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||
</query_thread_log>
|
||||
|
||||
<!-- Query views log. Has information about all dependent views associated with a query.
|
||||
Used only for queries with setting log_query_views = 1. -->
|
||||
<query_views_log>
|
||||
<database>system</database>
|
||||
<table>query_views_log</table>
|
||||
<partition_by>toYYYYMM(event_date)</partition_by>
|
||||
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||
</query_views_log>
|
||||
|
||||
<distributed_ddl>
|
||||
<path>/clickhouse/task_queue/ddl</path>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ export HYPERDX_LOG_LEVEL="error"
|
|||
# https://clickhouse.com/docs/en/operations/server-configuration-parameters/settings#logger
|
||||
export CLICKHOUSE_LOG_LEVEL="error"
|
||||
|
||||
# User-facing Services
|
||||
export INGESTOR_API_URL="http://localhost:8002"
|
||||
# User can specify either an entire SERVER_URL, or override slectively the
|
||||
# HYPERDX_API_URL or HYPERDX_API_PORT from the defaults
|
||||
# Same applies to the frontend/app
|
||||
|
|
@ -13,21 +11,19 @@ export SERVER_URL="${SERVER_URL:-${HYPERDX_API_URL:-http://localhost}:${HYPERDX_
|
|||
export FRONTEND_URL="${FRONTEND_URL:-${HYPERDX_APP_URL:-http://localhost}:${HYPERDX_APP_PORT:-8080}}"
|
||||
|
||||
# Internal Services
|
||||
export AGGREGATOR_API_URL="http://aggregator:8001"
|
||||
export CLICKHOUSE_HOST="http://ch-server:8123"
|
||||
export CLICKHOUSE_SERVER_ENDPOINT="ch-server:9000"
|
||||
export MONGO_URI="mongodb://db:27017/hyperdx"
|
||||
export REDIS_URI="redis://redis:6379"
|
||||
|
||||
export AGGREGATOR_PAYLOAD_SIZE_LIMIT="144mb"
|
||||
export EXPRESS_SESSION_SECRET="hyperdx is cool 👋"
|
||||
export IS_LOCAL_APP_MODE="DANGEROUSLY_is_local_app_mode💀"
|
||||
export NEXT_TELEMETRY_DISABLED="1"
|
||||
|
||||
|
||||
# Simulate Docker Service DNS
|
||||
echo "127.0.0.1 aggregator" >> /etc/hosts
|
||||
echo "127.0.0.1 ch-server" >> /etc/hosts
|
||||
echo "127.0.0.1 db" >> /etc/hosts
|
||||
echo "127.0.0.1 ingestor" >> /etc/hosts
|
||||
echo "127.0.0.1 redis" >> /etc/hosts
|
||||
|
||||
echo "Visit the HyperDX UI at $FRONTEND_URL/search"
|
||||
|
|
@ -42,38 +38,25 @@ echo ""
|
|||
/entrypoint.sh &
|
||||
|
||||
# Start Redis Server
|
||||
redis-server > /var/log/redis.log 2>&1 &
|
||||
# redis-server > /var/log/redis.log 2>&1 &
|
||||
|
||||
# Start Mongo Server
|
||||
mongod --quiet --dbpath /data/db > /var/log/mongod.log 2>&1 &
|
||||
|
||||
# Start Vector Ingestor
|
||||
ENABLE_GO_PARSER="false" \
|
||||
GO_PARSER_API_URL="http://go-parser:7777" \
|
||||
IS_LOCAL_APP_MODE="true" \
|
||||
vector \
|
||||
-qq \
|
||||
-c /etc/vector/sources.toml \
|
||||
-c /etc/vector/core.toml \
|
||||
-c /etc/vector/http-sinks.toml \
|
||||
--require-healthy true > /var/log/vector.log 2>&1 &
|
||||
# mongod --quiet --dbpath /data/db > /var/log/mongod.log 2>&1 &
|
||||
|
||||
# Wait for Clickhouse to be ready
|
||||
while ! curl -s "http://ch-server:8123" > /dev/null; do
|
||||
echo "Waiting for Clickhouse to be ready..."
|
||||
sleep 1
|
||||
done
|
||||
# Start Otel Collector
|
||||
otelcol-contrib --config /etc/otelcol-contrib/config.yaml &
|
||||
|
||||
# Aggregator
|
||||
APP_TYPE=aggregator \
|
||||
CLICKHOUSE_USER=aggregator \
|
||||
CLICKHOUSE_PASSWORD=aggregator \
|
||||
PORT=8001 \
|
||||
node /app/api/build/index.js > /var/log/aggregator.log 2>&1 &
|
||||
|
||||
# Api
|
||||
APP_TYPE=api \
|
||||
CLICKHOUSE_USER=api \
|
||||
CLICKHOUSE_PASSWORD=api \
|
||||
PORT=8000 \
|
||||
node /app/api/build/index.js > /var/log/api.log 2>&1 &
|
||||
# APP_TYPE=api \
|
||||
# CLICKHOUSE_USER=api \
|
||||
# CLICKHOUSE_PASSWORD=api \
|
||||
# PORT=8000 \
|
||||
# node /app/api/build/index.js > /var/log/api.log 2>&1 &
|
||||
|
||||
# App
|
||||
NODE_ENV=production \
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
## base #############################################################################################
|
||||
FROM otel/opentelemetry-collector-contrib:0.111.0 AS base
|
||||
FROM otel/opentelemetry-collector-contrib:0.113.0 AS base
|
||||
|
||||
|
||||
## dev ##############################################################################################
|
||||
|
|
@ -7,7 +7,7 @@ FROM base as dev
|
|||
|
||||
COPY ./config.yaml /etc/otelcol-contrib/config.yaml
|
||||
|
||||
EXPOSE 1888 4317 4318 55679 13133
|
||||
EXPOSE 4317 4318 13133
|
||||
|
||||
|
||||
## prod #############################################################################################
|
||||
|
|
@ -15,4 +15,4 @@ FROM base as prod
|
|||
|
||||
COPY ./config.yaml /etc/otelcol-contrib/config.yaml
|
||||
|
||||
EXPOSE 1888 4317 4318 55679 13133
|
||||
EXPOSE 4317 4318 13133
|
||||
|
|
|
|||
70
docker/otel-collector/config.local.yaml
Normal file
70
docker/otel-collector/config.local.yaml
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
receivers:
|
||||
# Data sources: logs
|
||||
fluentforward:
|
||||
endpoint: '0.0.0.0:24225'
|
||||
# Data sources: traces, metrics, logs
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
include_metadata: true
|
||||
endpoint: '0.0.0.0:4317'
|
||||
http:
|
||||
cors:
|
||||
allowed_origins: ['*']
|
||||
allowed_headers: ['*']
|
||||
include_metadata: true
|
||||
endpoint: '0.0.0.0:4318'
|
||||
processors:
|
||||
resourcedetection:
|
||||
detectors:
|
||||
- env
|
||||
- system
|
||||
- docker
|
||||
timeout: 5s
|
||||
override: false
|
||||
batch:
|
||||
memory_limiter:
|
||||
# 80% of maximum memory up to 2G
|
||||
limit_mib: 1500
|
||||
# 25% of limit up to 2G
|
||||
spike_limit_mib: 512
|
||||
check_interval: 5s
|
||||
exporters:
|
||||
debug:
|
||||
verbosity: detailed
|
||||
sampling_initial: 5
|
||||
sampling_thereafter: 200
|
||||
clickhouse:
|
||||
endpoint: tcp://${env:CLICKHOUSE_SERVER_ENDPOINT}?dial_timeout=10s&compress=lz4
|
||||
database: default
|
||||
ttl: 72h
|
||||
logs_table_name: otel_logs
|
||||
traces_table_name: otel_traces
|
||||
metrics_table_name: otel_metrics
|
||||
timeout: 5s
|
||||
retry_on_failure:
|
||||
enabled: true
|
||||
initial_interval: 5s
|
||||
max_interval: 30s
|
||||
max_elapsed_time: 300s
|
||||
extensions:
|
||||
health_check:
|
||||
endpoint: :13133
|
||||
service:
|
||||
telemetry:
|
||||
metrics:
|
||||
address: ':8888'
|
||||
extensions: [health_check]
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [memory_limiter, batch]
|
||||
exporters: [clickhouse]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [memory_limiter, batch]
|
||||
exporters: [clickhouse]
|
||||
logs:
|
||||
receivers: [otlp, fluentforward]
|
||||
processors: [memory_limiter, batch]
|
||||
exporters: [clickhouse]
|
||||
|
|
@ -22,11 +22,6 @@ processors:
|
|||
- docker
|
||||
timeout: 5s
|
||||
override: false
|
||||
attributes/attachHdxKey:
|
||||
actions:
|
||||
- key: __HDX_API_KEY
|
||||
from_context: authorization
|
||||
action: upsert
|
||||
batch:
|
||||
memory_limiter:
|
||||
# 80% of maximum memory up to 2G
|
||||
|
|
@ -60,18 +55,18 @@ service:
|
|||
metrics:
|
||||
address: ':8888'
|
||||
logs:
|
||||
level: debug
|
||||
level: ${HYPERDX_LOG_LEVEL}
|
||||
extensions: [health_check]
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [attributes/attachHdxKey, memory_limiter, batch]
|
||||
processors: [memory_limiter, batch]
|
||||
exporters: [debug, clickhouse]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [attributes/attachHdxKey, memory_limiter, batch]
|
||||
processors: [memory_limiter, batch]
|
||||
exporters: [debug, clickhouse]
|
||||
logs:
|
||||
receivers: [otlp, fluentforward]
|
||||
processors: [attributes/attachHdxKey, memory_limiter, batch]
|
||||
processors: [memory_limiter, batch]
|
||||
exporters: [debug, clickhouse]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "hyperdx",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "2-beta",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
|
|
|||
|
|
@ -1,29 +1,5 @@
|
|||
# @hyperdx/api
|
||||
|
||||
## 1.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7b4993c: feat: support Summary metrics data type
|
||||
- 3b5ec64: feat: copy "deployment.environment" resource attribute to the top
|
||||
level (span)
|
||||
- f322f46: chore: bump @clickhouse/client to v1.4.1
|
||||
- 4d6b362: feat: support sentry sdk 'X-Sentry-Auth' header
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 19a4086: fix: query to extract metrics names
|
||||
- b47c965: fix: remove webhook total counts limit
|
||||
- 90a5ca7: fix: validate the min length of series field at charts series
|
||||
endpoint
|
||||
- f7ae1a4: perf: use route transform to reroute go spans
|
||||
- c5efb84: perf: enable compression for insertion
|
||||
- ac88c52: style: introduce AGGREGATOR_PAYLOAD_SIZE_LIMIT env var
|
||||
- 523443e: fix: aggregator should return 413 (Content Too Large) to make
|
||||
ingestor ARC work properly
|
||||
- f1da5b6: chore: bump vector to v0.40.1
|
||||
- 3b5ec64: feat: support environment filtering
|
||||
|
||||
## 1.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
import express from 'express';
|
||||
import { uniq } from 'lodash';
|
||||
import { z } from 'zod';
|
||||
import { validateRequest } from 'zod-express-middleware';
|
||||
|
||||
import Alert from '@/models/alert';
|
||||
import LogView from '@/models/logView';
|
||||
import { objectIdSchema, tagsSchema } from '@/utils/zod';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
validateRequest({
|
||||
body: z.object({
|
||||
name: z.string().max(1024).min(1),
|
||||
query: z.string().max(2048),
|
||||
tags: tagsSchema,
|
||||
}),
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const teamId = req.user?.team;
|
||||
const userId = req.user?._id;
|
||||
if (teamId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
|
||||
const { query, name, tags } = req.body;
|
||||
|
||||
const logView = await new LogView({
|
||||
name,
|
||||
tags: tags && uniq(tags),
|
||||
query: `${query}`,
|
||||
team: teamId,
|
||||
creator: userId,
|
||||
}).save();
|
||||
|
||||
res.json({
|
||||
data: logView,
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const teamId = req.user?.team;
|
||||
if (teamId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
const logViews = await LogView.find(
|
||||
{ team: teamId },
|
||||
{
|
||||
name: 1,
|
||||
query: 1,
|
||||
tags: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
).sort({ createdAt: -1 });
|
||||
const allAlerts = await Promise.all(
|
||||
logViews.map(lv => Alert.find({ logView: lv._id }, { __v: 0 })),
|
||||
);
|
||||
res.json({
|
||||
data: logViews.map((lv, idx) => ({
|
||||
...lv.toJSON(),
|
||||
alerts: allAlerts[idx],
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch(
|
||||
'/:id',
|
||||
validateRequest({
|
||||
params: z.object({
|
||||
id: objectIdSchema,
|
||||
}),
|
||||
body: z.object({
|
||||
name: z.string().max(1024).min(1).optional(),
|
||||
query: z.string().max(2048).optional(),
|
||||
tags: tagsSchema,
|
||||
}),
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const teamId = req.user?.team;
|
||||
const { id: logViewId } = req.params;
|
||||
if (teamId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
|
||||
const { query, tags, name } = req.body;
|
||||
const logView = await LogView.findOneAndUpdate(
|
||||
{
|
||||
_id: logViewId,
|
||||
team: teamId,
|
||||
},
|
||||
{
|
||||
...(name && { name }),
|
||||
...(query && { query }),
|
||||
tags: tags && uniq(tags),
|
||||
},
|
||||
{ new: true },
|
||||
);
|
||||
res.json({
|
||||
data: logView,
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const teamId = req.user?.team;
|
||||
const { id: logViewId } = req.params;
|
||||
if (teamId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
if (!logViewId) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
// TODO: query teamId
|
||||
// delete all alerts
|
||||
await Alert.deleteMany({ logView: logViewId });
|
||||
await LogView.findByIdAndDelete(logViewId);
|
||||
res.sendStatus(200);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@hyperdx/app",
|
||||
"version": "1.9.0",
|
||||
"version": "2-beta",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
import AlertsPage from '@/AlertsPage';
|
||||
|
||||
export default AlertsPage;
|
||||
|
|
@ -1,363 +0,0 @@
|
|||
import { useRouter } from 'next/router';
|
||||
|
||||
import DashboardPage from '@/DashboardPage';
|
||||
import { withAppNav } from '@/layout';
|
||||
|
||||
const APP_PERFORMANCE_DASHBOARD_CONFIG = {
|
||||
id: '',
|
||||
name: 'App Performance',
|
||||
charts: [
|
||||
{
|
||||
id: '1624425',
|
||||
name: 'P95 Latency by Operation',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 8,
|
||||
h: 3,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'p95',
|
||||
field: 'duration',
|
||||
where: '',
|
||||
groupBy: ['span_name'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '401924',
|
||||
name: 'Operations with Errors',
|
||||
x: 8,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 3,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: 'level:err',
|
||||
groupBy: ['span_name'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '883200',
|
||||
name: 'Count of Operations',
|
||||
x: 0,
|
||||
y: 3,
|
||||
w: 8,
|
||||
h: 3,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: '',
|
||||
groupBy: ['span_name'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const HTTP_SERVER_DASHBOARD_CONFIG = {
|
||||
id: '',
|
||||
name: 'HTTP Server',
|
||||
charts: [
|
||||
{
|
||||
id: '312739',
|
||||
name: 'P95 Latency by Endpoint',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 2,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'p95',
|
||||
field: 'duration',
|
||||
where: 'span.kind:server',
|
||||
groupBy: ['http.route'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '434437',
|
||||
name: 'HTTP Status Codes',
|
||||
x: 0,
|
||||
y: 2,
|
||||
w: 6,
|
||||
h: 2,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: 'span.kind:server',
|
||||
groupBy: ['http.status_code'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '69137',
|
||||
name: 'HTTP 4xx, 5xx',
|
||||
x: 6,
|
||||
y: 4,
|
||||
w: 6,
|
||||
h: 2,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: 'http.status_code:>=400 span.kind:server',
|
||||
groupBy: ['http.status_code'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '34708',
|
||||
name: 'HTTP 5xx by Endpoint',
|
||||
x: 6,
|
||||
y: 2,
|
||||
w: 6,
|
||||
h: 2,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: 'span.kind:server http.status_code:>=500',
|
||||
groupBy: ['http.route'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '58773',
|
||||
name: 'Request Volume by Endpoint',
|
||||
x: 6,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 2,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: 'span.kind:server',
|
||||
groupBy: ['http.route'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const REDIS_DASHBOARD_CONFIG = {
|
||||
id: '',
|
||||
name: 'Redis',
|
||||
charts: [
|
||||
{
|
||||
id: '38463',
|
||||
name: 'GET Operations',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 2,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: 'db.system:"redis" span_name:GET',
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '488836',
|
||||
name: 'P95 GET Latency',
|
||||
x: 0,
|
||||
y: 2,
|
||||
w: 6,
|
||||
h: 2,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'p95',
|
||||
field: 'duration',
|
||||
where: 'db.system:"redis" span_name:GET',
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '8355753',
|
||||
name: 'SET Operations',
|
||||
x: 6,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 2,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: 'db.system:"redis" span_name:SET',
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '93278',
|
||||
name: 'P95 SET Latency',
|
||||
x: 6,
|
||||
y: 2,
|
||||
w: 6,
|
||||
h: 2,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'p95',
|
||||
field: 'duration',
|
||||
where: 'db.system:"redis" span_name:SET',
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const MONGO_DASHBOARD_CONFIG = {
|
||||
id: '',
|
||||
name: 'MongoDB',
|
||||
charts: [
|
||||
{
|
||||
id: '98180',
|
||||
name: 'P95 Read Operation Latency by Collection',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 3,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'p95',
|
||||
field: 'duration',
|
||||
where:
|
||||
'db.system:mongo (db.operation:"find" OR db.operation:"findOne" OR db.operation:"aggregate")',
|
||||
groupBy: ['db.mongodb.collection'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '28877',
|
||||
name: 'P95 Write Operation Latency by Collection',
|
||||
x: 6,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 3,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'p95',
|
||||
field: 'duration',
|
||||
where:
|
||||
'db.system:mongo (db.operation:"insert" OR db.operation:"findOneAndUpdate" OR db.operation:"save" OR db.operation:"findAndModify")',
|
||||
groupBy: ['db.mongodb.collection'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '9901546',
|
||||
name: 'Count of Write Operations by Collection',
|
||||
x: 6,
|
||||
y: 3,
|
||||
w: 6,
|
||||
h: 3,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where:
|
||||
'db.system:mongo (db.operation:"insert" OR db.operation:"findOneAndUpdate" OR db.operation:"save" OR db.operation:"findAndModify")',
|
||||
groupBy: ['db.mongodb.collection'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '6894669',
|
||||
name: 'Count of Read Operations by Collection',
|
||||
x: 0,
|
||||
y: 3,
|
||||
w: 6,
|
||||
h: 3,
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where:
|
||||
'db.system:mongo (db.operation:"find" OR db.operation:"findOne" OR db.operation:"aggregate")',
|
||||
groupBy: ['db.mongodb.collection'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const HYPERDX_USAGE_DASHBOARD_CONFIG = {
|
||||
id: '',
|
||||
name: 'HyperDX Usage',
|
||||
charts: [
|
||||
{
|
||||
id: '15gykg',
|
||||
name: 'Log/Span Usage in Bytes',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
series: [
|
||||
{
|
||||
table: 'logs',
|
||||
type: 'number',
|
||||
aggFn: 'sum',
|
||||
field: 'hyperdx_event_size',
|
||||
where: '',
|
||||
groupBy: [],
|
||||
numberFormat: {
|
||||
output: 'byte',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '1k5pul',
|
||||
name: 'Logs/Span Usage over Time',
|
||||
x: 3,
|
||||
y: 0,
|
||||
w: 9,
|
||||
h: 3,
|
||||
series: [
|
||||
{
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'sum',
|
||||
field: 'hyperdx_event_size',
|
||||
where: '',
|
||||
groupBy: [],
|
||||
numberFormat: {
|
||||
output: 'byte',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PRESETS: Record<string, any> = {
|
||||
'app-performance': APP_PERFORMANCE_DASHBOARD_CONFIG,
|
||||
'http-server': HTTP_SERVER_DASHBOARD_CONFIG,
|
||||
redis: REDIS_DASHBOARD_CONFIG,
|
||||
mongo: MONGO_DASHBOARD_CONFIG,
|
||||
'hyperdx-usage': HYPERDX_USAGE_DASHBOARD_CONFIG,
|
||||
};
|
||||
|
||||
export default function DashboardPresetPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const presetName = router.query.presetName as string;
|
||||
|
||||
const presetConfig = PRESETS[presetName] as any;
|
||||
|
||||
return <DashboardPage presetConfig={presetConfig} />;
|
||||
}
|
||||
|
||||
DashboardPresetPage.getLayout = withAppNav;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import KubernetesDashboardPage from '@/KubernetesDashboardPage';
|
||||
|
||||
export default KubernetesDashboardPage;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import SessionsPage from '@/SessionsPage';
|
||||
|
||||
export default SessionsPage;
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { sub } from 'date-fns';
|
||||
import { Form, FormSelectProps } from 'react-bootstrap';
|
||||
|
||||
import api from './api';
|
||||
import { Granularity } from './ChartUtils';
|
||||
import type { AlertChannelType, AlertInterval } from './types';
|
||||
|
||||
export function intervalToGranularity(interval: AlertInterval) {
|
||||
if (interval === '1m') return Granularity.OneMinute;
|
||||
if (interval === '5m') return Granularity.FiveMinute;
|
||||
if (interval === '15m') return Granularity.FifteenMinute;
|
||||
if (interval === '30m') return Granularity.ThirtyMinute;
|
||||
if (interval === '1h') return Granularity.OneHour;
|
||||
if (interval === '6h') return Granularity.SixHour;
|
||||
if (interval === '12h') return Granularity.TwelveHour;
|
||||
if (interval === '1d') return Granularity.OneDay;
|
||||
return Granularity.OneDay;
|
||||
}
|
||||
|
||||
export function intervalToDateRange(interval: AlertInterval): [Date, Date] {
|
||||
const now = new Date();
|
||||
if (interval === '1m') return [sub(now, { minutes: 15 }), now];
|
||||
if (interval === '5m') return [sub(now, { hours: 1 }), now];
|
||||
if (interval === '15m') return [sub(now, { hours: 4 }), now];
|
||||
if (interval === '30m') return [sub(now, { hours: 8 }), now];
|
||||
if (interval === '1h') return [sub(now, { hours: 16 }), now];
|
||||
if (interval === '6h') return [sub(now, { days: 4 }), now];
|
||||
if (interval === '12h') return [sub(now, { days: 7 }), now];
|
||||
if (interval === '1d') return [sub(now, { days: 7 }), now];
|
||||
return [now, now];
|
||||
}
|
||||
|
||||
export const ALERT_INTERVAL_OPTIONS: Record<AlertInterval, string> = {
|
||||
'1m': '1 minute',
|
||||
'5m': '5 minute',
|
||||
'15m': '15 minute',
|
||||
'30m': '30 minute',
|
||||
'1h': '1 hour',
|
||||
'6h': '6 hour',
|
||||
'12h': '12 hour',
|
||||
'1d': '1 day',
|
||||
};
|
||||
|
||||
export const ALERT_CHANNEL_OPTIONS: Record<AlertChannelType, string> = {
|
||||
webhook: 'Webhook',
|
||||
};
|
||||
|
||||
export const WebhookChannelForm = ({
|
||||
webhookSelectProps,
|
||||
}: {
|
||||
webhookSelectProps: FormSelectProps;
|
||||
}) => {
|
||||
const { data: webhooks } = api.useWebhooks(['slack', 'generic']);
|
||||
|
||||
const hasWebhooks = Array.isArray(webhooks?.data) && webhooks.data.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-3">
|
||||
<Form.Label className="text-muted">Webhook</Form.Label>
|
||||
<Form.Select
|
||||
className="bg-black border-0 mb-1 px-3"
|
||||
required
|
||||
id="webhookId"
|
||||
size="sm"
|
||||
{...webhookSelectProps}
|
||||
>
|
||||
{/* Ensure user selects a slack webhook before submitting form */}
|
||||
<option value="" disabled selected>
|
||||
{hasWebhooks ? 'Select a Webhook' : 'No Webhooks available'}
|
||||
</option>
|
||||
{webhooks?.data.map((sw: any) => (
|
||||
<option key={sw._id} value={sw._id}>
|
||||
{sw.name}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<a
|
||||
href="/team"
|
||||
target="_blank"
|
||||
className="text-muted-hover d-flex align-items-center gap-1 fs-8"
|
||||
>
|
||||
<i className="bi bi-plus fs-5" />
|
||||
Add New Incoming Webhook
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,519 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import cx from 'classnames';
|
||||
import { add, Duration, formatRelative } from 'date-fns';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { ArrayParam, useQueryParam, withDefault } from 'use-query-params';
|
||||
import {
|
||||
Alert as MAlert,
|
||||
Badge,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Menu,
|
||||
Stack,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
import api from './api';
|
||||
import { withAppNav } from './layout';
|
||||
import { Tags } from './Tags';
|
||||
import type { Alert, AlertHistory, LogView } from './types';
|
||||
import { AlertState } from './types';
|
||||
import { FormatTime } from './useFormatTime';
|
||||
|
||||
import styles from '../styles/AlertsPage.module.scss';
|
||||
|
||||
type AlertData = Alert & {
|
||||
history: AlertHistory[];
|
||||
dashboard?: {
|
||||
_id: string;
|
||||
name: string;
|
||||
charts: { id: string; name: string }[];
|
||||
tags?: string[];
|
||||
};
|
||||
logView?: LogView;
|
||||
};
|
||||
|
||||
const DISABLE_ALERTS_ENABLED = false;
|
||||
|
||||
function AlertHistoryCard({ history }: { history: AlertHistory }) {
|
||||
const start = new Date(history.createdAt.toString());
|
||||
const today = React.useMemo(() => new Date(), []);
|
||||
const latestHighestValue = history.lastValues.length
|
||||
? Math.max(...history.lastValues.map(({ count }) => count))
|
||||
: 0;
|
||||
return (
|
||||
<Tooltip
|
||||
label={latestHighestValue + ' ' + formatRelative(start, today)}
|
||||
color="dark"
|
||||
withArrow
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
styles.historyCard,
|
||||
history.state === AlertState.OK ? styles.ok : styles.alarm,
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const HISTORY_ITEMS = 18;
|
||||
|
||||
function AlertHistoryCardList({ history }: { history: AlertHistory[] }) {
|
||||
const items = React.useMemo(() => {
|
||||
if (history.length < HISTORY_ITEMS) {
|
||||
return history;
|
||||
}
|
||||
return history.slice(0, HISTORY_ITEMS);
|
||||
}, [history]);
|
||||
|
||||
const paddingItems = React.useMemo(() => {
|
||||
if (history.length > HISTORY_ITEMS) {
|
||||
return [];
|
||||
}
|
||||
return new Array(HISTORY_ITEMS - history.length).fill(null);
|
||||
}, [history]);
|
||||
|
||||
return (
|
||||
<div className={styles.historyCardWrapper}>
|
||||
{paddingItems.map((_, index) => (
|
||||
<Tooltip label="No data" color="dark" withArrow key={index}>
|
||||
<div className={styles.historyCard} />
|
||||
</Tooltip>
|
||||
))}
|
||||
{[...items].reverse().map((history, index) => (
|
||||
<AlertHistoryCard key={index} history={history} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function disableAlert(alertId?: string) {
|
||||
if (!alertId) {
|
||||
return; // no ID yet to disable?
|
||||
}
|
||||
// TODO do some lovely disabling of the alert here
|
||||
}
|
||||
|
||||
function AckAlert({ alert }: { alert: Alert }) {
|
||||
const queryClient = useQueryClient();
|
||||
const silenceAlert = api.useSilenceAlert();
|
||||
const unsilenceAlert = api.useUnsilenceAlert();
|
||||
|
||||
const mutateOptions = React.useMemo(
|
||||
() => ({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries('alerts');
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message: 'Failed to silence alert, please try again later.',
|
||||
});
|
||||
},
|
||||
}),
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const handleUnsilenceAlert = React.useCallback(() => {
|
||||
unsilenceAlert.mutate(alert._id || '', mutateOptions); // TODO: update types
|
||||
}, [alert._id, mutateOptions, unsilenceAlert]);
|
||||
|
||||
const isNoLongerMuted = React.useMemo(() => {
|
||||
return alert.silenced ? new Date() > new Date(alert.silenced.until) : false;
|
||||
}, [alert.silenced]);
|
||||
|
||||
const handleSilenceAlert = React.useCallback(
|
||||
(duration: Duration) => {
|
||||
const mutedUntil = add(new Date(), duration);
|
||||
silenceAlert.mutate(
|
||||
{
|
||||
alertId: alert._id || '', // TODO: update types
|
||||
mutedUntil,
|
||||
},
|
||||
mutateOptions,
|
||||
);
|
||||
},
|
||||
[alert._id, mutateOptions, silenceAlert],
|
||||
);
|
||||
|
||||
if (alert.silenced?.at) {
|
||||
return (
|
||||
<ErrorBoundary fallback={<>Something went wrong</>}>
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color={isNoLongerMuted ? 'orange' : 'green'}
|
||||
leftSection={<i className="bi bi-bell-slash fs-8" />}
|
||||
>
|
||||
Ack'd
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label py={6}>
|
||||
Acknowledged{' '}
|
||||
{alert.silenced?.by ? (
|
||||
<>
|
||||
by <strong>{alert.silenced?.by}</strong>
|
||||
</>
|
||||
) : null}{' '}
|
||||
on <br />
|
||||
<FormatTime value={alert.silenced?.at} />
|
||||
.<br />
|
||||
</Menu.Label>
|
||||
|
||||
<Menu.Label py={6}>
|
||||
{isNoLongerMuted ? (
|
||||
'Alert resumed.'
|
||||
) : (
|
||||
<>
|
||||
Resumes <FormatTime value={alert.silenced.until} />.
|
||||
</>
|
||||
)}
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
lh="1"
|
||||
py={8}
|
||||
color="orange"
|
||||
onClick={handleUnsilenceAlert}
|
||||
disabled={unsilenceAlert.isLoading}
|
||||
>
|
||||
{isNoLongerMuted ? 'Unacknowledge' : 'Resume alert'}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (alert.state === 'ALERT') {
|
||||
return (
|
||||
<ErrorBoundary fallback={<>Something went wrong</>}>
|
||||
<Menu disabled={silenceAlert.isLoading}>
|
||||
<Menu.Target>
|
||||
<Button size="compact-sm" variant="default">
|
||||
Ack
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label lh="1" py={6}>
|
||||
Acknowledge and silence for
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
lh="1"
|
||||
py={8}
|
||||
onClick={() =>
|
||||
handleSilenceAlert({
|
||||
minutes: 30,
|
||||
})
|
||||
}
|
||||
>
|
||||
30 minutes
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
lh="1"
|
||||
py={8}
|
||||
onClick={() =>
|
||||
handleSilenceAlert({
|
||||
hours: 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
1 hour
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
lh="1"
|
||||
py={8}
|
||||
onClick={() =>
|
||||
handleSilenceAlert({
|
||||
hours: 6,
|
||||
})
|
||||
}
|
||||
>
|
||||
6 hours
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
lh="1"
|
||||
py={8}
|
||||
onClick={() =>
|
||||
handleSilenceAlert({
|
||||
hours: 24,
|
||||
})
|
||||
}
|
||||
>
|
||||
24 hours
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function AlertDetails({ alert }: { alert: AlertData }) {
|
||||
const alertName = React.useMemo(() => {
|
||||
if (alert.source === 'CHART' && alert.dashboard) {
|
||||
const chartName = alert.dashboard.charts.find(
|
||||
chart => chart.id === alert.chartId,
|
||||
)?.name;
|
||||
return (
|
||||
<>
|
||||
{alert.dashboard.name}
|
||||
{chartName ? (
|
||||
<>
|
||||
<i className="bi bi-chevron-right fs-8 mx-1 text-slate-400" />
|
||||
{chartName}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (alert.source === 'LOG' && alert.logView) {
|
||||
return alert.logView?.name;
|
||||
}
|
||||
return '–';
|
||||
}, [alert]);
|
||||
|
||||
const alertUrl = React.useMemo(() => {
|
||||
if (alert.source === 'CHART' && alert.dashboard) {
|
||||
return `/dashboards/${alert.dashboard._id}?highlightedChartId=${alert.chartId}`;
|
||||
}
|
||||
if (alert.source === 'LOG' && alert.logView) {
|
||||
return `/search/${alert.logView._id}`;
|
||||
}
|
||||
return '';
|
||||
}, [alert]);
|
||||
|
||||
return (
|
||||
<div className={styles.alertRow}>
|
||||
<Group>
|
||||
{alert.state === AlertState.ALERT && (
|
||||
<Badge variant="light" color="red">
|
||||
Alert
|
||||
</Badge>
|
||||
)}
|
||||
{alert.state === AlertState.OK && <Badge variant="light">Ok</Badge>}
|
||||
{alert.state === AlertState.DISABLED && (
|
||||
<Badge variant="light" color="gray">
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap={2}>
|
||||
<div>
|
||||
<Link
|
||||
href={alertUrl}
|
||||
className={styles.alertLink}
|
||||
title={
|
||||
alert.source === 'CHART' ? 'Dashboard chart' : 'Saved search'
|
||||
}
|
||||
>
|
||||
<i
|
||||
className={`bi ${
|
||||
alert.source === 'CHART'
|
||||
? 'bi-graph-up'
|
||||
: 'bi-layout-text-sidebar-reverse'
|
||||
} text-slate-200 me-2 fs-8`}
|
||||
/>
|
||||
{alertName}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-slate-400 fs-8 d-flex gap-2">
|
||||
If {alert.source === 'LOG' ? 'count' : 'value'} is{' '}
|
||||
{alert.type === 'presence' ? 'at least' : 'under'}{' '}
|
||||
<span className="fw-bold">{alert.threshold}</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
{alert.channel.type === 'webhook' && (
|
||||
<span>Notify via Webhook</span>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<AckAlert alert={alert} />
|
||||
<AlertHistoryCardList history={alert.history} />
|
||||
{/* can we disable an alert that is alarming? hmmmmm */}
|
||||
{/* also, will make the alert jump from under the cursor to the disabled area */}
|
||||
{DISABLE_ALERTS_ENABLED ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
disableAlert(alert._id);
|
||||
}}
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertCardList({ alerts }: { alerts: AlertData[] }) {
|
||||
const alarmAlerts = alerts.filter(alert => alert.state === AlertState.ALERT);
|
||||
const okData = alerts.filter(alert => alert.state === AlertState.OK);
|
||||
const disabledData = alerts.filter(
|
||||
alert =>
|
||||
alert.state === AlertState.DISABLED ||
|
||||
alert.state === AlertState.INSUFFICIENT_DATA,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column gap-4">
|
||||
{alarmAlerts.length > 0 && (
|
||||
<div>
|
||||
<div className={styles.sectionHeader}>
|
||||
<i className="bi bi-exclamation-triangle"></i> Triggered
|
||||
</div>
|
||||
{alarmAlerts.map((alert, index) => (
|
||||
<AlertDetails key={index} alert={alert} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className={styles.sectionHeader}>
|
||||
<i className="bi bi-check-lg"></i> OK
|
||||
</div>
|
||||
{okData.length === 0 && (
|
||||
<div className="text-center text-slate-400 my-4 fs-8">No alerts</div>
|
||||
)}
|
||||
{okData.map((alert, index) => (
|
||||
<AlertDetails key={index} alert={alert} />
|
||||
))}
|
||||
</div>
|
||||
{DISABLE_ALERTS_ENABLED && (
|
||||
<div>
|
||||
<div className={styles.sectionHeader}>
|
||||
<i className="bi bi-stop"></i> Disabled
|
||||
</div>
|
||||
{disabledData.length === 0 && (
|
||||
<div className="text-center text-slate-400 my-4 fs-8">
|
||||
No alerts
|
||||
</div>
|
||||
)}
|
||||
{disabledData.map((alert, index) => (
|
||||
<AlertDetails key={index} alert={alert} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlertsPage() {
|
||||
const { data, isError, isLoading } = api.useAlerts();
|
||||
const alerts = React.useMemo(
|
||||
() => (data?.data || []) as AlertData[],
|
||||
[data?.data],
|
||||
);
|
||||
|
||||
// TODO: Error and loading states
|
||||
|
||||
const [_tags, setTags] = useQueryParam(
|
||||
'tags',
|
||||
withDefault(ArrayParam, [] as (string | null)[]),
|
||||
{ updateType: 'replaceIn' },
|
||||
);
|
||||
const tags = React.useMemo(() => _tags.filter(Boolean) as string[], [_tags]);
|
||||
|
||||
const filteredAlerts = React.useMemo(() => {
|
||||
if (!tags.length) {
|
||||
return alerts;
|
||||
}
|
||||
return alerts.filter(alert =>
|
||||
[...(alert.dashboard?.tags || []), ...(alert.logView?.tags || [])].some(
|
||||
tag => tags.includes(tag),
|
||||
),
|
||||
);
|
||||
}, [tags, alerts]);
|
||||
|
||||
return (
|
||||
<div className="AlertsPage">
|
||||
<Head>
|
||||
<title>Alerts - HyperDX</title>
|
||||
</Head>
|
||||
<div className={styles.header}>Alerts</div>
|
||||
<div className="my-4">
|
||||
<Container>
|
||||
<MAlert
|
||||
icon={<i className="bi bi-info-circle-fill text-slate-400" />}
|
||||
color="gray"
|
||||
>
|
||||
Alerts can be{' '}
|
||||
<a
|
||||
href="https://www.hyperdx.io/docs/alerts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
created
|
||||
</a>{' '}
|
||||
from dashboard charts and saved searches.
|
||||
</MAlert>
|
||||
{isLoading ? (
|
||||
<div className="text-center text-slate-400 my-4 fs-8">
|
||||
Loading...
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="text-center text-slate-400 my-4 fs-8">Error</div>
|
||||
) : alerts?.length ? (
|
||||
<>
|
||||
<Button.Group mt="xl">
|
||||
<Tags values={tags} onChange={setTags}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={
|
||||
<i
|
||||
className={cx(
|
||||
'bi bi-funnel-fill',
|
||||
tags.length ? 'text-success' : 'text-slate-400',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{tags.length ? (
|
||||
<>
|
||||
<span className="text-slate-400 me-1">Tags </span>
|
||||
{tags?.join(', ')}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-slate-400">Filter by Tags</span>
|
||||
)}
|
||||
</Button>
|
||||
</Tags>
|
||||
{tags.length > 0 && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => setTags([])}
|
||||
px="xs"
|
||||
>
|
||||
<i className="bi bi-x-lg" />
|
||||
</Button>
|
||||
)}
|
||||
</Button.Group>
|
||||
<AlertCardList alerts={filteredAlerts} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-slate-400 my-4 fs-8">
|
||||
No alerts created yet
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AlertsPage.getLayout = withAppNav;
|
||||
|
|
@ -210,22 +210,16 @@ export const AppNavHelpMenu = ({
|
|||
</Menu.Label>
|
||||
|
||||
<Menu.Item
|
||||
href="https://hyperdx.io/docs"
|
||||
href="https://hyperdx.io/docs/v2"
|
||||
component="a"
|
||||
leftSection={<Icon name="book" />}
|
||||
>
|
||||
Documentation
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Icon name="lightbulb" />}
|
||||
onClick={openInstallModal}
|
||||
>
|
||||
Setup Instructions
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Icon name="discord" />}
|
||||
component="a"
|
||||
href="https://discord.gg/FErRRKU78j"
|
||||
href="https://hyperdx.io/discord"
|
||||
target="_blank"
|
||||
>
|
||||
Discord Community
|
||||
|
|
|
|||
|
|
@ -1,196 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Head from 'next/head';
|
||||
import {
|
||||
parseAsJson,
|
||||
parseAsStringEnum,
|
||||
parseAsStringLiteral,
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
|
||||
import { Granularity } from './ChartUtils';
|
||||
import EditTileForm from './EditTileForm';
|
||||
import { withAppNav } from './layout';
|
||||
import { parseTimeQuery, useNewTimeQuery } from './timeQuery';
|
||||
import type { Chart, ChartSeries } from './types';
|
||||
|
||||
function getDashboard(chart: Chart) {
|
||||
return {
|
||||
_id: '',
|
||||
name: 'My New Dashboard',
|
||||
charts: [chart],
|
||||
alerts: [],
|
||||
tags: [],
|
||||
query: '',
|
||||
};
|
||||
}
|
||||
|
||||
function getDashboardHref({
|
||||
chart,
|
||||
dateRange,
|
||||
granularity,
|
||||
timeQuery,
|
||||
}: {
|
||||
chart: Chart;
|
||||
dateRange: [Date, Date];
|
||||
timeQuery: string;
|
||||
granularity: Granularity | undefined;
|
||||
}) {
|
||||
const dashboard = getDashboard(chart);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
config: JSON.stringify(dashboard),
|
||||
tq: timeQuery,
|
||||
...(dateRange[0] != null && dateRange[1] != null
|
||||
? { from: dateRange[0].toISOString(), to: dateRange[1].toISOString() }
|
||||
: {}),
|
||||
...(granularity ? { granularity } : {}),
|
||||
});
|
||||
|
||||
return `/dashboards?${params.toString()}`;
|
||||
}
|
||||
|
||||
const DEFAULT_SERIES: ChartSeries[] = [
|
||||
{
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
field: undefined,
|
||||
where: '',
|
||||
groupBy: [],
|
||||
},
|
||||
];
|
||||
|
||||
const getLegacySeriesQueryParam = () => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const series = params.getAll('series');
|
||||
return series?.flatMap(series =>
|
||||
series != null ? [JSON.parse(series)] : [],
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse legacy query param', e);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: This is a hack to set the default time range
|
||||
const defaultTimeRange = parseTimeQuery('Past 1h', false) as [Date, Date];
|
||||
|
||||
function GraphPage() {
|
||||
const [_granularity, _setGranularity] = useQueryState(
|
||||
'granularity',
|
||||
parseAsStringEnum<Granularity>(Object.values(Granularity)),
|
||||
);
|
||||
|
||||
const granularity = _granularity ?? undefined;
|
||||
const setGranularity = useCallback(
|
||||
(value: Granularity | undefined) => {
|
||||
_setGranularity(value || null);
|
||||
},
|
||||
[_setGranularity],
|
||||
);
|
||||
|
||||
const [seriesReturnType, setSeriesReturnType] = useQueryState(
|
||||
'seriesReturnType',
|
||||
parseAsStringLiteral(['ratio', 'column'] as const),
|
||||
);
|
||||
|
||||
const [chartSeries, setChartSeries] = useQueryState(
|
||||
'chartSeries',
|
||||
parseAsJson<ChartSeries[]>().withDefault(DEFAULT_SERIES),
|
||||
);
|
||||
|
||||
// Support for legacy query param
|
||||
const [_, setLegacySeries] = useQueryState('series', {
|
||||
shallow: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const legacySeries = getLegacySeriesQueryParam();
|
||||
if (legacySeries?.length) {
|
||||
// Clear the legacy query param
|
||||
setChartSeries(legacySeries);
|
||||
setLegacySeries(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editedChart = useMemo<Chart>(() => {
|
||||
return {
|
||||
id: 'chart-explorer',
|
||||
name: 'My New Chart',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 3,
|
||||
series: chartSeries.length ? chartSeries : DEFAULT_SERIES,
|
||||
seriesReturnType: seriesReturnType ?? 'column',
|
||||
};
|
||||
}, [chartSeries, seriesReturnType]);
|
||||
|
||||
const setEditedChart = useCallback(
|
||||
(chart: Chart) => {
|
||||
setChartSeries(chart.series);
|
||||
setSeriesReturnType(chart.seriesReturnType);
|
||||
},
|
||||
[setChartSeries, setSeriesReturnType],
|
||||
);
|
||||
|
||||
const { isReady, searchedTimeRange, displayedTimeInputValue, onSearch } =
|
||||
useNewTimeQuery({
|
||||
initialDisplayValue: 'Past 1h',
|
||||
initialTimeRange: defaultTimeRange,
|
||||
});
|
||||
|
||||
const [input, setInput] = useState<string>(displayedTimeInputValue);
|
||||
useEffect(() => {
|
||||
setInput(displayedTimeInputValue);
|
||||
}, [displayedTimeInputValue]);
|
||||
|
||||
return (
|
||||
<div className="LogViewerPage">
|
||||
<Head>
|
||||
<title>Chart Explorer - HyperDX</title>
|
||||
</Head>
|
||||
<div
|
||||
style={{ minHeight: '100vh' }}
|
||||
className="d-flex flex-column bg-hdx-dark p-3"
|
||||
>
|
||||
{isReady ? (
|
||||
<EditTileForm
|
||||
chart={editedChart}
|
||||
isLocalDashboard
|
||||
dateRange={searchedTimeRange}
|
||||
editedChart={editedChart}
|
||||
setEditedChart={setEditedChart}
|
||||
displayedTimeInputValue={input}
|
||||
setDisplayedTimeInputValue={setInput}
|
||||
onTimeRangeSearch={onSearch}
|
||||
granularity={granularity}
|
||||
setGranularity={setGranularity}
|
||||
createDashboardHref={getDashboardHref({
|
||||
timeQuery: displayedTimeInputValue,
|
||||
chart: editedChart,
|
||||
dateRange: searchedTimeRange,
|
||||
granularity,
|
||||
})}
|
||||
hideSearch
|
||||
hideMarkdown
|
||||
/>
|
||||
) : (
|
||||
'Loading...'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GraphPage.getLayout = withAppNav;
|
||||
|
||||
// TODO: Restore when we fix hydratrion errors
|
||||
// export default GraphPage;
|
||||
|
||||
const GraphPageDynamic = dynamic(async () => GraphPage, { ssr: false });
|
||||
// @ts-ignore
|
||||
GraphPageDynamic.getLayout = withAppNav;
|
||||
|
||||
export default GraphPageDynamic;
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import type { Control } from 'react-hook-form';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import Select from 'react-select';
|
||||
|
||||
export default function ControllerSelect<
|
||||
Option extends { value: string | undefined; label: React.ReactNode },
|
||||
>({
|
||||
control,
|
||||
defaultValue,
|
||||
name,
|
||||
options,
|
||||
}: {
|
||||
options: Option[];
|
||||
defaultValue: string | undefined;
|
||||
name: string;
|
||||
control: Control<any>;
|
||||
}) {
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
name={name}
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Select
|
||||
ref={ref}
|
||||
className="ds-select"
|
||||
classNamePrefix="ds-react-select"
|
||||
options={options}
|
||||
value={options.find(c => c.value === value)}
|
||||
onChange={val => onChange(val?.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,447 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Form, Modal } from 'react-bootstrap';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
import {
|
||||
ALERT_CHANNEL_OPTIONS,
|
||||
ALERT_INTERVAL_OPTIONS,
|
||||
intervalToDateRange,
|
||||
intervalToGranularity,
|
||||
WebhookChannelForm,
|
||||
} from './Alert';
|
||||
import api from './api';
|
||||
import { FieldSelect } from './ChartUtils';
|
||||
import HDXMultiSeriesTimeChart from './HDXMultiSeriesTimeChart';
|
||||
import { genEnglishExplanation } from './queryv2';
|
||||
import TabBar from './TabBar';
|
||||
import type {
|
||||
AlertChannelType,
|
||||
AlertInterval,
|
||||
AlertType,
|
||||
LogView,
|
||||
} from './types';
|
||||
import { capitalizeFirstLetter } from './utils';
|
||||
|
||||
function AlertForm({
|
||||
alertId,
|
||||
defaultValues,
|
||||
onDeleteClick,
|
||||
onSubmit,
|
||||
query,
|
||||
}: {
|
||||
defaultValues:
|
||||
| {
|
||||
groupBy: string | undefined;
|
||||
interval: AlertInterval;
|
||||
threshold: number;
|
||||
type: AlertType;
|
||||
channelType: AlertChannelType;
|
||||
webhookId: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
alertId: string | undefined;
|
||||
onSubmit: (values: {
|
||||
channelType: AlertChannelType;
|
||||
groupBy: string | undefined;
|
||||
interval: AlertInterval;
|
||||
threshold: number;
|
||||
type: AlertType;
|
||||
webhookId: string | undefined;
|
||||
}) => void;
|
||||
onDeleteClick: () => void;
|
||||
query: string;
|
||||
}) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
control,
|
||||
} = useForm({
|
||||
defaultValues:
|
||||
defaultValues != null
|
||||
? {
|
||||
channelType: defaultValues.channelType,
|
||||
interval: defaultValues.interval,
|
||||
threshold: defaultValues.threshold,
|
||||
type: defaultValues.type,
|
||||
webhookId: defaultValues.webhookId,
|
||||
groupBy: defaultValues.groupBy,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const channel = watch('channelType');
|
||||
const interval = watch('interval');
|
||||
const groupBy = watch('groupBy');
|
||||
const threshold = watch('threshold');
|
||||
const type = watch('type');
|
||||
|
||||
const previewChartConfig = useMemo(() => {
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
type: 'time' as const,
|
||||
table: 'logs' as const,
|
||||
aggFn: 'count' as const,
|
||||
field: '',
|
||||
groupBy: groupBy != null ? [groupBy] : [],
|
||||
where: query,
|
||||
},
|
||||
],
|
||||
seriesReturnType: 'column' as const,
|
||||
dateRange: intervalToDateRange(interval),
|
||||
granularity: intervalToGranularity(interval),
|
||||
};
|
||||
}, [interval, query, groupBy]);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit(data => onSubmit(data))}>
|
||||
<div className="d-flex align-items-center mt-4 flex-wrap">
|
||||
<div className="me-2 mb-2">Alert when</div>
|
||||
<div className="me-2 mb-2">
|
||||
<Form.Select id="type" size="sm" {...register('type')}>
|
||||
<option key="presence" value="presence">
|
||||
at least (≥)
|
||||
</option>
|
||||
<option key="absence" value="absence">
|
||||
less than ({'<'})
|
||||
</option>
|
||||
</Form.Select>
|
||||
</div>
|
||||
<Form.Control
|
||||
style={{ width: 70 }}
|
||||
className="me-2 mb-2"
|
||||
type="number"
|
||||
id="threshold"
|
||||
size="sm"
|
||||
defaultValue={1}
|
||||
{...register('threshold', { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="me-2 mb-2">lines appear within</div>
|
||||
<div className="me-2 mb-2">
|
||||
<Form.Select id="interval" size="sm" {...register('interval')}>
|
||||
{Object.entries(ALERT_INTERVAL_OPTIONS).map(([value, text]) => (
|
||||
<option key={value} value={value}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="me-2 mb-2">grouped by</div>
|
||||
<div className="me-2 mb-2" style={{ minWidth: 300 }}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="groupBy"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FieldSelect
|
||||
value={value}
|
||||
setValue={onChange}
|
||||
types={['number', 'string', 'bool']}
|
||||
className="input-bg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="me-2 mb-2">via</div>
|
||||
<div className="me-2 mb-2">
|
||||
<Form.Select id="channel" size="sm" {...register('channelType')}>
|
||||
{Object.entries(ALERT_CHANNEL_OPTIONS).map(([value, text]) => (
|
||||
<option key={value} value={value}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center mb-2"></div>
|
||||
|
||||
{channel === 'webhook' && (
|
||||
<WebhookChannelForm webhookSelectProps={register('webhookId')} />
|
||||
)}
|
||||
|
||||
<div className="d-flex justify-content-between mt-4">
|
||||
<Button
|
||||
variant="outline-success"
|
||||
className="fs-7 text-muted-hover"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{alertId != null ? (
|
||||
<Button onClick={onDeleteClick} variant="dark">
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-3 text-muted ps-2 fs-7">Alert Threshold Preview</div>
|
||||
<div style={{ height: 400 }}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={previewChartConfig}
|
||||
alertThreshold={threshold}
|
||||
alertThresholdType={type === 'presence' ? 'above' : 'below'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreateLogAlertModal({
|
||||
savedSearch,
|
||||
onSaveSuccess,
|
||||
onDeleteSuccess,
|
||||
onSavedSearchCreateSuccess,
|
||||
show,
|
||||
onHide,
|
||||
query,
|
||||
}: {
|
||||
savedSearch: LogView | undefined;
|
||||
onSaveSuccess: () => void;
|
||||
onDeleteSuccess: () => void;
|
||||
onSavedSearchCreateSuccess: (responseData: any) => void;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
query: string;
|
||||
}) {
|
||||
const saveAlert = api.useSaveAlert();
|
||||
const updateAlert = api.useUpdateAlert();
|
||||
const deleteAlert = api.useDeleteAlert();
|
||||
const saveLogView = api.useSaveLogView();
|
||||
|
||||
const alerts = savedSearch?.alerts ?? [];
|
||||
const [selectedAlertId, setSelectedAlertId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const selectedAlert = alerts.find(alert => alert._id === selectedAlertId);
|
||||
|
||||
const onClickDeleteAlert = (alertId: string) => {
|
||||
if (savedSearch) {
|
||||
deleteAlert.mutate(alertId, {
|
||||
onSuccess: () => {
|
||||
onDeleteSuccess();
|
||||
setSelectedAlertId(alerts?.[0]?._id);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message:
|
||||
'An error occurred. Please contact support for more details.',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [savedSearchName, setSavedSearchName] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [parsedEnglishQuery, setParsedEnglishQuery] = useState<string>('');
|
||||
useEffect(() => {
|
||||
genEnglishExplanation(query).then(q => {
|
||||
setParsedEnglishQuery(q === '' ? 'All Events' : q);
|
||||
});
|
||||
}, [query]);
|
||||
|
||||
const displayedSavedSearchName = savedSearchName ?? parsedEnglishQuery;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
aria-labelledby="contained-modal-title-vcenter"
|
||||
centered
|
||||
onHide={onHide}
|
||||
show={show}
|
||||
size="xl"
|
||||
>
|
||||
<Modal.Body className="bg-hdx-dark rounded">
|
||||
<div className="d-flex align-items-center mt-3 flex-wrap mb-4">
|
||||
<h5 className="text-nowrap me-3 my-0">Alerts for</h5>
|
||||
{savedSearch == null ? (
|
||||
<Form.Control
|
||||
type="text"
|
||||
id="name"
|
||||
value={displayedSavedSearchName}
|
||||
onChange={e => {
|
||||
setSavedSearchName(e.target.value);
|
||||
}}
|
||||
placeholder="Your Saved Search Name"
|
||||
/>
|
||||
) : (
|
||||
<span className="fs-6 fw-bold">{savedSearch?.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="fs-8 text-muted">
|
||||
<span className="fw-bold">Query: </span>
|
||||
<span>{query}</span>
|
||||
</div>
|
||||
<TabBar
|
||||
className="fs-8 mt-3"
|
||||
items={[
|
||||
...alerts.map((alert, i) => ({
|
||||
text: `${capitalizeFirstLetter(alert.channel.type)} Alert ${
|
||||
i + 1
|
||||
}`,
|
||||
value: alert._id,
|
||||
})),
|
||||
{
|
||||
text: 'New Alert',
|
||||
value: undefined,
|
||||
},
|
||||
]}
|
||||
activeItem={selectedAlertId}
|
||||
onClick={(alertId: string | undefined) => setSelectedAlertId(alertId)}
|
||||
/>
|
||||
{selectedAlert == null ? (
|
||||
<AlertForm
|
||||
onSubmit={async ({
|
||||
channelType,
|
||||
groupBy,
|
||||
interval,
|
||||
threshold,
|
||||
type,
|
||||
webhookId,
|
||||
}) => {
|
||||
let savedSearchId = savedSearch?._id;
|
||||
if (savedSearch == null) {
|
||||
try {
|
||||
if (
|
||||
displayedSavedSearchName == null ||
|
||||
displayedSavedSearchName.length === 0
|
||||
) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message:
|
||||
'You must enter a saved search name to create an alert.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const savedSearch = await saveLogView.mutateAsync({
|
||||
name: displayedSavedSearchName,
|
||||
query: query ?? '',
|
||||
});
|
||||
savedSearchId = savedSearch.data._id;
|
||||
onSavedSearchCreateSuccess(savedSearch.data);
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message:
|
||||
'An error occurred while saving the search for this alert. Please contact support for more details.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (savedSearchId != null) {
|
||||
saveAlert.mutate(
|
||||
{
|
||||
source: 'LOG',
|
||||
type,
|
||||
threshold,
|
||||
interval,
|
||||
groupBy,
|
||||
channel: {
|
||||
type: channelType,
|
||||
...(channelType === 'webhook' && {
|
||||
webhookId,
|
||||
}),
|
||||
},
|
||||
logViewId: savedSearchId,
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
setSelectedAlertId(response?.data?._id);
|
||||
onSaveSuccess();
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message:
|
||||
'An error occurred. Please contact support for more details.',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message:
|
||||
'An error occurred while saving the search for this alert. Please contact support for more details.',
|
||||
});
|
||||
}
|
||||
}}
|
||||
alertId={undefined}
|
||||
defaultValues={undefined}
|
||||
onDeleteClick={() => {}}
|
||||
query={query}
|
||||
/>
|
||||
) : null}
|
||||
{selectedAlert != null && selectedAlertId != null ? (
|
||||
<AlertForm
|
||||
query={query}
|
||||
onDeleteClick={() => {
|
||||
onClickDeleteAlert(selectedAlertId);
|
||||
}}
|
||||
key={selectedAlertId}
|
||||
onSubmit={({
|
||||
channelType,
|
||||
groupBy,
|
||||
interval,
|
||||
threshold,
|
||||
type,
|
||||
webhookId,
|
||||
}) => {
|
||||
if (savedSearch != null && selectedAlertId != null) {
|
||||
// use useUpdateAlert
|
||||
updateAlert.mutate(
|
||||
{
|
||||
id: selectedAlertId,
|
||||
source: 'LOG',
|
||||
type,
|
||||
threshold,
|
||||
interval,
|
||||
groupBy,
|
||||
channel: { type: channelType, webhookId },
|
||||
logViewId: savedSearch._id,
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
onSaveSuccess();
|
||||
// notifications.show({ color: 'green', message: 'The alert is saved.' });
|
||||
// refetchLogViews();
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message:
|
||||
'An error occurred. Please contact support for more details.',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
alertId={selectedAlertId}
|
||||
defaultValues={{
|
||||
type: selectedAlert.type,
|
||||
threshold: selectedAlert.threshold,
|
||||
interval: selectedAlert.interval,
|
||||
channelType: selectedAlert.channel.type,
|
||||
groupBy: selectedAlert.groupBy,
|
||||
webhookId:
|
||||
selectedAlert.channel.type === 'webhook'
|
||||
? selectedAlert.channel.webhookId
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -253,6 +253,7 @@ const Tile = forwardRef(
|
|||
{(queriedConfig?.displayType === DisplayType.Line ||
|
||||
queriedConfig?.displayType === DisplayType.StackedBar) && (
|
||||
<DBTimeChart
|
||||
sourceId={chart.config.source}
|
||||
showDisplaySwitcher={false}
|
||||
config={queriedConfig}
|
||||
onTimeRangeSelect={onTimeRangeSelect}
|
||||
|
|
@ -814,9 +815,6 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
|
|||
source={rowSidePanelSource}
|
||||
rowId={rowId}
|
||||
onClose={handleSidePanelClose}
|
||||
shareUrl=""
|
||||
generateSearchUrl={() => ''}
|
||||
generateChartUrl={() => ''}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import Drawer from 'react-modern-drawer';
|
||||
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
|
||||
import { Box, Card, Grid, Text } from '@mantine/core';
|
||||
|
||||
import { DrawerBody, DrawerHeader } from './components/DrawerUtils';
|
||||
import {
|
||||
convertDateRangeToGranularityString,
|
||||
INTEGER_NUMBER_FORMAT,
|
||||
MS_NUMBER_FORMAT,
|
||||
} from './ChartUtils';
|
||||
import HDXMultiSeriesTimeChart from './HDXMultiSeriesTimeChart';
|
||||
import SlowestEventsTile from './SlowestEventsTile';
|
||||
import { parseTimeQuery, useTimeQuery } from './timeQuery';
|
||||
import { useZIndex, ZIndexContext } from './zIndex';
|
||||
|
||||
import styles from '../styles/LogSidePanel.module.scss';
|
||||
|
||||
const defaultTimeRange = parseTimeQuery('Past 1h', false);
|
||||
|
||||
const CHART_HEIGHT = 300;
|
||||
|
||||
const DB_STATEMENT_PROPERTY = 'db.normalized_statement';
|
||||
|
||||
export default function DBQuerySidePanel() {
|
||||
const [service] = useQueryParam('service', withDefault(StringParam, ''), {
|
||||
updateType: 'replaceIn',
|
||||
});
|
||||
|
||||
const [dbQuery, setDbQuery] = useQueryParam(
|
||||
'db_query',
|
||||
withDefault(StringParam, ''),
|
||||
{ updateType: 'replaceIn' },
|
||||
);
|
||||
|
||||
const { searchedTimeRange: dateRange } = useTimeQuery({
|
||||
defaultValue: 'Past 1h',
|
||||
defaultTimeRange: [
|
||||
defaultTimeRange?.[0]?.getTime() ?? -1,
|
||||
defaultTimeRange?.[1]?.getTime() ?? -1,
|
||||
],
|
||||
});
|
||||
|
||||
const scopeWhereQuery = React.useCallback(
|
||||
(where: string) => {
|
||||
const spanNameQuery = dbQuery
|
||||
? `${DB_STATEMENT_PROPERTY}:"${dbQuery.replace(/"/g, '\\"')}" `
|
||||
: '';
|
||||
const whereQuery = where ? `(${where})` : '';
|
||||
const serviceQuery = service ? `service:"${service}" ` : '';
|
||||
return `${spanNameQuery}${serviceQuery}${whereQuery}`.trim();
|
||||
},
|
||||
[dbQuery, service],
|
||||
);
|
||||
const contextZIndex = useZIndex();
|
||||
const drawerZIndex = contextZIndex + 10;
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setDbQuery(undefined);
|
||||
}, [setDbQuery]);
|
||||
|
||||
if (!dbQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
enableOverlay
|
||||
overlayOpacity={0.1}
|
||||
duration={0}
|
||||
open={!!dbQuery}
|
||||
onClose={handleClose}
|
||||
direction="right"
|
||||
size={'80vw'}
|
||||
zIndex={drawerZIndex}
|
||||
>
|
||||
<ZIndexContext.Provider value={drawerZIndex}>
|
||||
<div className={styles.panel}>
|
||||
<DrawerHeader
|
||||
header={`Details for ${dbQuery}`}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<DrawerBody>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Total Query Time
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
series: [
|
||||
{
|
||||
displayName: 'Total Query Time',
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'sum',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery(''),
|
||||
groupBy: [],
|
||||
numberFormat: MS_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
seriesReturnType: 'column',
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Query Throughput
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
series: [
|
||||
{
|
||||
displayName: 'Queries',
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: scopeWhereQuery(''),
|
||||
groupBy: [],
|
||||
numberFormat: {
|
||||
...INTEGER_NUMBER_FORMAT,
|
||||
unit: 'queries',
|
||||
},
|
||||
},
|
||||
],
|
||||
seriesReturnType: 'column',
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SlowestEventsTile
|
||||
dateRange={dateRange}
|
||||
height={CHART_HEIGHT}
|
||||
scopeWhereQuery={scopeWhereQuery}
|
||||
title={<Text>Slowest 10% of Queries</Text>}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</DrawerBody>
|
||||
</div>
|
||||
</ZIndexContext.Provider>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ import { useTimeChartSettings } from '@/ChartUtils';
|
|||
import DBDeltaChart from '@/components/DBDeltaChart';
|
||||
import DBHeatmapChart from '@/components/DBHeatmapChart';
|
||||
import DBRowSidePanel from '@/components/DBRowSidePanel';
|
||||
import { RowSidePanelContext } from '@/components/DBRowSidePanel';
|
||||
import { DBSqlRowTable } from '@/components/DBRowTable';
|
||||
import { DBSearchPageFilters } from '@/components/DBSearchPageFilters';
|
||||
import { DBTimeChart } from '@/components/DBTimeChart';
|
||||
|
|
@ -66,6 +67,7 @@ import {
|
|||
useSavedSearch,
|
||||
useUpdateSavedSearch,
|
||||
} from '@/savedSearch';
|
||||
import { useSearchPageFilterState } from '@/searchFilters';
|
||||
import SearchInputV2 from '@/SearchInputV2';
|
||||
import { getDurationMsExpression, useSource, useSources } from '@/source';
|
||||
import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery';
|
||||
|
|
@ -667,6 +669,49 @@ function DBSearchPage() {
|
|||
};
|
||||
}, [chartConfig, searchedTimeRange]);
|
||||
|
||||
const searchFilters = useSearchPageFilterState({
|
||||
searchQuery: watch('filters') ?? undefined,
|
||||
onFilterChange: handleSetFilters,
|
||||
});
|
||||
|
||||
const displayedColumns = (
|
||||
dbSqlRowTableConfig?.select ??
|
||||
searchedSource?.defaultTableSelectExpression ??
|
||||
''
|
||||
)
|
||||
.split(',')
|
||||
.map(s => s.trim());
|
||||
|
||||
const toggleColumn = (column: string) => {
|
||||
const newSelectArray = displayedColumns.includes(column)
|
||||
? displayedColumns.filter(s => s !== column)
|
||||
: [...displayedColumns, column];
|
||||
setValue('select', newSelectArray.join(', '));
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
const generateSearchUrl = useCallback(
|
||||
(query?: string) => {
|
||||
const qParams = new URLSearchParams({
|
||||
where: query || searchedConfig.where || '',
|
||||
whereLanguage: 'sql',
|
||||
from: searchedTimeRange[0].getTime().toString(),
|
||||
to: searchedTimeRange[1].getTime().toString(),
|
||||
select: searchedConfig.select || '',
|
||||
source: searchedSource?.id || '',
|
||||
filters: JSON.stringify(searchedConfig.filters),
|
||||
});
|
||||
return `/search?${qParams.toString()}`;
|
||||
},
|
||||
[
|
||||
searchedConfig.filters,
|
||||
searchedConfig.select,
|
||||
searchedConfig.where,
|
||||
searchedSource?.id,
|
||||
searchedTimeRange,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column" h="100vh" style={{ overflow: 'hidden' }}>
|
||||
<OnboardingModal />
|
||||
|
|
@ -831,16 +876,22 @@ function DBSearchPage() {
|
|||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
{searchedSource && (
|
||||
<DBRowSidePanel
|
||||
source={searchedSource}
|
||||
rowId={rowId ?? undefined}
|
||||
onClose={() => setRowId(null)}
|
||||
shareUrl=""
|
||||
generateSearchUrl={() => ''}
|
||||
generateChartUrl={() => ''}
|
||||
/>
|
||||
)}
|
||||
<RowSidePanelContext.Provider
|
||||
value={{
|
||||
onPropertyAddClick: searchFilters.setFilterValue,
|
||||
displayedColumns,
|
||||
toggleColumn,
|
||||
generateSearchUrl,
|
||||
}}
|
||||
>
|
||||
{searchedSource && (
|
||||
<DBRowSidePanel
|
||||
source={searchedSource}
|
||||
rowId={rowId ?? undefined}
|
||||
onClose={() => setRowId(null)}
|
||||
/>
|
||||
)}
|
||||
</RowSidePanelContext.Provider>
|
||||
{searchedConfig != null && searchedSource != null && (
|
||||
<SaveSearchModal
|
||||
opened={saveSearchModalState != null}
|
||||
|
|
@ -884,8 +935,7 @@ function DBSearchPage() {
|
|||
...chartConfig,
|
||||
dateRange: searchedTimeRange,
|
||||
}}
|
||||
filters={watch('filters')}
|
||||
onFilterChange={handleSetFilters}
|
||||
{...searchFilters}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{analysisMode === 'delta' && searchedSource != null && (
|
||||
|
|
@ -963,6 +1013,7 @@ function DBSearchPage() {
|
|||
queryKeyPrefix={QUERY_KEY_PREFIX}
|
||||
/>
|
||||
<DBTimeChart
|
||||
sourceId={searchedConfig.source ?? undefined}
|
||||
showLegend={false}
|
||||
config={{
|
||||
...chartConfig,
|
||||
|
|
|
|||
|
|
@ -1,528 +0,0 @@
|
|||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Replayer } from 'rrweb';
|
||||
import { ActionIcon, CopyButton, HoverCard } from '@mantine/core';
|
||||
|
||||
import { useSearchEventStream } from './search';
|
||||
import { useDebugMode } from './utils';
|
||||
|
||||
import styles from '../styles/SessionSubpanelV2.module.scss';
|
||||
|
||||
function getPlayerCurrentTime(player: Replayer) {
|
||||
return Math.max(player.getCurrentTime(), 0); //getCurrentTime can be -startTime
|
||||
}
|
||||
|
||||
const URLHoverCard = memo(({ url }: { url: string }) => {
|
||||
let parsedUrl: URL | undefined;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let searchParams: { key: string; value: string }[] | undefined;
|
||||
try {
|
||||
const _searchParams = new URLSearchParams(parsedUrl?.search ?? '');
|
||||
searchParams = [];
|
||||
for (const [key, value] of _searchParams.entries()) {
|
||||
searchParams.push({ key, value });
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard shadow="md" position="bottom-start">
|
||||
<HoverCard.Target>
|
||||
<div className={styles.playerHeaderUrl}>{url || 'Session Player'}</div>
|
||||
</HoverCard.Target>
|
||||
{url && (
|
||||
<HoverCard.Dropdown>
|
||||
<table className="table fs-8 mb-0">
|
||||
<tr>
|
||||
<td>
|
||||
<i className="bi bi-globe fs-8 text-slate-300"></i>
|
||||
</td>
|
||||
<td>{parsedUrl?.host}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<i className="bi bi-link-45deg text-slate-300 fs-7"></i>
|
||||
</td>
|
||||
<td>{parsedUrl?.pathname}</td>
|
||||
</tr>
|
||||
{searchParams &&
|
||||
searchParams.length > 0 &&
|
||||
searchParams.map(param => (
|
||||
<tr key={param.key}>
|
||||
<td>
|
||||
<strong>{param.key}</strong>
|
||||
</td>
|
||||
<td>{param.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
</HoverCard.Dropdown>
|
||||
)}
|
||||
</HoverCard>
|
||||
);
|
||||
});
|
||||
|
||||
export default function DOMPlayer({
|
||||
config: { sessionId, dateRange },
|
||||
focus,
|
||||
setPlayerTime,
|
||||
playerState,
|
||||
setPlayerState,
|
||||
playerSpeed,
|
||||
skipInactive,
|
||||
setPlayerStartTimestamp,
|
||||
setPlayerEndTimestamp,
|
||||
setPlayerFullWidth,
|
||||
playerFullWidth,
|
||||
resizeKey,
|
||||
}: {
|
||||
config: {
|
||||
sessionId: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
focus: { ts: number; setBy: string } | undefined;
|
||||
setPlayerTime: (ts: number) => void;
|
||||
playerState: 'playing' | 'paused';
|
||||
setPlayerState: (state: 'playing' | 'paused') => void;
|
||||
playerSpeed: number;
|
||||
setPlayerStartTimestamp?: (ts: number) => void;
|
||||
setPlayerEndTimestamp?: (ts: number) => void;
|
||||
// setPlayerSpeed: (playerSpeed: number) => void;
|
||||
skipInactive: boolean;
|
||||
// setSkipInactive: (skipInactive: boolean) => void;
|
||||
// highlightedResultId: string | undefined;
|
||||
// onClick: (logId: string, sortKey: number) => void;
|
||||
resizeKey?: string;
|
||||
setPlayerFullWidth: (fullWidth: boolean) => void;
|
||||
playerFullWidth: boolean;
|
||||
}) {
|
||||
const debug = useDebugMode();
|
||||
const wrapper = useRef<HTMLDivElement>(null);
|
||||
const playerContainer = useRef<HTMLDivElement>(null);
|
||||
const replayer = useRef<Replayer | null>(null);
|
||||
const initialEvents = useRef<any[]>([]);
|
||||
|
||||
const lastEventTsLoadedRef = useRef(0);
|
||||
const [lastEventTsLoaded, _setLastEventTsLoaded] = useState(0);
|
||||
const setLastEventTsLoaded = useRef(
|
||||
throttle(_setLastEventTsLoaded, 100, { leading: true, trailing: true }),
|
||||
);
|
||||
const [isInitialEventsLoaded, setIsInitialEventsLoaded] = useState(false);
|
||||
const [isReplayFullyLoaded, setIsReplayFullyLoaded] = useState(false);
|
||||
|
||||
let currentRrwebEvent = '';
|
||||
|
||||
const { isFetching: isSearchResultsFetching, abort } = useSearchEventStream(
|
||||
{
|
||||
apiUrlPath: `/sessions/${sessionId}/rrweb`,
|
||||
q: '',
|
||||
startDate: dateRange?.[0] ?? new Date(),
|
||||
endDate: dateRange?.[1] ?? new Date(),
|
||||
extraFields: [],
|
||||
order: 'asc', // hardcoded at the api side. doesn't matter here
|
||||
limit: 1000000, // large enough to get all events
|
||||
onEvent: (event: { b: string; ck: number; tcks: number; t: number }) => {
|
||||
try {
|
||||
const { b: body, ck: chunk, tcks: totalChunks, t: type } = event;
|
||||
currentRrwebEvent += body;
|
||||
if (!chunk || chunk === totalChunks) {
|
||||
const parsedEvent = JSON.parse(currentRrwebEvent);
|
||||
|
||||
if (replayer.current != null) {
|
||||
replayer.current.addEvent(parsedEvent);
|
||||
} else {
|
||||
if (
|
||||
setPlayerStartTimestamp != null &&
|
||||
initialEvents.current.length === 0
|
||||
) {
|
||||
setPlayerStartTimestamp(parsedEvent.timestamp);
|
||||
}
|
||||
|
||||
initialEvents.current.push(parsedEvent);
|
||||
}
|
||||
|
||||
setLastEventTsLoaded.current(parsedEvent.timestamp);
|
||||
// Used for setting the player end timestamp on onEnd
|
||||
// we can't use state since the onEnd function is declared
|
||||
// at the beginning of the component lifecylce.
|
||||
// We can't use the rrweb metadata as it's not updated fast enough
|
||||
lastEventTsLoadedRef.current = parsedEvent.timestamp;
|
||||
|
||||
currentRrwebEvent = '';
|
||||
}
|
||||
} catch (e) {
|
||||
if (debug) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
currentRrwebEvent = '';
|
||||
}
|
||||
|
||||
if (initialEvents.current.length > 5) {
|
||||
setIsInitialEventsLoaded(true);
|
||||
}
|
||||
},
|
||||
onEnd: () => {
|
||||
setIsInitialEventsLoaded(true);
|
||||
setIsReplayFullyLoaded(true);
|
||||
|
||||
if (setPlayerEndTimestamp != null) {
|
||||
if (replayer.current != null) {
|
||||
const endTime = lastEventTsLoadedRef.current;
|
||||
|
||||
// Might want to merge with the below logic at some point, since
|
||||
// it's using a ts ref now
|
||||
setPlayerEndTimestamp(endTime ?? 0);
|
||||
} else {
|
||||
// If there's no events (empty replay session), there's no point in setting a timestamp
|
||||
if (initialEvents.current.length > 0) {
|
||||
setPlayerEndTimestamp(
|
||||
initialEvents.current[initialEvents.current.length - 1]
|
||||
.timestamp ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: dateRange != null,
|
||||
keepPreviousData: true, // TODO: support streaming
|
||||
shouldAbortPendingRequest: true,
|
||||
},
|
||||
);
|
||||
|
||||
// RRWeb Player Stuff ==============================
|
||||
const [lastHref, setLastHref] = useState('');
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (replayer.current != null) {
|
||||
try {
|
||||
replayer.current.play(getPlayerCurrentTime(replayer.current));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}, [replayer]);
|
||||
|
||||
const pause = useCallback(
|
||||
(ts?: number) => {
|
||||
if (replayer.current != null) {
|
||||
try {
|
||||
replayer.current.pause(ts);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
[replayer],
|
||||
);
|
||||
|
||||
useHotkeys(['space'], () => {
|
||||
if (playerState === 'playing') {
|
||||
setPlayerState('paused');
|
||||
} else if (playerState === 'paused') {
|
||||
setPlayerState('playing');
|
||||
}
|
||||
});
|
||||
|
||||
// XXX: Hack to let requestAnimationFrame access the current setPlayerTime
|
||||
const setPlayerTimeRef = useRef(setPlayerTime);
|
||||
useEffect(() => {
|
||||
setPlayerTimeRef.current = setPlayerTime;
|
||||
}, [setPlayerTime]);
|
||||
|
||||
const updatePlayerTimeRafRef = useRef(0);
|
||||
const updatePlayerTime = () => {
|
||||
if (
|
||||
replayer.current != null &&
|
||||
replayer.current.service.state.matches('playing')
|
||||
) {
|
||||
setPlayerTimeRef.current(
|
||||
Math.round(
|
||||
replayer.current.getMetaData().startTime +
|
||||
getPlayerCurrentTime(replayer.current),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
updatePlayerTimeRafRef.current = requestAnimationFrame(updatePlayerTime);
|
||||
};
|
||||
|
||||
// Update timestamp ui in timeline
|
||||
useEffect(() => {
|
||||
updatePlayerTimeRafRef.current = requestAnimationFrame(updatePlayerTime);
|
||||
return () => {
|
||||
cancelAnimationFrame(updatePlayerTimeRafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Manage playback pause/play state, rrweb only
|
||||
useEffect(() => {
|
||||
if (replayer.current != null) {
|
||||
if (playerState === 'playing') {
|
||||
play();
|
||||
} else if (playerState === 'paused') {
|
||||
pause();
|
||||
}
|
||||
}
|
||||
}, [playerState, play, pause]);
|
||||
|
||||
useEffect(() => {
|
||||
if (replayer.current != null) {
|
||||
if (playerState === 'playing') {
|
||||
pause();
|
||||
replayer.current?.setConfig({ speed: playerSpeed, skipInactive });
|
||||
play();
|
||||
} else if (playerState === 'paused') {
|
||||
replayer.current?.setConfig({ speed: playerSpeed, skipInactive });
|
||||
}
|
||||
}
|
||||
}, [playerState, playerSpeed, skipInactive, pause, play]);
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (wrapper.current == null || playerContainer.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
playerContainer.current.style.transform = `scale(0.0001)`;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (wrapper.current == null || playerContainer.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapperRect = wrapper.current.getBoundingClientRect();
|
||||
const playerWidth = replayer?.current?.iframe?.offsetWidth ?? 1280;
|
||||
const playerHeight = replayer?.current?.iframe?.offsetHeight ?? 720;
|
||||
|
||||
const xScale = wrapperRect.width / playerWidth;
|
||||
const yScale = wrapperRect.height / playerHeight;
|
||||
playerContainer.current.style.transform = `scale(${Math.min(
|
||||
xScale,
|
||||
yScale,
|
||||
)})`;
|
||||
});
|
||||
}, [wrapper, playerContainer]);
|
||||
|
||||
// Listen to window resizes to resize player
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [handleResize]);
|
||||
// Resize when something external changes our player size
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
}, [resizeKey, handleResize]);
|
||||
|
||||
const [isReplayerInitialized, setIsReplayerInitialized] = useState(false);
|
||||
// Set up player
|
||||
useEffect(() => {
|
||||
if (
|
||||
// If we have no events yet, we can't mount yet.
|
||||
// (results ?? []).length == 0 ||
|
||||
initialEvents.current.length < 2 ||
|
||||
// Just skip if we're already enabled
|
||||
playerContainer.current == null ||
|
||||
replayer.current != null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
replayer.current = new Replayer(initialEvents.current, {
|
||||
root: playerContainer.current,
|
||||
mouseTail: true,
|
||||
pauseAnimation: false,
|
||||
showWarning: debug,
|
||||
skipInactive: true,
|
||||
});
|
||||
setIsReplayerInitialized(true);
|
||||
|
||||
if (debug) {
|
||||
// @ts-ignore
|
||||
window.__hdx_replayer = replayer.current;
|
||||
}
|
||||
|
||||
replayer.current.enableInteract();
|
||||
replayer.current.on('event-cast', (e: any) => {
|
||||
try {
|
||||
// if this is an incremental update from a resize
|
||||
// OR if its a full snapshot `type=4`, we'll want to resize just in case
|
||||
// https://github.com/rrweb-io/rrweb/blob/07aa1b2807da5a9a1db678ebc3ff59320a300d06/packages/rrweb/src/record/index.ts#L447
|
||||
// https://github.com/rrweb-io/rrweb/blob/2a809499480ae4f7118432f09871c5f75fda06d7/packages/types/src/index.ts#L74
|
||||
if ((e?.type === 3 && e?.data?.source === 4) || e.type === 4) {
|
||||
setTimeout(() => {
|
||||
handleResize();
|
||||
}, 0);
|
||||
}
|
||||
if (e?.type === 4) {
|
||||
setLastHref(e.data.href);
|
||||
}
|
||||
} catch (e) {
|
||||
if (debug) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If we're supposed to be playing, let's start playing.
|
||||
if (
|
||||
playerState === 'playing' &&
|
||||
replayer.current.getMetaData().endTime > (focus?.ts ?? 0)
|
||||
) {
|
||||
if (focus != null) {
|
||||
pause(focus.ts - replayer.current.getMetaData().startTime);
|
||||
}
|
||||
play();
|
||||
}
|
||||
|
||||
// XXX: Yes this is a hugeee antipattern
|
||||
setTimeout(() => {
|
||||
handleResize();
|
||||
}, 0);
|
||||
}, [
|
||||
handleResize,
|
||||
// results,
|
||||
focus,
|
||||
pause,
|
||||
isInitialEventsLoaded,
|
||||
playerState,
|
||||
play,
|
||||
]);
|
||||
|
||||
// Set player to the correct time based on focus
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isInitialEventsLoaded ||
|
||||
!isReplayerInitialized ||
|
||||
lastEventTsLoaded < (focus?.ts ? focus.ts + 1000 : Infinity)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (focus?.setBy !== 'player' && replayer.current != null) {
|
||||
pause(
|
||||
focus?.ts == null
|
||||
? 0
|
||||
: focus?.ts - replayer.current.getMetaData().startTime,
|
||||
);
|
||||
handleResize();
|
||||
if (playerState === 'playing') {
|
||||
play();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
focus,
|
||||
pause,
|
||||
setPlayerState,
|
||||
playerState,
|
||||
play,
|
||||
isInitialEventsLoaded,
|
||||
isReplayerInitialized,
|
||||
handleResize,
|
||||
lastEventTsLoaded,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (replayer.current != null) {
|
||||
replayer.current?.destroy();
|
||||
replayer.current = null;
|
||||
}
|
||||
abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isLoading = isInitialEventsLoaded === false && isSearchResultsFetching;
|
||||
// TODO: Handle when ts is set to a value that's outside of this session
|
||||
const isBuffering =
|
||||
isReplayFullyLoaded === false &&
|
||||
(replayer.current?.getMetaData()?.endTime ?? 0) < (focus?.ts ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
// If we're trying to play, but the player is paused
|
||||
// try to play again if we've loaded the event we're trying to play
|
||||
// this is relevant when you click or load on a timestamp that hasn't loaded yet
|
||||
if (
|
||||
replayer.current != null &&
|
||||
focus != null &&
|
||||
replayer.current.getMetaData().endTime > focus.ts &&
|
||||
playerState === 'playing' &&
|
||||
replayer.current?.service?.state?.matches('paused')
|
||||
) {
|
||||
pause(focus.ts - replayer.current.getMetaData().startTime);
|
||||
play();
|
||||
}
|
||||
}, [lastEventTsLoaded, focus, playerState, pause, play]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.playerHeader}>
|
||||
<ActionIcon
|
||||
onClick={() => setPlayerFullWidth(!playerFullWidth)}
|
||||
size="sm"
|
||||
color="gray.7"
|
||||
>
|
||||
{playerFullWidth ? (
|
||||
<i className="bi bi-list"></i>
|
||||
) : (
|
||||
<i className="bi bi-arrows-fullscreen fs-8"></i>
|
||||
)}
|
||||
</ActionIcon>
|
||||
<CopyButton value={lastHref}>
|
||||
{({ copied, copy }) => (
|
||||
<>
|
||||
<URLHoverCard url={lastHref} />
|
||||
<ActionIcon
|
||||
onClick={copy}
|
||||
title="Copy URL"
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="gray"
|
||||
>
|
||||
{copied ? (
|
||||
<i className="bi bi-check2 fs-8" />
|
||||
) : (
|
||||
<i className="bi bi-copy fs-8" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</CopyButton>
|
||||
</div>
|
||||
|
||||
<div className={styles.playerContainer}>
|
||||
{isLoading || isBuffering ? (
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status" />
|
||||
<div className="mt-2">
|
||||
{isBuffering ? 'Buffering to time...' : 'Loading replay...'}
|
||||
</div>
|
||||
</div>
|
||||
) : isReplayFullyLoaded && replayer.current == null ? (
|
||||
<div className="text-center">
|
||||
No replay available for this session, most likely due to this
|
||||
session starting and ending in a background tab.
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
ref={wrapper}
|
||||
className={cx(styles.domPlayerWrapper, 'overflow-hidden', {
|
||||
'd-none': isLoading || isBuffering,
|
||||
started: (replayer.current?.getCurrentTime() ?? 0) > 0,
|
||||
[styles.domPlayerWrapperPaused]: playerState === 'paused',
|
||||
})}
|
||||
>
|
||||
<div className="player rr-block" ref={playerContainer} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,40 +0,0 @@
|
|||
import { Form } from 'react-bootstrap';
|
||||
|
||||
export default function Dropdown<T extends string | number>({
|
||||
name,
|
||||
className,
|
||||
disabled,
|
||||
onChange,
|
||||
options,
|
||||
style,
|
||||
value,
|
||||
}: {
|
||||
name?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (value: T) => any;
|
||||
options: Array<{ value: T; text: string }>;
|
||||
style?: { [key: string]: any };
|
||||
value: T | undefined;
|
||||
}) {
|
||||
return (
|
||||
<Form.Select
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
role="button"
|
||||
className={`shadow-none fw-bold ${
|
||||
(className ?? '').indexOf('bg-') >= 0 ? '' : 'bg-body'
|
||||
} w-auto ${className ?? ''}`}
|
||||
value={value}
|
||||
style={style}
|
||||
onChange={e => onChange(e.target.value as T)}
|
||||
title={options.find(opt => opt.value === value)?.text ?? ''}
|
||||
>
|
||||
{options.map(option => (
|
||||
<option value={option.value} key={option.value}>
|
||||
{option.text}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,163 +0,0 @@
|
|||
import produce from 'immer';
|
||||
import { omit } from 'lodash';
|
||||
import { Form } from 'react-bootstrap';
|
||||
import { Tooltip } from '@mantine/core';
|
||||
|
||||
import {
|
||||
ALERT_CHANNEL_OPTIONS,
|
||||
ALERT_INTERVAL_OPTIONS,
|
||||
WebhookChannelForm,
|
||||
} from './Alert';
|
||||
import type { Alert } from './types';
|
||||
import { NumberFormat } from './types';
|
||||
import { formatNumber } from './utils';
|
||||
|
||||
// Don't allow 1 minute alerts for charts
|
||||
const CHART_ALERT_INTERVAL_OPTIONS = omit(ALERT_INTERVAL_OPTIONS, '1m');
|
||||
|
||||
type ChartAlertFormProps = {
|
||||
alert: Alert;
|
||||
setAlert: (alert?: Alert) => void;
|
||||
numberFormat?: NumberFormat;
|
||||
};
|
||||
|
||||
export default function EditChartFormAlerts({
|
||||
alert,
|
||||
setAlert,
|
||||
numberFormat,
|
||||
}: ChartAlertFormProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||
<span>
|
||||
Alert when the value
|
||||
<Tooltip label="Raw value before applying number format">
|
||||
<i className="bi bi-question-circle ms-1 text-slate-300" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Form.Select
|
||||
id="type"
|
||||
size="sm"
|
||||
style={{
|
||||
width: 170,
|
||||
}}
|
||||
value={alert?.type}
|
||||
onChange={e => {
|
||||
setAlert(
|
||||
produce(alert, draft => {
|
||||
draft.type = e.target.value as 'presence' | 'absence';
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option key="presence" value="presence">
|
||||
is at least (≥)
|
||||
</option>
|
||||
<option key="absence" value="absence">
|
||||
falls below ({'<'})
|
||||
</option>
|
||||
</Form.Select>
|
||||
<div style={{ marginBottom: -20 }}>
|
||||
<Form.Control
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
required
|
||||
id="threshold"
|
||||
size="sm"
|
||||
defaultValue={1}
|
||||
value={alert?.threshold}
|
||||
onChange={e => {
|
||||
setAlert(
|
||||
produce(alert, draft => {
|
||||
draft.threshold = parseFloat(e.target.value);
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="text-slate-300 fs-8"
|
||||
style={{
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
{numberFormat && alert?.threshold > 0 && (
|
||||
<>
|
||||
{formatNumber(alert.threshold, numberFormat)}
|
||||
<Tooltip label="Formatted value">
|
||||
<i className="bi bi-question-circle ms-1 text-slate-300" />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
over
|
||||
<Form.Select
|
||||
id="interval"
|
||||
size="sm"
|
||||
style={{
|
||||
width: 140,
|
||||
}}
|
||||
value={alert?.interval}
|
||||
onChange={e => {
|
||||
setAlert(
|
||||
produce(alert, draft => {
|
||||
draft.interval = e.target
|
||||
.value as keyof typeof ALERT_INTERVAL_OPTIONS;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select interval
|
||||
</option>
|
||||
{Object.entries(CHART_ALERT_INTERVAL_OPTIONS).map(([value, text]) => (
|
||||
<option key={value} value={value}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
window via
|
||||
<Form.Select
|
||||
id="channel"
|
||||
size="sm"
|
||||
style={{ width: 200 }}
|
||||
value={alert?.channel?.type}
|
||||
onChange={e => {
|
||||
setAlert(
|
||||
produce(alert, draft => {
|
||||
draft.channel = {
|
||||
type: e.target.value as keyof typeof ALERT_CHANNEL_OPTIONS,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{Object.entries(ALERT_CHANNEL_OPTIONS).map(([value, text]) => (
|
||||
<option key={value} value={value}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{alert?.channel?.type === 'webhook' && (
|
||||
<WebhookChannelForm
|
||||
webhookSelectProps={{
|
||||
value: alert?.channel?.webhookId || '',
|
||||
onChange: e => {
|
||||
setAlert(
|
||||
produce(alert, draft => {
|
||||
draft.channel = {
|
||||
type: 'webhook',
|
||||
webhookId: e.target.value,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import produce from 'immer';
|
||||
import { Box, Button, Flex } from '@mantine/core';
|
||||
|
||||
import { Granularity } from './ChartUtils';
|
||||
import {
|
||||
EditHistogramChartForm,
|
||||
EditLineChartForm,
|
||||
EditMarkdownChartForm,
|
||||
EditNumberChartForm,
|
||||
EditSearchChartForm,
|
||||
EditTableChartForm,
|
||||
} from './EditChartForm';
|
||||
import { Histogram } from './SVGIcons';
|
||||
import TabBar from './TabBar';
|
||||
import type { Alert, Chart, Dashboard } from './types';
|
||||
|
||||
const EditTileForm = ({
|
||||
isLocalDashboard,
|
||||
isAddingAlert,
|
||||
chart,
|
||||
alerts,
|
||||
dateRange,
|
||||
onSave,
|
||||
onClose,
|
||||
editedChart,
|
||||
setEditedChart,
|
||||
displayedTimeInputValue,
|
||||
setDisplayedTimeInputValue,
|
||||
granularity,
|
||||
setGranularity,
|
||||
onTimeRangeSearch,
|
||||
hideMarkdown,
|
||||
hideSearch,
|
||||
createDashboardHref,
|
||||
dashboardQuery,
|
||||
}: {
|
||||
isLocalDashboard: boolean;
|
||||
isAddingAlert?: boolean;
|
||||
chart: Chart | undefined;
|
||||
alerts?: Alert[];
|
||||
dateRange: [Date, Date];
|
||||
displayedTimeInputValue?: string;
|
||||
setDisplayedTimeInputValue?: (value: string) => void;
|
||||
onTimeRangeSearch?: (value: string) => void;
|
||||
granularity?: Granularity;
|
||||
setGranularity?: (granularity: Granularity | undefined) => void;
|
||||
onSave?: (chart: Chart, alerts?: Alert[]) => void;
|
||||
onClose?: () => void;
|
||||
editedChart?: Chart;
|
||||
setEditedChart?: (chart: Chart) => void;
|
||||
hideMarkdown?: boolean;
|
||||
hideSearch?: boolean;
|
||||
createDashboardHref?: string;
|
||||
dashboardQuery?: string;
|
||||
}) => {
|
||||
type Tab =
|
||||
| 'time'
|
||||
| 'search'
|
||||
| 'histogram'
|
||||
| 'markdown'
|
||||
| 'number'
|
||||
| 'table'
|
||||
| undefined;
|
||||
|
||||
const [tab, setTab] = useState<Tab>(undefined);
|
||||
const displayedTab = tab ?? chart?.series?.[0]?.type ?? 'time';
|
||||
|
||||
const onTabClick = useCallback(
|
||||
(newTab: Tab) => {
|
||||
setTab(newTab);
|
||||
if (setEditedChart != null && editedChart != null) {
|
||||
setEditedChart(
|
||||
produce(editedChart, draft => {
|
||||
for (const series of draft.series) {
|
||||
series.type = newTab ?? 'time';
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[setTab, setEditedChart, editedChart],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex justify="content-between" align="center" mb="sm">
|
||||
<TabBar
|
||||
className="fs-8 flex-grow-1"
|
||||
items={[
|
||||
{
|
||||
text: (
|
||||
<span>
|
||||
<i className="bi bi-graph-up" /> Line Chart
|
||||
</span>
|
||||
),
|
||||
value: 'time',
|
||||
},
|
||||
...(hideSearch === true
|
||||
? []
|
||||
: [
|
||||
{
|
||||
text: (
|
||||
<span>
|
||||
<i className="bi bi-card-list" /> Search Results
|
||||
</span>
|
||||
),
|
||||
value: 'search' as const,
|
||||
},
|
||||
]),
|
||||
{
|
||||
text: (
|
||||
<span>
|
||||
<i className="bi bi-table" /> Table
|
||||
</span>
|
||||
),
|
||||
value: 'table',
|
||||
},
|
||||
{
|
||||
text: (
|
||||
<span>
|
||||
<Histogram width={12} color="#fff" /> Histogram
|
||||
</span>
|
||||
),
|
||||
value: 'histogram',
|
||||
},
|
||||
{
|
||||
text: (
|
||||
<span>
|
||||
<i className="bi bi-123"></i> Number
|
||||
</span>
|
||||
),
|
||||
value: 'number',
|
||||
},
|
||||
...(hideMarkdown === true
|
||||
? []
|
||||
: [
|
||||
{
|
||||
text: (
|
||||
<span>
|
||||
<i className="bi bi-markdown"></i> Markdown
|
||||
</span>
|
||||
),
|
||||
value: 'markdown' as const,
|
||||
},
|
||||
]),
|
||||
]}
|
||||
activeItem={displayedTab}
|
||||
onClick={onTabClick}
|
||||
/>
|
||||
{createDashboardHref != null && (
|
||||
<Box ml="md">
|
||||
<Link href={createDashboardHref} passHref legacyBehavior>
|
||||
<Button variant="outline" size="xs" component="a">
|
||||
Create Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
{displayedTab === 'time' && chart != null && (
|
||||
<EditLineChartForm
|
||||
isLocalDashboard={isLocalDashboard}
|
||||
isAddingAlert={isAddingAlert}
|
||||
chart={produce(chart, draft => {
|
||||
for (const series of draft.series) {
|
||||
series.type = 'time';
|
||||
}
|
||||
})}
|
||||
alerts={alerts}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
dateRange={dateRange}
|
||||
editedChart={editedChart}
|
||||
setEditedChart={setEditedChart}
|
||||
setDisplayedTimeInputValue={setDisplayedTimeInputValue}
|
||||
displayedTimeInputValue={displayedTimeInputValue}
|
||||
onTimeRangeSearch={onTimeRangeSearch}
|
||||
granularity={granularity}
|
||||
setGranularity={setGranularity}
|
||||
dashboardQuery={dashboardQuery}
|
||||
/>
|
||||
)}
|
||||
{displayedTab === 'table' && chart != null && (
|
||||
<EditTableChartForm
|
||||
chart={produce(chart, draft => {
|
||||
for (const series of draft.series) {
|
||||
series.type = 'table';
|
||||
}
|
||||
})}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
dateRange={dateRange}
|
||||
editedChart={editedChart}
|
||||
setEditedChart={setEditedChart}
|
||||
setDisplayedTimeInputValue={setDisplayedTimeInputValue}
|
||||
displayedTimeInputValue={displayedTimeInputValue}
|
||||
onTimeRangeSearch={onTimeRangeSearch}
|
||||
dashboardQuery={dashboardQuery}
|
||||
/>
|
||||
)}
|
||||
{displayedTab === 'histogram' && chart != null && (
|
||||
<EditHistogramChartForm
|
||||
chart={produce(chart, draft => {
|
||||
draft.series[0].type = 'histogram';
|
||||
})}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
dateRange={dateRange}
|
||||
editedChart={editedChart}
|
||||
setEditedChart={setEditedChart}
|
||||
setDisplayedTimeInputValue={setDisplayedTimeInputValue}
|
||||
displayedTimeInputValue={displayedTimeInputValue}
|
||||
onTimeRangeSearch={onTimeRangeSearch}
|
||||
dashboardQuery={dashboardQuery}
|
||||
/>
|
||||
)}
|
||||
{displayedTab === 'search' && chart != null && (
|
||||
<EditSearchChartForm
|
||||
chart={produce(chart, draft => {
|
||||
draft.series[0].type = 'search';
|
||||
})}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
dateRange={dateRange}
|
||||
dashboardQuery={dashboardQuery}
|
||||
/>
|
||||
)}
|
||||
{displayedTab === 'number' && chart != null && (
|
||||
<EditNumberChartForm
|
||||
chart={produce(chart, draft => {
|
||||
draft.series[0].type = 'number';
|
||||
})}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
editedChart={editedChart}
|
||||
setEditedChart={setEditedChart}
|
||||
dateRange={dateRange}
|
||||
setDisplayedTimeInputValue={setDisplayedTimeInputValue}
|
||||
displayedTimeInputValue={displayedTimeInputValue}
|
||||
onTimeRangeSearch={onTimeRangeSearch}
|
||||
dashboardQuery={dashboardQuery}
|
||||
/>
|
||||
)}
|
||||
{displayedTab === 'markdown' && chart != null && (
|
||||
<EditMarkdownChartForm
|
||||
chart={produce(chart, draft => {
|
||||
draft.series[0].type = 'markdown';
|
||||
})}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditTileForm;
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { Box, Button, Card, Flex } from '@mantine/core';
|
||||
|
||||
import {
|
||||
convertDateRangeToGranularityString,
|
||||
MS_NUMBER_FORMAT,
|
||||
} from './ChartUtils';
|
||||
import HDXHistogramChart from './HDXHistogramChart';
|
||||
import HDXMultiSeriesTimeChart from './HDXMultiSeriesTimeChart';
|
||||
import { Histogram } from './SVGIcons';
|
||||
|
||||
export default function EndpointLatencyTile({
|
||||
height = 300,
|
||||
dateRange,
|
||||
scopeWhereQuery = (where: string) => where,
|
||||
}: {
|
||||
height?: number;
|
||||
dateRange: [Date, Date];
|
||||
scopeWhereQuery?: (where: string) => string;
|
||||
}) {
|
||||
const [chartType, setChartType] = React.useState<'line' | 'histogram'>(
|
||||
'line',
|
||||
);
|
||||
|
||||
return (
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py={5} withBorder>
|
||||
<Flex justify="space-between" align="center">
|
||||
<span>Request Latency</span>
|
||||
<Box>
|
||||
<Button.Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color={chartType === 'line' ? 'green' : 'dark.2'}
|
||||
size="xs"
|
||||
title="Line Chart"
|
||||
onClick={() => setChartType('line')}
|
||||
>
|
||||
<i className="bi bi-graph-up" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color={chartType === 'histogram' ? 'green' : 'dark.2'}
|
||||
size="xs"
|
||||
title="Histogram"
|
||||
onClick={() => setChartType('histogram')}
|
||||
>
|
||||
<Histogram width={12} color="currentColor" />
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={height}>
|
||||
{chartType === 'line' ? (
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(dateRange, 60),
|
||||
series: [
|
||||
{
|
||||
displayName: '95th Percentile',
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'p95',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: [],
|
||||
numberFormat: MS_NUMBER_FORMAT,
|
||||
},
|
||||
{
|
||||
displayName: 'Median',
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'p50',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: [],
|
||||
},
|
||||
{
|
||||
displayName: 'Average',
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'avg',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
seriesReturnType: 'column',
|
||||
}}
|
||||
showDisplaySwitcher={false}
|
||||
/>
|
||||
) : (
|
||||
<HDXHistogramChart
|
||||
config={{
|
||||
table: 'logs',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
dateRange,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import Drawer from 'react-modern-drawer';
|
||||
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
|
||||
import { Card, Grid, Text } from '@mantine/core';
|
||||
|
||||
import { DrawerBody, DrawerHeader } from './components/DrawerUtils';
|
||||
import {
|
||||
convertDateRangeToGranularityString,
|
||||
ERROR_RATE_PERCENTAGE_NUMBER_FORMAT,
|
||||
INTEGER_NUMBER_FORMAT,
|
||||
} from './ChartUtils';
|
||||
import EndpointLatencyTile from './EndpointLatencyTile';
|
||||
import { HDXSpanPerformanceBarChart } from './HDXListBarChart';
|
||||
import HDXMultiSeriesTimeChart from './HDXMultiSeriesTimeChart';
|
||||
import SlowestEventsTile from './SlowestEventsTile';
|
||||
import { parseTimeQuery, useTimeQuery } from './timeQuery';
|
||||
import { useZIndex, ZIndexContext } from './zIndex';
|
||||
|
||||
import styles from '../styles/LogSidePanel.module.scss';
|
||||
|
||||
const defaultTimeRange = parseTimeQuery('Past 1h', false);
|
||||
|
||||
const CHART_HEIGHT = 300;
|
||||
|
||||
export default function EndpointSidePanel() {
|
||||
const [service] = useQueryParam('service', withDefault(StringParam, ''), {
|
||||
updateType: 'replaceIn',
|
||||
});
|
||||
|
||||
const [endpoint, setEndpoint] = useQueryParam(
|
||||
'endpoint',
|
||||
withDefault(StringParam, ''),
|
||||
{ updateType: 'replaceIn' },
|
||||
);
|
||||
|
||||
const { searchedTimeRange: dateRange } = useTimeQuery({
|
||||
defaultValue: 'Past 1h',
|
||||
defaultTimeRange: [
|
||||
defaultTimeRange?.[0]?.getTime() ?? -1,
|
||||
defaultTimeRange?.[1]?.getTime() ?? -1,
|
||||
],
|
||||
});
|
||||
|
||||
const scopeWhereQuery = React.useCallback(
|
||||
(where: string) => {
|
||||
const spanNameQuery = endpoint ? `span_name:"${endpoint}" ` : '';
|
||||
const whereQuery = where ? `(${where})` : '';
|
||||
const serviceQuery = service ? `service:"${service}" ` : '';
|
||||
return `${spanNameQuery}${serviceQuery}${whereQuery} span.kind:"server"`.trim();
|
||||
},
|
||||
[endpoint, service],
|
||||
);
|
||||
const contextZIndex = useZIndex();
|
||||
const drawerZIndex = contextZIndex + 10;
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setEndpoint(undefined);
|
||||
}, [setEndpoint]);
|
||||
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
enableOverlay
|
||||
overlayOpacity={0.1}
|
||||
duration={0}
|
||||
open={!!endpoint}
|
||||
onClose={handleClose}
|
||||
direction="right"
|
||||
size={'80vw'}
|
||||
zIndex={drawerZIndex}
|
||||
>
|
||||
<ZIndexContext.Provider value={drawerZIndex}>
|
||||
<div className={styles.panel}>
|
||||
<DrawerHeader
|
||||
header={`Details for ${endpoint}`}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<DrawerBody>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Request Error Rate
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
series: [
|
||||
{
|
||||
displayName: 'Error Rate %',
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: scopeWhereQuery('level:"error"'),
|
||||
groupBy: [],
|
||||
numberFormat: ERROR_RATE_PERCENTAGE_NUMBER_FORMAT,
|
||||
},
|
||||
{
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
field: '',
|
||||
where: scopeWhereQuery(''),
|
||||
groupBy: [],
|
||||
numberFormat: ERROR_RATE_PERCENTAGE_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
seriesReturnType: 'ratio',
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Request Throughput
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
series: [
|
||||
{
|
||||
displayName: 'Requests',
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: scopeWhereQuery(''),
|
||||
groupBy: [],
|
||||
numberFormat: {
|
||||
...INTEGER_NUMBER_FORMAT,
|
||||
unit: 'requests',
|
||||
},
|
||||
},
|
||||
],
|
||||
seriesReturnType: 'column',
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
20 Top Most Time Consuming Operations
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXSpanPerformanceBarChart
|
||||
config={{
|
||||
spanName: endpoint,
|
||||
dateRange,
|
||||
parentSpanWhere: scopeWhereQuery(''),
|
||||
childrenSpanWhere: service
|
||||
? `service:"${service}"`
|
||||
: '',
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<EndpointLatencyTile
|
||||
dateRange={dateRange}
|
||||
height={CHART_HEIGHT}
|
||||
scopeWhereQuery={scopeWhereQuery}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SlowestEventsTile
|
||||
dateRange={dateRange}
|
||||
height={CHART_HEIGHT}
|
||||
scopeWhereQuery={scopeWhereQuery}
|
||||
title={<Text>Slowest 10% of Transactions</Text>}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</DrawerBody>
|
||||
</div>
|
||||
</ZIndexContext.Provider>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import type { StacktraceFrame } from './types';
|
||||
|
||||
export const parseEvents = (__events?: string) => {
|
||||
try {
|
||||
return JSON.parse(__events || '[]')[0].fields.reduce(
|
||||
(acc: any, field: any) => {
|
||||
try {
|
||||
acc[field.key] = JSON.parse(field.value);
|
||||
} catch (e) {
|
||||
acc[field.key] = field.value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getFirstFrame = (frames?: StacktraceFrame[]) => {
|
||||
if (!frames || !frames.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
frames.find(frame => frame.in_app) ??
|
||||
frames.find(frame => !!frame.function || !!frame.filename) ??
|
||||
frames[0]
|
||||
);
|
||||
};
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { CategoricalChartState } from 'recharts/types/chart/generateCategoricalChart';
|
||||
|
||||
import api from './api';
|
||||
import { generateSearchUrl } from './utils';
|
||||
|
||||
const MemoChart = memo(function MemoChart({
|
||||
graphResults,
|
||||
generateSearchUrl,
|
||||
}: {
|
||||
graphResults: any[];
|
||||
generateSearchUrl: (lower: string, upper: string) => string;
|
||||
}) {
|
||||
const data = useMemo(() => {
|
||||
return (
|
||||
graphResults?.map((result: any) => {
|
||||
return {
|
||||
lower: result[0],
|
||||
upper: result[1],
|
||||
height: result[2],
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}, [graphResults]);
|
||||
|
||||
const barChartRef = useRef<any>();
|
||||
const activeBar = useRef<CategoricalChartState>();
|
||||
|
||||
useHotkeys(['esc'], () => {
|
||||
activeBar.current = undefined;
|
||||
});
|
||||
|
||||
// Complete hack
|
||||
// See: https://github.com/recharts/recharts/issues/1231#issuecomment-1237958802
|
||||
const setChartActive = (payload: {
|
||||
activeCoordinate?: { x: number; y: number };
|
||||
activeLabel: any;
|
||||
activePayload?: any[];
|
||||
}) => {
|
||||
if (barChartRef.current == null) return;
|
||||
|
||||
if (activeBar.current == null) {
|
||||
// @ts-ignore
|
||||
return barChartRef.current.setState({
|
||||
isTooltipActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
barChartRef.current.setState({
|
||||
isTooltipActive: true,
|
||||
activeCoordinate: payload.activeCoordinate,
|
||||
activeLabel: payload.activeLabel,
|
||||
activePayload: payload.activePayload,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0}>
|
||||
<BarChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
className="user-select-none cursor-crosshair"
|
||||
ref={barChartRef}
|
||||
onMouseMove={() => {
|
||||
if (activeBar.current == null) return;
|
||||
|
||||
setChartActive({
|
||||
activeCoordinate: activeBar.current.activeCoordinate,
|
||||
activeLabel: activeBar.current.activeLabel,
|
||||
activePayload: activeBar.current.activePayload,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
activeBar.current = undefined;
|
||||
}}
|
||||
onClick={click => {
|
||||
activeBar.current = click;
|
||||
|
||||
if (click != null) {
|
||||
setChartActive({
|
||||
activeCoordinate: activeBar.current.activeCoordinate,
|
||||
activeLabel: activeBar.current.activeLabel,
|
||||
activePayload: activeBar.current.activePayload,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey={'lower'}
|
||||
domain={
|
||||
data.length > 1
|
||||
? [data[0].lower, data[data.length - 1].upper]
|
||||
: undefined
|
||||
}
|
||||
interval="preserveStartEnd"
|
||||
type="category"
|
||||
tickFormatter={(value: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(value)
|
||||
}
|
||||
tick={{ fontSize: 12, fontFamily: 'IBM Plex Mono, monospace' }}
|
||||
/>
|
||||
<YAxis
|
||||
width={35}
|
||||
minTickGap={25}
|
||||
tickFormatter={(value: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(value)
|
||||
}
|
||||
tick={{ fontSize: 12, fontFamily: 'IBM Plex Mono, monospace' }}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<HDXHistogramChartTooltip generateSearchUrl={generateSearchUrl} />
|
||||
}
|
||||
active
|
||||
/>
|
||||
<Bar dataKey="height" stackId="a" fill="#50FA7B" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
});
|
||||
|
||||
const HDXHistogramChartTooltip = (props: any) => {
|
||||
const { active, payload, generateSearchUrl } = props;
|
||||
if (active && payload && payload.length > 0) {
|
||||
const bucket = props.payload[0].payload;
|
||||
|
||||
const lower = bucket.lower.toFixed(5);
|
||||
const upper = bucket.upper.toFixed(5);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-grey px-3 py-2 rounded fs-8"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<div className="mb-2">
|
||||
Bucket: {lower} - {upper}
|
||||
</div>
|
||||
{payload.map((p: any) => (
|
||||
<div key={p.name} style={{ color: p.color }}>
|
||||
Number of Events: {p.value}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2">
|
||||
<Link
|
||||
href={generateSearchUrl(lower, upper)}
|
||||
className="text-muted-hover cursor-pointer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
View Events
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-muted fs-9 mt-2">
|
||||
Click to Pin Tooltip • Approx value via SPDT algorithm
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const HDXHistogramChart = memo(
|
||||
({
|
||||
config: { table, field, where, dateRange },
|
||||
onSettled,
|
||||
}: {
|
||||
config: {
|
||||
table: string;
|
||||
field: string;
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
onSettled?: () => void;
|
||||
}) => {
|
||||
const { data, isError, isLoading } = api.useLogsChartHistogram(
|
||||
{
|
||||
endDate: dateRange[1] ?? new Date(),
|
||||
field,
|
||||
q: where,
|
||||
startDate: dateRange[0] ?? new Date(),
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
dateRange[0] != null &&
|
||||
dateRange[1] != null &&
|
||||
typeof field === 'string' &&
|
||||
field.length > 0,
|
||||
onSettled,
|
||||
},
|
||||
);
|
||||
|
||||
const genSearchUrl = useCallback(
|
||||
(lower: string, upper: string) => {
|
||||
return generateSearchUrl({
|
||||
query: `${where} ${field}:[${lower} TO ${upper}]`.trim(),
|
||||
dateRange,
|
||||
});
|
||||
},
|
||||
[where, field, dateRange],
|
||||
);
|
||||
|
||||
// Don't ask me why...
|
||||
const buckets = data?.data?.[0]?.data;
|
||||
|
||||
return isLoading ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
Loading Chart Data...
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
Error loading chart, please try again or contact support.
|
||||
</div>
|
||||
) : buckets?.length === 0 ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
No data found within time range.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
// Hack, recharts will release real fix soon https://github.com/recharts/recharts/issues/172
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
<MemoChart graphResults={buckets} generateSearchUrl={genSearchUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default HDXHistogramChart;
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
import { memo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Box, Flex, HoverCard, Text } from '@mantine/core';
|
||||
import { FloatingPosition } from '@mantine/core/lib/components/Floating';
|
||||
|
||||
import api from './api';
|
||||
import { Granularity, MS_NUMBER_FORMAT, seriesColumns } from './ChartUtils';
|
||||
import type { ChartSeries, NumberFormat } from './types';
|
||||
import { formatNumber, semanticKeyedColor } from './utils';
|
||||
|
||||
function ListItem({
|
||||
title,
|
||||
value,
|
||||
color,
|
||||
percent,
|
||||
hoverCardContent,
|
||||
hoverCardPosition = 'right',
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
color: string;
|
||||
percent: number;
|
||||
hoverCardContent?: React.ReactNode;
|
||||
hoverCardPosition?: FloatingPosition;
|
||||
}) {
|
||||
const item = (
|
||||
<Box>
|
||||
<Flex justify="space-between">
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ overflowWrap: 'anywhere' }}
|
||||
pr="xs"
|
||||
lineClamp={2}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm">{value}</Text>
|
||||
</Flex>
|
||||
<Box pt="xs">
|
||||
<Box
|
||||
style={{
|
||||
width: `${percent}%`,
|
||||
height: 8,
|
||||
backgroundColor: color,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
return hoverCardContent ? (
|
||||
<HoverCard
|
||||
width={380}
|
||||
shadow="md"
|
||||
position={hoverCardPosition}
|
||||
withinPortal
|
||||
>
|
||||
<HoverCard.Target>{item}</HoverCard.Target>
|
||||
<HoverCard.Dropdown>{hoverCardContent}</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
) : (
|
||||
item
|
||||
);
|
||||
}
|
||||
|
||||
type Row = {
|
||||
group: string[];
|
||||
[dataKey: `series_${number}.data`]: number;
|
||||
};
|
||||
|
||||
function ListBar({
|
||||
rows,
|
||||
getRowSearchLink,
|
||||
columns,
|
||||
hoverCardPosition,
|
||||
}: {
|
||||
rows: Row[];
|
||||
getRowSearchLink?: (row: Row) => string;
|
||||
columns: {
|
||||
dataKey: `series_${number}.data`;
|
||||
displayName: string;
|
||||
numberFormat?: NumberFormat;
|
||||
visible?: boolean;
|
||||
}[];
|
||||
hoverCardPosition?: FloatingPosition;
|
||||
}) {
|
||||
const values = (rows ?? []).map(row => row['series_0.data']);
|
||||
const maxValue = Math.max(...values);
|
||||
const totalValue = values.reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows?.map((row, index) => {
|
||||
const value = row['series_0.data'];
|
||||
const percentOfMax = (value / maxValue) * 100;
|
||||
const percentOfTotal = (value / totalValue) * 100;
|
||||
const group = `${row.group.join(' ').trim()}`;
|
||||
|
||||
const hoverCardContent =
|
||||
columns.length > 0 ? (
|
||||
<Box>
|
||||
<Box mb="xs">
|
||||
<Text
|
||||
size="xs"
|
||||
style={{ overflowWrap: 'anywhere' }}
|
||||
lineClamp={4}
|
||||
>
|
||||
{group}
|
||||
</Text>
|
||||
</Box>
|
||||
{columns
|
||||
.filter(c => c.visible !== false)
|
||||
.map(column => {
|
||||
const value = row[column.dataKey];
|
||||
return (
|
||||
<Box key={column.displayName}>
|
||||
<Text size="xs" fw={500} span>
|
||||
{column.displayName}:{' '}
|
||||
</Text>
|
||||
<Text size="xs" span>
|
||||
{column.numberFormat != null
|
||||
? formatNumber(value, column.numberFormat) ?? 'N/A'
|
||||
: value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
return getRowSearchLink ? (
|
||||
<Link href={getRowSearchLink(row)} passHref legacyBehavior>
|
||||
<Box
|
||||
mb="sm"
|
||||
key={group}
|
||||
component="a"
|
||||
td="none"
|
||||
className="cursor-pointer"
|
||||
display="block"
|
||||
c="inherit"
|
||||
>
|
||||
<ListItem
|
||||
title={group}
|
||||
value={`${percentOfTotal.toFixed(2)}%`}
|
||||
color={semanticKeyedColor(group, index)}
|
||||
percent={percentOfMax}
|
||||
hoverCardContent={hoverCardContent}
|
||||
hoverCardPosition={hoverCardPosition}
|
||||
/>
|
||||
</Box>
|
||||
</Link>
|
||||
) : (
|
||||
<Box mb="sm" key={group}>
|
||||
<ListItem
|
||||
title={group}
|
||||
value={`${percentOfTotal.toFixed(2)}%`}
|
||||
color={semanticKeyedColor(group, index)}
|
||||
percent={percentOfMax}
|
||||
hoverCardContent={hoverCardContent}
|
||||
hoverCardPosition={hoverCardPosition}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const HDXListBarChart = memo(
|
||||
({
|
||||
config: { series, seriesReturnType = 'column', dateRange },
|
||||
getRowSearchLink,
|
||||
hoverCardPosition,
|
||||
}: {
|
||||
config: {
|
||||
series: ChartSeries[];
|
||||
granularity: Granularity;
|
||||
dateRange: [Date, Date];
|
||||
seriesReturnType?: 'ratio' | 'column';
|
||||
numberFormat?: NumberFormat;
|
||||
groupColumnName?: string;
|
||||
};
|
||||
onSettled?: () => void;
|
||||
getRowSearchLink?: (row: Row) => string;
|
||||
hoverCardPosition?: FloatingPosition;
|
||||
}) => {
|
||||
const { data, isError, isLoading } = api.useMultiSeriesChart({
|
||||
series,
|
||||
endDate: dateRange[1] ?? new Date(),
|
||||
startDate: dateRange[0] ?? new Date(),
|
||||
seriesReturnType,
|
||||
});
|
||||
|
||||
const rows: any[] = data?.data ?? [];
|
||||
|
||||
return isLoading ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
Loading Chart Data...
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
Error loading chart, please try again or contact support.
|
||||
</div>
|
||||
) : data?.data?.length === 0 ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
No data found within time range.
|
||||
</div>
|
||||
) : (
|
||||
<Box className="overflow-auto" h="100%">
|
||||
<ListBar
|
||||
rows={rows}
|
||||
getRowSearchLink={getRowSearchLink}
|
||||
columns={seriesColumns({ series, seriesReturnType: 'column' })}
|
||||
hoverCardPosition={hoverCardPosition}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const HDXSpanPerformanceBarChart = memo(
|
||||
({
|
||||
config: { spanName, parentSpanWhere, childrenSpanWhere, dateRange },
|
||||
}: {
|
||||
config: {
|
||||
spanName: string;
|
||||
parentSpanWhere: string;
|
||||
childrenSpanWhere: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
onSettled?: () => void;
|
||||
}) => {
|
||||
const { data, isError, isLoading } = api.useSpanPerformanceChart({
|
||||
parentSpanWhere,
|
||||
childrenSpanWhere,
|
||||
endDate: dateRange[1] ?? new Date(),
|
||||
startDate: dateRange[0] ?? new Date(),
|
||||
});
|
||||
|
||||
const rows: any[] =
|
||||
data?.data?.filter(row => {
|
||||
return row.group[0] != spanName;
|
||||
}) ?? [];
|
||||
|
||||
const getRowSearchLink = (row: Row) => {
|
||||
const urlQ =
|
||||
row.group.length > 1 && row.group[1]
|
||||
? ` (http.host:"${row.group[1]}" OR server.address:"${row.group[1]}")`
|
||||
: '';
|
||||
const qparams = new URLSearchParams({
|
||||
q: `${childrenSpanWhere} span_name:"${row.group[0]}"${urlQ}`.trim(),
|
||||
from: `${dateRange[0].getTime()}`,
|
||||
to: `${dateRange[1].getTime()}`,
|
||||
});
|
||||
return `/search?${qparams.toString()}`;
|
||||
};
|
||||
|
||||
return isLoading ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-slate-400">
|
||||
Loading Chart Data...
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-slate-400">
|
||||
Error loading chart, please try again or contact support.
|
||||
</div>
|
||||
) : rows?.length === 0 ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-slate-400">
|
||||
No children spans recorded for this route
|
||||
</div>
|
||||
) : (
|
||||
<Box className="overflow-auto" h="100%">
|
||||
<ListBar
|
||||
rows={rows}
|
||||
getRowSearchLink={getRowSearchLink}
|
||||
columns={[
|
||||
{
|
||||
displayName: 'Total Time Spent',
|
||||
numberFormat: MS_NUMBER_FORMAT,
|
||||
dataKey: 'series_0.data',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
displayName: 'Number of Calls',
|
||||
dataKey: 'series_1.data',
|
||||
},
|
||||
{
|
||||
displayName: 'Average Duration',
|
||||
numberFormat: MS_NUMBER_FORMAT,
|
||||
dataKey: 'series_2.data',
|
||||
},
|
||||
{
|
||||
displayName: 'Min Duration',
|
||||
numberFormat: MS_NUMBER_FORMAT,
|
||||
dataKey: 'series_3.data',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Duration',
|
||||
numberFormat: MS_NUMBER_FORMAT,
|
||||
dataKey: 'series_4.data',
|
||||
},
|
||||
{
|
||||
displayName: 'Number of Requests',
|
||||
dataKey: 'series_5.data',
|
||||
},
|
||||
{
|
||||
displayName: 'Calls per Request',
|
||||
dataKey: 'series_6.data',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default HDXListBarChart;
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import api from './api';
|
||||
import type { ChartSeries, NumberFormat } from './types';
|
||||
import { formatNumber } from './utils';
|
||||
|
||||
const HDXNumberChart = memo(
|
||||
({
|
||||
config: { series, dateRange, numberFormat },
|
||||
onSettled,
|
||||
}: {
|
||||
config: {
|
||||
series: ChartSeries[];
|
||||
dateRange: [Date, Date];
|
||||
numberFormat?: NumberFormat;
|
||||
};
|
||||
onSettled?: () => void;
|
||||
}) => {
|
||||
const { data, isError, isLoading } = api.useMultiSeriesChart(
|
||||
{
|
||||
series,
|
||||
startDate: dateRange[0],
|
||||
endDate: dateRange[1],
|
||||
seriesReturnType: series.length > 1 ? 'ratio' : 'column',
|
||||
},
|
||||
{
|
||||
onSettled,
|
||||
},
|
||||
);
|
||||
const sortedData = data?.data?.sort(
|
||||
(a: any, b: any) => b?.ts_bucket - a?.ts_bucket,
|
||||
);
|
||||
|
||||
const number = formatNumber(
|
||||
sortedData?.[0]?.['series_0.data'],
|
||||
numberFormat,
|
||||
);
|
||||
|
||||
return isLoading ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
Loading Chart Data...
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
Error loading chart, please try again or contact support.
|
||||
</div>
|
||||
) : data?.data?.length === 0 ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
No data found within time range.
|
||||
</div>
|
||||
) : (
|
||||
<div className="d-flex align-items-center justify-content-center fs-2 h-100">
|
||||
{number}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default HDXNumberChart;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +0,0 @@
|
|||
import { getLogLevelClass } from './utils';
|
||||
|
||||
export default function LogLevel({ level }: { level: string }) {
|
||||
const lvlClass = getLogLevelClass(level);
|
||||
|
||||
const colorClass =
|
||||
lvlClass === 'error'
|
||||
? 'text-danger'
|
||||
: lvlClass === 'warn'
|
||||
? 'text-warning'
|
||||
: 'text-muted';
|
||||
return <span className={colorClass}>{level}</span>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,944 +0,0 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import curry from 'lodash/curry';
|
||||
import { Button, Modal } from 'react-bootstrap';
|
||||
import { CSVLink } from 'react-csv';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { Text } from '@mantine/core';
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
ColumnResizeMode,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
Row as TableRow,
|
||||
TableOptions,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import api from './api';
|
||||
import Checkbox from './Checkbox';
|
||||
import { IS_LOCAL_MODE } from './config';
|
||||
import FieldMultiSelect from './FieldMultiSelect';
|
||||
import InstallInstructionsModal from './InstallInstructionsModal';
|
||||
import LogLevel from './LogLevel';
|
||||
import { useSearchEventStream } from './search';
|
||||
import { UNDEFINED_WIDTH } from './tableUtils';
|
||||
import { FormatTime } from './useFormatTime';
|
||||
import { useUserPreferences } from './useUserPreferences';
|
||||
import { useLocalStorage, usePrevious, useWindowSize } from './utils';
|
||||
|
||||
import styles from '../styles/LogTable.module.scss';
|
||||
type Row = Record<string, any> & { duration: number };
|
||||
type AccessorFn = (row: Row, column: string) => any;
|
||||
|
||||
const SPECIAL_VALUES = {
|
||||
not_available: 'NULL',
|
||||
};
|
||||
const ACCESSOR_MAP: Record<string, AccessorFn> = {
|
||||
duration: row =>
|
||||
row.duration >= 0 ? row.duration : SPECIAL_VALUES.not_available,
|
||||
default: (row, column) => row[column],
|
||||
};
|
||||
|
||||
const MAX_SCROLL_FETCH_NEW_PAGE_ATTEMPTS = 20;
|
||||
|
||||
function retrieveColumnValue(column: string, row: Row): any {
|
||||
const accessor = ACCESSOR_MAP[column] ?? ACCESSOR_MAP.default;
|
||||
return accessor(row, column);
|
||||
}
|
||||
|
||||
function DownloadCSVButton({
|
||||
config: { where, dateRange },
|
||||
extraFields,
|
||||
}: {
|
||||
config: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
extraFields: string[];
|
||||
}) {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const { data: searchResultsPages, isFetching: isSearchResultsFetching } =
|
||||
api.useLogBatch(
|
||||
{
|
||||
q: where,
|
||||
startDate: dateRange?.[0] ?? new Date(),
|
||||
endDate: dateRange?.[1] ?? new Date(),
|
||||
extraFields,
|
||||
order: null,
|
||||
limit: 4000,
|
||||
},
|
||||
{
|
||||
enabled: downloading,
|
||||
refetchOnWindowFocus: false,
|
||||
getNextPageParam: (lastPage: any, allPages) => {
|
||||
if (lastPage.rows === 0) return undefined;
|
||||
return allPages.flatMap(page => page.data).length;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const csvData = useMemo(() => {
|
||||
if (searchResultsPages == null) return [];
|
||||
return searchResultsPages.pages.flatMap(page =>
|
||||
page.data.map(
|
||||
({
|
||||
_platform,
|
||||
_host,
|
||||
id,
|
||||
sort_key,
|
||||
type,
|
||||
timestamp,
|
||||
severity_text,
|
||||
_service,
|
||||
body,
|
||||
...row
|
||||
}) => ({
|
||||
timestamp: timestamp,
|
||||
level: severity_text,
|
||||
service: _service,
|
||||
...row,
|
||||
message: body,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, [searchResultsPages]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!downloading ? (
|
||||
<span>
|
||||
<Button size="sm" variant="dark" onClick={() => setDownloading(true)}>
|
||||
Download Search Results as CSV
|
||||
</Button>{' '}
|
||||
<span className="text-muted fs-7.5">(Max 4,000 events)</span>
|
||||
</span>
|
||||
) : isSearchResultsFetching ? (
|
||||
<span>Fetching results...</span>
|
||||
) : csvData.length > 0 ? (
|
||||
<CSVLink
|
||||
data={csvData}
|
||||
filename={`HyperDX_search_${where.replace(/[^a-zA-Z0-9]/g, '_')}`}
|
||||
>
|
||||
<Button size="sm" variant="success">
|
||||
Download CSV
|
||||
</Button>
|
||||
</CSVLink>
|
||||
) : (
|
||||
<span>An error occurred.</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LogTableSettingsModal({
|
||||
show,
|
||||
onHide,
|
||||
onDone,
|
||||
initialAdditionalColumns,
|
||||
initialWrapLines,
|
||||
downloadCSVButton,
|
||||
}: {
|
||||
initialAdditionalColumns: string[];
|
||||
initialWrapLines: boolean;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onDone: (settings: {
|
||||
additionalColumns: string[];
|
||||
wrapLines: boolean;
|
||||
}) => void;
|
||||
downloadCSVButton: JSX.Element;
|
||||
}) {
|
||||
const [additionalColumns, setAdditionalColumns] = useState<string[]>(
|
||||
initialAdditionalColumns,
|
||||
);
|
||||
const [wrapLines, setWrapLines] = useState(initialWrapLines);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
aria-labelledby="contained-modal-title-vcenter"
|
||||
centered
|
||||
onHide={onHide}
|
||||
show={show}
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Body className="bg-hdx-dark rounded">
|
||||
<div className="fs-5 mb-4">Event Viewer Options</div>
|
||||
<div className="mb-2 text-muted">Display Additional Columns</div>
|
||||
<FieldMultiSelect
|
||||
values={additionalColumns}
|
||||
setValues={(values: string[]) => setAdditionalColumns(values)}
|
||||
types={['string', 'number', 'bool']}
|
||||
/>
|
||||
<Checkbox
|
||||
id="wrap-lines"
|
||||
className="mt-4"
|
||||
labelClassName="fs-7"
|
||||
checked={wrapLines}
|
||||
onChange={() => setWrapLines(!wrapLines)}
|
||||
label="Wrap Lines"
|
||||
/>
|
||||
<div className="mt-4 text-muted fs-8">
|
||||
UTC setting moved to User Preferences
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="mb-2">Download Search Results</div>
|
||||
{downloadCSVButton}
|
||||
</div>
|
||||
<div className="mt-4 d-flex justify-content-between">
|
||||
<Button
|
||||
variant="outline-success"
|
||||
className="fs-7 text-muted-hover"
|
||||
onClick={() => {
|
||||
onDone({ additionalColumns, wrapLines });
|
||||
onHide();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
<Button variant="dark" onClick={() => onHide()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export const RawLogTable = memo(
|
||||
({
|
||||
tableId,
|
||||
displayedColumns,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
highlightedLineId,
|
||||
isLive,
|
||||
isLoading,
|
||||
logs,
|
||||
onInstructionsClick,
|
||||
// onPropertySearchClick,
|
||||
onRowExpandClick,
|
||||
onScroll,
|
||||
onSettingsClick,
|
||||
onShowPatternsClick,
|
||||
wrapLines,
|
||||
columnNameMap,
|
||||
showServiceColumn = true,
|
||||
}: {
|
||||
wrapLines: boolean;
|
||||
displayedColumns: string[];
|
||||
onSettingsClick?: () => void;
|
||||
onInstructionsClick?: () => void;
|
||||
logs: {
|
||||
id: string;
|
||||
sort_key: string;
|
||||
_service?: string;
|
||||
severity_text: string;
|
||||
body: string;
|
||||
timestamp: string;
|
||||
}[];
|
||||
isLoading: boolean;
|
||||
fetchNextPage: (arg0?: { cb?: VoidFunction }) => any;
|
||||
onRowExpandClick: (id: string, sortKey: string) => void;
|
||||
// onPropertySearchClick: (
|
||||
// name: string,
|
||||
// value: string | number | boolean,
|
||||
// ) => void;
|
||||
hasNextPage: boolean;
|
||||
highlightedLineId: string | undefined;
|
||||
onScroll: (scrollTop: number) => void;
|
||||
isLive: boolean;
|
||||
onShowPatternsClick?: () => void;
|
||||
tableId?: string;
|
||||
columnNameMap?: Record<string, string>;
|
||||
showServiceColumn?: boolean;
|
||||
}) => {
|
||||
const dedupLogs = useMemo(() => {
|
||||
const lIds = new Set();
|
||||
return logs.filter(l => {
|
||||
if (lIds.has(l.id)) {
|
||||
return false;
|
||||
}
|
||||
lIds.add(l.id);
|
||||
return true;
|
||||
});
|
||||
}, [logs]);
|
||||
|
||||
const { width } = useWindowSize();
|
||||
const isSmallScreen = (width ?? 1000) < 900;
|
||||
const {
|
||||
userPreferences: { isUTC },
|
||||
} = useUserPreferences();
|
||||
|
||||
const [columnSizeStorage, setColumnSizeStorage] = useLocalStorage<
|
||||
Record<string, number>
|
||||
>(`${tableId}-column-sizes`, {});
|
||||
|
||||
//once the user has scrolled within 500px of the bottom of the table, fetch more data if there is any
|
||||
const FETCH_NEXT_PAGE_PX = 500;
|
||||
|
||||
//we need a reference to the scrolling element for logic down below
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset scroll when live tail is enabled for the first time
|
||||
const prevIsLive = usePrevious(isLive);
|
||||
useEffect(() => {
|
||||
if (isLive && prevIsLive === false && tableContainerRef.current != null) {
|
||||
tableContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [isLive, prevIsLive]);
|
||||
|
||||
const columns = useMemo<ColumnDef<any>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: () => '',
|
||||
cell: info => {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
className={cx('cursor-pointer', {
|
||||
'text-success': highlightedLineId === info.getValue(),
|
||||
'text-muted-hover': highlightedLineId !== info.getValue(),
|
||||
})}
|
||||
onMouseDown={e => {
|
||||
// For some reason this interfers with the onclick handler
|
||||
// inside a dashboard tile
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => {
|
||||
const { id, sort_key } = info.row.original;
|
||||
onRowExpandClick(id, sort_key);
|
||||
}}
|
||||
>
|
||||
<span className="bi bi-chevron-right" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 8,
|
||||
enableResizing: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'timestamp',
|
||||
header: () =>
|
||||
isSmallScreen
|
||||
? 'Time'
|
||||
: `Timestamp${isUTC ? ' (UTC)' : ' (Local)'}`,
|
||||
cell: info => {
|
||||
// FIXME: since original timestamp doesn't come with timezone info
|
||||
const date = new Date(info.getValue<string>());
|
||||
return (
|
||||
<span className="text-muted">
|
||||
<FormatTime
|
||||
value={date}
|
||||
format={isSmallScreen ? 'short' : 'withMs'}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
size: columnSizeStorage.timestamp ?? (isSmallScreen ? 75 : 180),
|
||||
},
|
||||
{
|
||||
accessorKey: 'severity_text',
|
||||
header: 'Level',
|
||||
cell: info => (
|
||||
<span
|
||||
// role="button"
|
||||
// onClick={() =>
|
||||
// onPropertySearchClick('level', info.getValue<string>())
|
||||
// }
|
||||
>
|
||||
<LogLevel level={info.getValue<string>()} />
|
||||
</span>
|
||||
),
|
||||
size: columnSizeStorage.severity_text ?? (isSmallScreen ? 50 : 100),
|
||||
},
|
||||
...(showServiceColumn
|
||||
? [
|
||||
{
|
||||
accessorKey: '_service',
|
||||
header: 'Service',
|
||||
cell: (info: CellContext<any, unknown>) => (
|
||||
<span
|
||||
// role="button"
|
||||
// onClick={() =>
|
||||
// onPropertySearchClick('service', info.getValue<string>())
|
||||
// }
|
||||
>
|
||||
{info.getValue<string>()}
|
||||
</span>
|
||||
),
|
||||
size: columnSizeStorage._service ?? (isSmallScreen ? 70 : 100),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(displayedColumns.map(column => ({
|
||||
accessorFn: curry(retrieveColumnValue)(column), // Columns can contain '.' and will not work with accessorKey
|
||||
header: columnNameMap?.[column] ?? column,
|
||||
cell: info => {
|
||||
const value = info.getValue<string>();
|
||||
return (
|
||||
<span
|
||||
className={cx({
|
||||
'text-muted': value === SPECIAL_VALUES.not_available,
|
||||
})}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
size: columnSizeStorage[column] ?? 150,
|
||||
})) as ColumnDef<any>[]),
|
||||
{
|
||||
accessorKey: 'body',
|
||||
header: () => (
|
||||
<span>
|
||||
Message{' '}
|
||||
{onShowPatternsClick != null && !IS_LOCAL_MODE && (
|
||||
<span>
|
||||
•{' '}
|
||||
<Text
|
||||
span
|
||||
size="xs"
|
||||
c="green"
|
||||
onClick={onShowPatternsClick}
|
||||
role="button"
|
||||
>
|
||||
<i className="bi bi-collection"></i> Group Similar Events
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
cell: info => <div>{stripAnsi(info.getValue<string>())}</div>,
|
||||
size: UNDEFINED_WIDTH,
|
||||
enableResizing: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
isUTC,
|
||||
highlightedLineId,
|
||||
onRowExpandClick,
|
||||
displayedColumns,
|
||||
onShowPatternsClick,
|
||||
isSmallScreen,
|
||||
columnSizeStorage,
|
||||
showServiceColumn,
|
||||
columnNameMap,
|
||||
],
|
||||
);
|
||||
|
||||
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
|
||||
const fetchMoreOnBottomReached = useCallback(
|
||||
(containerRefElement?: HTMLDivElement | null) => {
|
||||
if (containerRefElement) {
|
||||
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < FETCH_NEXT_PAGE_PX &&
|
||||
!isLoading &&
|
||||
hasNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchNextPage, isLoading, hasNextPage],
|
||||
);
|
||||
|
||||
//a check on mount and after a fetch to see if the table is already scrolled to the bottom and immediately needs to fetch more data
|
||||
useEffect(() => {
|
||||
fetchMoreOnBottomReached(tableContainerRef.current);
|
||||
}, [fetchMoreOnBottomReached]);
|
||||
|
||||
const reactTableProps = useMemo((): TableOptions<any> => {
|
||||
//TODO: fix any
|
||||
const onColumnSizingChange = (updaterOrValue: any) => {
|
||||
const state =
|
||||
updaterOrValue instanceof Function
|
||||
? updaterOrValue()
|
||||
: updaterOrValue;
|
||||
setColumnSizeStorage({ ...columnSizeStorage, ...state });
|
||||
};
|
||||
|
||||
const initReactTableProps = {
|
||||
data: dedupLogs,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// debugTable: true,
|
||||
enableColumnResizing: true,
|
||||
columnResizeMode: 'onChange' as ColumnResizeMode,
|
||||
};
|
||||
|
||||
const columnSizeProps = {
|
||||
state: {
|
||||
columnSizing: columnSizeStorage,
|
||||
},
|
||||
onColumnSizingChange: onColumnSizingChange,
|
||||
};
|
||||
|
||||
return tableId
|
||||
? { ...initReactTableProps, ...columnSizeProps }
|
||||
: initReactTableProps;
|
||||
}, [columns, dedupLogs, tableId, columnSizeStorage, setColumnSizeStorage]);
|
||||
|
||||
const table = useReactTable(reactTableProps);
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
// count: hasNextPage ? allRows.length + 1 : allRows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: useCallback(() => 23, []),
|
||||
overscan: 30,
|
||||
paddingEnd: 20,
|
||||
});
|
||||
|
||||
const items = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
|
||||
const [paddingTop, paddingBottom] = useMemo(
|
||||
() =>
|
||||
items.length > 0
|
||||
? [
|
||||
Math.max(0, items[0].start - rowVirtualizer.options.scrollMargin),
|
||||
Math.max(0, totalSize - items[items.length - 1].end),
|
||||
]
|
||||
: [0, 0],
|
||||
[items, rowVirtualizer.options.scrollMargin, totalSize],
|
||||
);
|
||||
|
||||
// Scroll to log id if it's not in window yet
|
||||
const [scrolledToHighlightedLine, setScrolledToHighlightedLine] =
|
||||
useState(false);
|
||||
const [scrolledToHighlightedLineCount, setScrolledToHighlightedLineCount] =
|
||||
useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
scrolledToHighlightedLine ||
|
||||
highlightedLineId == null ||
|
||||
rowVirtualizer == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowIdx = dedupLogs.findIndex(l => l.id === highlightedLineId);
|
||||
if (rowIdx == -1) {
|
||||
if (
|
||||
scrolledToHighlightedLineCount < MAX_SCROLL_FETCH_NEW_PAGE_ATTEMPTS
|
||||
) {
|
||||
fetchNextPage({
|
||||
cb: () => {
|
||||
setScrolledToHighlightedLineCount(prev => prev + 1);
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setScrolledToHighlightedLine(true);
|
||||
if (
|
||||
rowVirtualizer.getVirtualItems().find(l => l.index === rowIdx) == null
|
||||
) {
|
||||
rowVirtualizer.scrollToIndex(rowIdx, {
|
||||
align: 'center',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
dedupLogs,
|
||||
highlightedLineId,
|
||||
fetchNextPage,
|
||||
rowVirtualizer,
|
||||
scrolledToHighlightedLine,
|
||||
isLoading,
|
||||
scrolledToHighlightedLineCount,
|
||||
]);
|
||||
|
||||
const shiftHighlightedLineId = useCallback(
|
||||
(shift: number) => {
|
||||
if (highlightedLineId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex =
|
||||
dedupLogs.findIndex(l => l.id === highlightedLineId) + shift;
|
||||
|
||||
if (newIndex < 0 || newIndex >= dedupLogs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newLine = dedupLogs[newIndex];
|
||||
|
||||
onRowExpandClick(newLine.id, newLine.sort_key);
|
||||
},
|
||||
[highlightedLineId, onRowExpandClick, dedupLogs],
|
||||
);
|
||||
|
||||
useHotkeys(['ArrowRight', 'ArrowDown', 'j'], e => {
|
||||
e.preventDefault();
|
||||
shiftHighlightedLineId(1);
|
||||
});
|
||||
useHotkeys(['ArrowLeft', 'ArrowUp', 'k'], e => {
|
||||
e.preventDefault();
|
||||
shiftHighlightedLineId(-1);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-auto h-100 fs-8 bg-inherit"
|
||||
onScroll={e => {
|
||||
fetchMoreOnBottomReached(e.target as HTMLDivElement);
|
||||
|
||||
if (e.target != null) {
|
||||
const { scrollTop } = e.target as HTMLDivElement;
|
||||
onScroll(scrollTop);
|
||||
}
|
||||
}}
|
||||
ref={tableContainerRef}
|
||||
// Fixes flickering scroll bar: https://github.com/TanStack/virtual/issues/426#issuecomment-1403438040
|
||||
// style={{ overflowAnchor: 'none' }}
|
||||
>
|
||||
<table
|
||||
className="w-100 bg-inherit"
|
||||
id={tableId}
|
||||
style={{ tableLayout: 'fixed' }}
|
||||
>
|
||||
<thead className={styles.tableHead}>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, headerIndex) => {
|
||||
return (
|
||||
<th
|
||||
className="overflow-hidden text-truncate"
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
style={{
|
||||
width:
|
||||
header.getSize() === UNDEFINED_WIDTH
|
||||
? '100%'
|
||||
: header.getSize(),
|
||||
// Allow unknown width columns to shrink to 0
|
||||
minWidth:
|
||||
header.getSize() === UNDEFINED_WIDTH
|
||||
? 0
|
||||
: header.getSize(),
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`resizer text-gray-600 cursor-col-resize ${
|
||||
header.column.getIsResizing() ? 'isResizing' : ''
|
||||
}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 12,
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-three-dots-vertical" />
|
||||
</div>
|
||||
)}
|
||||
{headerIndex === headerGroup.headers.length - 1 && (
|
||||
<div
|
||||
className="d-flex align-items-center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
{tableId != null &&
|
||||
Object.keys(columnSizeStorage).length > 0 && (
|
||||
<div
|
||||
className="fs-8 text-muted-hover disabled"
|
||||
role="button"
|
||||
onClick={() => setColumnSizeStorage({})}
|
||||
title="Reset Column Widths"
|
||||
>
|
||||
<i className="bi bi-arrow-clockwise" />
|
||||
</div>
|
||||
)}
|
||||
{onSettingsClick != null && (
|
||||
<div
|
||||
className="fs-8 text-muted-hover ms-2"
|
||||
role="button"
|
||||
onClick={() => onSettingsClick()}
|
||||
>
|
||||
<i className="bi bi-gear-fill" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{paddingTop > 0 && (
|
||||
<tr>
|
||||
<td colSpan={99999} style={{ height: `${paddingTop}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
{items.map(virtualRow => {
|
||||
const row = rows[virtualRow.index] as TableRow<any>;
|
||||
return (
|
||||
<tr
|
||||
onClick={() => {
|
||||
onRowExpandClick(row.original.id, row.original.sort_key);
|
||||
}}
|
||||
role="button"
|
||||
key={virtualRow.key}
|
||||
className={cx(styles.tableRow, {
|
||||
[styles.tableRow__selected]:
|
||||
highlightedLineId === row.original.id,
|
||||
})}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cx('align-top overflow-hidden', {
|
||||
'text-break': wrapLines,
|
||||
'text-truncate': !wrapLines,
|
||||
})}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<td colSpan={800}>
|
||||
<div className="rounded fs-7 bg-grey text-center d-flex align-items-center justify-content-center mt-3">
|
||||
{isLoading ? (
|
||||
<div className="my-3">
|
||||
<div className="spin-animate d-inline-block">
|
||||
<i className="bi bi-arrow-repeat" />
|
||||
</div>{' '}
|
||||
Loading results...
|
||||
</div>
|
||||
) : hasNextPage == false &&
|
||||
isLoading == false &&
|
||||
dedupLogs.length > 0 ? (
|
||||
<div className="my-3">End of Results</div>
|
||||
) : hasNextPage == false &&
|
||||
isLoading == false &&
|
||||
dedupLogs.length === 0 ? (
|
||||
<div className="my-3">
|
||||
No results found.
|
||||
<div className="text-muted mt-3">
|
||||
Try checking the query explainer in the search bar if
|
||||
there are any search syntax issues.
|
||||
</div>
|
||||
{onInstructionsClick != null && (
|
||||
<>
|
||||
<div className="text-muted mt-3">
|
||||
Add new data sources by setting up a HyperDX
|
||||
integration.
|
||||
</div>
|
||||
<Button
|
||||
variant="outline-success"
|
||||
className="fs-7 mt-3"
|
||||
onClick={() => onInstructionsClick()}
|
||||
>
|
||||
Install New HyperDX Integration
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{paddingBottom > 0 && (
|
||||
<tr>
|
||||
<td colSpan={99999} style={{ height: `${paddingBottom}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default function LogTable({
|
||||
config: { where: searchedQuery, dateRange: searchedTimeRange },
|
||||
highlightedLineId,
|
||||
onPropertySearchClick,
|
||||
onRowExpandClick,
|
||||
isLive,
|
||||
onScroll,
|
||||
onEnd,
|
||||
onShowPatternsClick,
|
||||
tableId,
|
||||
displayedColumns,
|
||||
setDisplayedColumns,
|
||||
columnNameMap,
|
||||
showServiceColumn,
|
||||
}: {
|
||||
config: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
highlightedLineId: undefined | string;
|
||||
onPropertySearchClick: (
|
||||
property: string,
|
||||
value: string | number | boolean,
|
||||
) => void;
|
||||
onRowExpandClick: (logId: string, sortKey: string) => void;
|
||||
onScroll: (scrollTop: number) => void;
|
||||
isLive: boolean;
|
||||
onEnd?: () => void;
|
||||
onShowPatternsClick?: () => void;
|
||||
tableId?: string;
|
||||
displayedColumns: string[];
|
||||
setDisplayedColumns: (columns: string[]) => void;
|
||||
columnNameMap?: Record<string, string>;
|
||||
showServiceColumn?: boolean;
|
||||
}) {
|
||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [wrapLines, setWrapLines] = useState(false);
|
||||
|
||||
const prevQueryConfig = usePrevious({ searchedQuery, isLive });
|
||||
|
||||
const resultsKey = [searchedQuery, displayedColumns, isLive].join(':');
|
||||
|
||||
const {
|
||||
userPreferences: { isUTC },
|
||||
} = useUserPreferences();
|
||||
|
||||
const {
|
||||
results: searchResults,
|
||||
resultsKey: searchResultsKey,
|
||||
fetchNextPage,
|
||||
isFetching: isSearchResultsFetching,
|
||||
hasNextPage,
|
||||
} = useSearchEventStream(
|
||||
{
|
||||
apiUrlPath: '/logs/stream',
|
||||
q: searchedQuery,
|
||||
startDate: searchedTimeRange?.[0] ?? new Date(),
|
||||
endDate: searchedTimeRange?.[1] ?? new Date(),
|
||||
extraFields: displayedColumns,
|
||||
order: 'desc',
|
||||
onEnd,
|
||||
resultsKey,
|
||||
},
|
||||
{
|
||||
enabled: searchedTimeRange != null,
|
||||
keepPreviousData:
|
||||
isLive && prevQueryConfig?.searchedQuery === searchedQuery,
|
||||
// If we're in live mode, we shouldn't abort the previous request
|
||||
// as a slow live search will always result in an aborted request
|
||||
// unless the user has changed their query (without leaving live mode)
|
||||
// If we're not in live mode, we should abort as the user is requesting a new search
|
||||
// We need to look at prev state to make sure we abort if transitioning from live to not live
|
||||
shouldAbortPendingRequest:
|
||||
!(isLive && prevQueryConfig?.isLive) ||
|
||||
prevQueryConfig?.searchedQuery !== searchedQuery,
|
||||
},
|
||||
);
|
||||
|
||||
// Check if live tail is enabled, if so, we need to compare the search results
|
||||
// key to see if the data we're showing is stale relative to the query we're trying to show.
|
||||
// otherwise, we just need to check if the search results are fetching
|
||||
const isLoading =
|
||||
isLive && prevQueryConfig != null && prevQueryConfig.isLive
|
||||
? searchResultsKey !== resultsKey && isSearchResultsFetching
|
||||
: isSearchResultsFetching;
|
||||
|
||||
const hasNextPageWhenNotLive =
|
||||
prevQueryConfig?.searchedQuery === searchedQuery &&
|
||||
isLive &&
|
||||
prevQueryConfig.isLive
|
||||
? false
|
||||
: hasNextPage ?? true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InstallInstructionsModal
|
||||
show={instructionsOpen}
|
||||
onHide={() => setInstructionsOpen(false)}
|
||||
/>
|
||||
<LogTableSettingsModal
|
||||
key={`${isUTC} ${displayedColumns} ${wrapLines}`}
|
||||
show={settingsOpen}
|
||||
initialAdditionalColumns={displayedColumns}
|
||||
initialWrapLines={wrapLines}
|
||||
onHide={() => setSettingsOpen(false)}
|
||||
onDone={({ additionalColumns, wrapLines }) => {
|
||||
setDisplayedColumns(additionalColumns);
|
||||
setWrapLines(wrapLines);
|
||||
}}
|
||||
downloadCSVButton={
|
||||
<DownloadCSVButton
|
||||
config={{
|
||||
where: searchedQuery,
|
||||
dateRange: searchedTimeRange,
|
||||
}}
|
||||
extraFields={displayedColumns}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<RawLogTable
|
||||
tableId={tableId}
|
||||
isLive={isLive}
|
||||
wrapLines={wrapLines}
|
||||
displayedColumns={displayedColumns}
|
||||
onSettingsClick={useCallback(
|
||||
() => setSettingsOpen(true),
|
||||
[setSettingsOpen],
|
||||
)}
|
||||
onInstructionsClick={useCallback(
|
||||
() => setInstructionsOpen(true),
|
||||
[setInstructionsOpen],
|
||||
)}
|
||||
highlightedLineId={highlightedLineId}
|
||||
logs={searchResults ?? []}
|
||||
isLoading={isLoading}
|
||||
fetchNextPage={useCallback(
|
||||
(args: any) => fetchNextPage({ limit: 200, ...args }),
|
||||
[fetchNextPage],
|
||||
)}
|
||||
// onPropertySearchClick={onPropertySearchClick}
|
||||
hasNextPage={hasNextPageWhenNotLive}
|
||||
onRowExpandClick={onRowExpandClick}
|
||||
onScroll={onScroll}
|
||||
onShowPatternsClick={onShowPatternsClick}
|
||||
columnNameMap={columnNameMap}
|
||||
showServiceColumn={showServiceColumn}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import usePortal from 'react-useportal';
|
||||
|
||||
import LogSidePanel from './LogSidePanel';
|
||||
import LogTable from './LogTable';
|
||||
import type { LogView } from './types';
|
||||
import { useDisplayedColumns } from './useDisplayedColumns';
|
||||
|
||||
export function LogTableWithSidePanel({
|
||||
config,
|
||||
isLive,
|
||||
onScroll,
|
||||
selectedSavedSearch,
|
||||
onPropertySearchClick,
|
||||
onRowExpandClick,
|
||||
onPropertyAddClick,
|
||||
onSettled,
|
||||
columnNameMap,
|
||||
showServiceColumn,
|
||||
}: {
|
||||
config: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
columns?: string[];
|
||||
};
|
||||
isLive: boolean;
|
||||
columnNameMap?: Record<string, string>;
|
||||
showServiceColumn?: boolean;
|
||||
|
||||
onPropertySearchClick: (
|
||||
property: string,
|
||||
value: string | number | boolean,
|
||||
) => void;
|
||||
|
||||
onPropertyAddClick?: (name: string, value: string | boolean | number) => void;
|
||||
onRowExpandClick?: (logId: string, sortKey: string) => void;
|
||||
onScroll?: (scrollTop: number) => void | undefined;
|
||||
selectedSavedSearch?: LogView | undefined;
|
||||
|
||||
onSettled?: () => void;
|
||||
}) {
|
||||
const { where: searchedQuery, dateRange: searchedTimeRange } = config;
|
||||
|
||||
const [openedLog, setOpenedLog] = useState<
|
||||
{ id: string; sortKey: string } | undefined
|
||||
>();
|
||||
|
||||
// Needed as sometimes the side panel will be contained with some
|
||||
// weird positioning and it breaks the slideout
|
||||
const { Portal } = usePortal();
|
||||
|
||||
const generateSearchUrl = useCallback(
|
||||
(newQuery?: string, newTimeRange?: [Date, Date]) => {
|
||||
const qparams = new URLSearchParams({
|
||||
q: newQuery ?? searchedQuery,
|
||||
from: newTimeRange
|
||||
? newTimeRange[0].getTime().toString()
|
||||
: searchedTimeRange[0].getTime().toString(),
|
||||
to: newTimeRange
|
||||
? newTimeRange[1].getTime().toString()
|
||||
: searchedTimeRange[1].getTime().toString(),
|
||||
});
|
||||
return `/search${
|
||||
selectedSavedSearch != null ? `/${selectedSavedSearch._id}` : ''
|
||||
}?${qparams.toString()}`;
|
||||
},
|
||||
[searchedQuery, searchedTimeRange, selectedSavedSearch],
|
||||
);
|
||||
|
||||
const generateChartUrl = useCallback(
|
||||
({ aggFn, field, groupBy }: any) => {
|
||||
return `/chart?series=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
type: 'time',
|
||||
aggFn,
|
||||
field,
|
||||
where: searchedQuery,
|
||||
groupBy,
|
||||
}),
|
||||
)}`;
|
||||
},
|
||||
[searchedQuery],
|
||||
);
|
||||
|
||||
const voidFn = useCallback(() => {}, []);
|
||||
|
||||
const { displayedColumns, setDisplayedColumns, toggleColumn } =
|
||||
useDisplayedColumns(config.columns);
|
||||
|
||||
return (
|
||||
<>
|
||||
{openedLog != null ? (
|
||||
<Portal>
|
||||
<LogSidePanel
|
||||
logId={openedLog?.id}
|
||||
sortKey={openedLog?.sortKey}
|
||||
onClose={() => {
|
||||
setOpenedLog(undefined);
|
||||
}}
|
||||
onPropertyAddClick={onPropertyAddClick}
|
||||
generateSearchUrl={generateSearchUrl}
|
||||
generateChartUrl={generateChartUrl}
|
||||
displayedColumns={displayedColumns}
|
||||
toggleColumn={toggleColumn}
|
||||
q={searchedQuery}
|
||||
/>
|
||||
</Portal>
|
||||
) : null}
|
||||
<LogTable
|
||||
isLive={isLive}
|
||||
onScroll={onScroll ?? voidFn}
|
||||
highlightedLineId={openedLog?.id}
|
||||
config={config}
|
||||
onPropertySearchClick={onPropertySearchClick}
|
||||
onRowExpandClick={useCallback(
|
||||
(id: string, sortKey: string) => {
|
||||
setOpenedLog({ id, sortKey });
|
||||
onRowExpandClick?.(id, sortKey);
|
||||
},
|
||||
[setOpenedLog, onRowExpandClick],
|
||||
)}
|
||||
onEnd={onSettled}
|
||||
displayedColumns={displayedColumns}
|
||||
setDisplayedColumns={setDisplayedColumns}
|
||||
columnNameMap={columnNameMap}
|
||||
showServiceColumn={showServiceColumn}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Select } from '@mantine/core';
|
||||
|
||||
import api from './api';
|
||||
import { legacyMetricNameToNameAndDataType } from './utils';
|
||||
|
||||
export default function MetricTagValueSelect({
|
||||
metricName,
|
||||
metricAttribute,
|
||||
value,
|
||||
onChange,
|
||||
dropdownOpenWidth,
|
||||
dropdownClosedWidth,
|
||||
...selectProps
|
||||
}: {
|
||||
metricName: string;
|
||||
metricAttribute: string;
|
||||
value: string;
|
||||
dropdownOpenWidth?: number;
|
||||
dropdownClosedWidth?: number;
|
||||
onChange: (value: string) => void;
|
||||
} & Partial<React.ComponentProps<typeof Select>>) {
|
||||
const { name: mName, dataType: mDataType } =
|
||||
legacyMetricNameToNameAndDataType(metricName);
|
||||
const { data: metricTagsData, isLoading: isMetricTagsLoading } =
|
||||
api.useMetricsTags([
|
||||
{
|
||||
name: mName,
|
||||
dataType: mDataType,
|
||||
},
|
||||
]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const tags =
|
||||
metricTagsData?.data?.filter(metric => metric.name === metricName)?.[0]
|
||||
?.tags ?? [];
|
||||
const tagNameValueSet = new Set<string>();
|
||||
tags.forEach(tag => {
|
||||
Object.entries(tag).forEach(([name, value]) =>
|
||||
tagNameValueSet.add(`${name}:"${value}"`),
|
||||
);
|
||||
});
|
||||
return Array.from(tagNameValueSet).map(tagName => ({
|
||||
value: tagName,
|
||||
label: tagName,
|
||||
}));
|
||||
}, [metricTagsData, metricName]);
|
||||
|
||||
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Select
|
||||
searchable
|
||||
clearable
|
||||
allowDeselect
|
||||
maxDropdownHeight={280}
|
||||
disabled={isMetricTagsLoading}
|
||||
radius="md"
|
||||
variant="filled"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
w={dropdownOpen ? dropdownOpenWidth ?? 200 : dropdownClosedWidth ?? 200}
|
||||
limit={20}
|
||||
data={options}
|
||||
onDropdownOpen={() => setDropdownOpen(true)}
|
||||
onDropdownClose={() => setDropdownOpen(false)}
|
||||
{...selectProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,300 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Drawer from 'react-modern-drawer';
|
||||
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Card,
|
||||
Flex,
|
||||
Grid,
|
||||
SegmentedControl,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { DrawerBody, DrawerHeader } from './components/DrawerUtils';
|
||||
import api from './api';
|
||||
import {
|
||||
convertDateRangeToGranularityString,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
K8S_MEM_NUMBER_FORMAT,
|
||||
} from './ChartUtils';
|
||||
import HDXMultiSeriesTimeChart from './HDXMultiSeriesTimeChart';
|
||||
import { InfraPodsStatusTable } from './KubernetesDashboardPage';
|
||||
import { LogTableWithSidePanel } from './LogTableWithSidePanel';
|
||||
import { parseTimeQuery, useTimeQuery } from './timeQuery';
|
||||
import { formatUptime } from './utils';
|
||||
import { useZIndex, ZIndexContext } from './zIndex';
|
||||
|
||||
import styles from '../styles/LogSidePanel.module.scss';
|
||||
|
||||
const CHART_HEIGHT = 300;
|
||||
const defaultTimeRange = parseTimeQuery('Past 1h', false);
|
||||
|
||||
const PodDetailsProperty = React.memo(
|
||||
({ label, value }: { label: string; value?: React.ReactNode }) => {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="pe-4">
|
||||
<Text size="xs" color="gray.6">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" color="gray.3">
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const NamespaceDetails = ({
|
||||
name,
|
||||
dateRange,
|
||||
}: {
|
||||
name: string;
|
||||
dateRange: [Date, Date];
|
||||
}) => {
|
||||
const where = `k8s.namespace.name:"${name}"`;
|
||||
const groupBy = ['k8s.namespace.name'];
|
||||
|
||||
const { data } = api.useMultiSeriesChart({
|
||||
series: [
|
||||
{
|
||||
table: 'metrics',
|
||||
field: 'k8s.namespace.phase - Gauge',
|
||||
type: 'table',
|
||||
aggFn: 'last_value',
|
||||
where,
|
||||
groupBy,
|
||||
},
|
||||
],
|
||||
endDate: dateRange[1] ?? new Date(),
|
||||
startDate: dateRange[0] ?? new Date(),
|
||||
seriesReturnType: 'column',
|
||||
});
|
||||
|
||||
const properties = React.useMemo(() => {
|
||||
const series: Record<string, any> = data?.data?.[0] || {};
|
||||
return {
|
||||
ready: series['series_0.data'],
|
||||
};
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<Grid.Col span={12}>
|
||||
<div className="p-2 gap-2 d-flex flex-wrap">
|
||||
<PodDetailsProperty label="Namespace" value={name} />
|
||||
{properties.ready !== undefined && (
|
||||
<PodDetailsProperty
|
||||
label="Status"
|
||||
value={
|
||||
properties.ready === 1 ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="green"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Ready
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Not Ready
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Grid.Col>
|
||||
);
|
||||
};
|
||||
|
||||
function NamespaceLogs({
|
||||
where,
|
||||
dateRange,
|
||||
}: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
}) {
|
||||
const [resultType, setResultType] = React.useState<'all' | 'error'>('all');
|
||||
|
||||
const _where = where + (resultType === 'error' ? ' level:err' : '');
|
||||
|
||||
return (
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
<Flex justify="space-between" align="center">
|
||||
Latest Namespace Logs & Spans
|
||||
<Flex gap="xs" align="center">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={resultType}
|
||||
onChange={(value: string) => {
|
||||
if (value === 'all' || value === 'error') {
|
||||
setResultType(value);
|
||||
}
|
||||
}}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Errors', value: 'error' },
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href={`/search?q=${encodeURIComponent(_where)}`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
<Anchor size="xs" color="dimmed">
|
||||
Search <i className="bi bi-box-arrow-up-right"></i>
|
||||
</Anchor>
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<LogTableWithSidePanel
|
||||
config={{
|
||||
dateRange,
|
||||
where: _where,
|
||||
}}
|
||||
isLive={false}
|
||||
onPropertySearchClick={() => {}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NamespaceDetailsSidePanel() {
|
||||
const [namespaceName, setNamespaceName] = useQueryParam(
|
||||
'namespaceName',
|
||||
withDefault(StringParam, ''),
|
||||
{
|
||||
updateType: 'replaceIn',
|
||||
},
|
||||
);
|
||||
|
||||
const contextZIndex = useZIndex();
|
||||
const drawerZIndex = contextZIndex + 10;
|
||||
|
||||
const where = React.useMemo(() => {
|
||||
return `k8s.namespace.name:"${namespaceName}"`;
|
||||
}, [namespaceName]);
|
||||
|
||||
const { searchedTimeRange: dateRange } = useTimeQuery({
|
||||
defaultValue: 'Past 1h',
|
||||
defaultTimeRange: [
|
||||
defaultTimeRange?.[0]?.getTime() ?? -1,
|
||||
defaultTimeRange?.[1]?.getTime() ?? -1,
|
||||
],
|
||||
});
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setNamespaceName(undefined);
|
||||
}, [setNamespaceName]);
|
||||
|
||||
if (!namespaceName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
enableOverlay
|
||||
overlayOpacity={0.1}
|
||||
duration={0}
|
||||
open={!!namespaceName}
|
||||
onClose={handleClose}
|
||||
direction="right"
|
||||
size={'80vw'}
|
||||
zIndex={drawerZIndex}
|
||||
>
|
||||
<ZIndexContext.Provider value={drawerZIndex}>
|
||||
<div className={styles.panel}>
|
||||
<DrawerHeader
|
||||
header={`Details for ${namespaceName}`}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<DrawerBody>
|
||||
<Grid>
|
||||
<NamespaceDetails name={namespaceName} dateRange={dateRange} />
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
CPU Usage by Pod
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
seriesReturnType: 'column',
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
groupBy: ['k8s.pod.name'],
|
||||
where,
|
||||
table: 'metrics',
|
||||
aggFn: 'avg',
|
||||
field: 'k8s.pod.cpu.utilization - Gauge',
|
||||
numberFormat: K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Memory Usage by Pod
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
seriesReturnType: 'column',
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
groupBy: ['k8s.pod.name'],
|
||||
where,
|
||||
table: 'metrics',
|
||||
aggFn: 'avg',
|
||||
field: 'k8s.pod.memory.usage - Gauge',
|
||||
numberFormat: K8S_MEM_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<InfraPodsStatusTable dateRange={dateRange} where={where} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<NamespaceLogs where={where} dateRange={dateRange} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</DrawerBody>
|
||||
</div>
|
||||
</ZIndexContext.Provider>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Drawer from 'react-modern-drawer';
|
||||
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Card,
|
||||
Flex,
|
||||
Grid,
|
||||
SegmentedControl,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { DrawerBody, DrawerHeader } from './components/DrawerUtils';
|
||||
import api from './api';
|
||||
import {
|
||||
convertDateRangeToGranularityString,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
K8S_MEM_NUMBER_FORMAT,
|
||||
} from './ChartUtils';
|
||||
import HDXMultiSeriesTimeChart from './HDXMultiSeriesTimeChart';
|
||||
import { InfraPodsStatusTable } from './KubernetesDashboardPage';
|
||||
import { LogTableWithSidePanel } from './LogTableWithSidePanel';
|
||||
import { parseTimeQuery, useTimeQuery } from './timeQuery';
|
||||
import { formatUptime } from './utils';
|
||||
import { useZIndex, ZIndexContext } from './zIndex';
|
||||
|
||||
import styles from '../styles/LogSidePanel.module.scss';
|
||||
|
||||
const CHART_HEIGHT = 300;
|
||||
const defaultTimeRange = parseTimeQuery('Past 1h', false);
|
||||
|
||||
const PodDetailsProperty = React.memo(
|
||||
({ label, value }: { label: string; value?: React.ReactNode }) => {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="pe-4">
|
||||
<Text size="xs" color="gray.6">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" color="gray.3">
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const NodeDetails = ({
|
||||
name,
|
||||
dateRange,
|
||||
}: {
|
||||
name: string;
|
||||
dateRange: [Date, Date];
|
||||
}) => {
|
||||
const where = `k8s.node.name:"${name}"`;
|
||||
const groupBy = ['k8s.node.name'];
|
||||
|
||||
const { data } = api.useMultiSeriesChart({
|
||||
series: [
|
||||
{
|
||||
table: 'metrics',
|
||||
field: 'k8s.node.condition_ready - Gauge',
|
||||
type: 'table',
|
||||
aggFn: 'last_value',
|
||||
where,
|
||||
groupBy,
|
||||
},
|
||||
{
|
||||
table: 'metrics',
|
||||
field: 'k8s.node.uptime - Sum',
|
||||
type: 'table',
|
||||
aggFn: 'last_value',
|
||||
where,
|
||||
groupBy,
|
||||
},
|
||||
],
|
||||
endDate: dateRange[1] ?? new Date(),
|
||||
startDate: dateRange[0] ?? new Date(),
|
||||
seriesReturnType: 'column',
|
||||
});
|
||||
|
||||
const properties = React.useMemo(() => {
|
||||
const series: Record<string, any> = data?.data?.[0] || {};
|
||||
return {
|
||||
ready: series['series_0.data'],
|
||||
uptime: series['series_1.data'],
|
||||
};
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<Grid.Col span={12}>
|
||||
<div className="p-2 gap-2 d-flex flex-wrap">
|
||||
<PodDetailsProperty label="Node" value={name} />
|
||||
{properties.ready !== undefined && (
|
||||
<PodDetailsProperty
|
||||
label="Status"
|
||||
value={
|
||||
properties.ready === 1 ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="green"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Ready
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Not Ready
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{properties.uptime && (
|
||||
<PodDetailsProperty
|
||||
label="Uptime"
|
||||
value={formatUptime(properties.uptime)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Grid.Col>
|
||||
);
|
||||
};
|
||||
|
||||
function NodeLogs({
|
||||
where,
|
||||
dateRange,
|
||||
}: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
}) {
|
||||
const [resultType, setResultType] = React.useState<'all' | 'error'>('all');
|
||||
|
||||
const _where = where + (resultType === 'error' ? ' level:err' : '');
|
||||
|
||||
return (
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
<Flex justify="space-between" align="center">
|
||||
Latest Node Logs & Spans
|
||||
<Flex gap="xs" align="center">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={resultType}
|
||||
onChange={(value: string) => {
|
||||
if (value === 'all' || value === 'error') {
|
||||
setResultType(value);
|
||||
}
|
||||
}}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Errors', value: 'error' },
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href={`/search?q=${encodeURIComponent(_where)}`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
<Anchor size="xs" color="dimmed">
|
||||
Search <i className="bi bi-box-arrow-up-right"></i>
|
||||
</Anchor>
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<LogTableWithSidePanel
|
||||
config={{
|
||||
dateRange,
|
||||
where: _where,
|
||||
}}
|
||||
isLive={false}
|
||||
onPropertySearchClick={() => {}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NodeDetailsSidePanel() {
|
||||
const [nodeName, setNodeName] = useQueryParam(
|
||||
'nodeName',
|
||||
withDefault(StringParam, ''),
|
||||
{
|
||||
updateType: 'replaceIn',
|
||||
},
|
||||
);
|
||||
|
||||
const contextZIndex = useZIndex();
|
||||
const drawerZIndex = contextZIndex + 10;
|
||||
|
||||
const where = React.useMemo(() => {
|
||||
return `k8s.node.name:"${nodeName}"`;
|
||||
}, [nodeName]);
|
||||
|
||||
const { searchedTimeRange: dateRange } = useTimeQuery({
|
||||
defaultValue: 'Past 1h',
|
||||
defaultTimeRange: [
|
||||
defaultTimeRange?.[0]?.getTime() ?? -1,
|
||||
defaultTimeRange?.[1]?.getTime() ?? -1,
|
||||
],
|
||||
});
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setNodeName(undefined);
|
||||
}, [setNodeName]);
|
||||
|
||||
if (!nodeName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
enableOverlay
|
||||
overlayOpacity={0.1}
|
||||
duration={0}
|
||||
open={!!nodeName}
|
||||
onClose={handleClose}
|
||||
direction="right"
|
||||
size={'80vw'}
|
||||
zIndex={drawerZIndex}
|
||||
>
|
||||
<ZIndexContext.Provider value={drawerZIndex}>
|
||||
<div className={styles.panel}>
|
||||
<DrawerHeader
|
||||
header={`Details for ${nodeName}`}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<DrawerBody>
|
||||
<Grid>
|
||||
<NodeDetails name={nodeName} dateRange={dateRange} />
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
CPU Usage by Pod
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
seriesReturnType: 'column',
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
groupBy: ['k8s.pod.name'],
|
||||
where,
|
||||
table: 'metrics',
|
||||
aggFn: 'avg',
|
||||
field: 'k8s.pod.cpu.utilization - Gauge',
|
||||
numberFormat: K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Memory Usage by Pod
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
seriesReturnType: 'column',
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
groupBy: ['k8s.pod.name'],
|
||||
where,
|
||||
table: 'metrics',
|
||||
aggFn: 'avg',
|
||||
field: 'k8s.pod.memory.usage - Gauge',
|
||||
numberFormat: K8S_MEM_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<InfraPodsStatusTable dateRange={dateRange} where={where} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<NodeLogs where={where} dateRange={dateRange} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</DrawerBody>
|
||||
</div>
|
||||
</ZIndexContext.Provider>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { NextSeo } from 'next-seo';
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
|
||||
import { SERVER_URL } from './config';
|
||||
import LandingHeader from './LandingHeader';
|
||||
|
||||
export default function PasswordResetPage({
|
||||
action,
|
||||
}: {
|
||||
action: 'forgot' | 'reset-password';
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { msg, token } = router.query;
|
||||
|
||||
const title = action === 'forgot' ? 'Forgot password' : 'Reset password';
|
||||
|
||||
const renderForgotPasswordForm = () => (
|
||||
<div>
|
||||
<Form
|
||||
className="text-start"
|
||||
action={`${SERVER_URL}/password-reset`}
|
||||
method="POST"
|
||||
>
|
||||
<Form.Label htmlFor="email" className="text-start text-muted fs-7 mb-1">
|
||||
Email
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@company.com"
|
||||
className="border-0 mb-3"
|
||||
/>
|
||||
{msg === 'error' && (
|
||||
<div className="text-danger mt-2" data-test-id="auth-error-msg">
|
||||
Email is invalid
|
||||
</div>
|
||||
)}
|
||||
{msg === 'success' && (
|
||||
<div className="text-success mt-2" data-test-id="auth-error-msg">
|
||||
Check your email for a link to reset your password
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center mt-4">
|
||||
<Button
|
||||
variant="light"
|
||||
className="px-6"
|
||||
type="submit"
|
||||
data-test-id="submit"
|
||||
disabled={msg === 'success'}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
<div className="mt-4 text-muted">
|
||||
Back to <Link href="/login">Log in</Link>{' '}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderResetPasswordForm = () => (
|
||||
<div>
|
||||
<Form
|
||||
className="text-start"
|
||||
action={`${SERVER_URL}/password-reset/${token}`}
|
||||
method="POST"
|
||||
>
|
||||
<Form.Label
|
||||
htmlFor="password"
|
||||
className="text-start text-muted fs-7 mb-1"
|
||||
>
|
||||
Password
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
className="border-0"
|
||||
/>
|
||||
{msg === 'error' && (
|
||||
<div className="text-danger mt-2" data-test-id="auth-error-msg">
|
||||
Token expired
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center mt-4">
|
||||
<Button
|
||||
variant="light"
|
||||
className="px-6"
|
||||
type="submit"
|
||||
data-test-id="submit"
|
||||
disabled={msg === 'success'}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="AuthPage">
|
||||
<NextSeo title={title} />
|
||||
{/* <div className="w-100">
|
||||
<LandingHeader
|
||||
activeKey={action === 'forgot' ? '/forgot' : '/reset-password'}
|
||||
/>
|
||||
</div> */}
|
||||
<div className="d-flex align-items-center justify-content-center vh-100 p-2">
|
||||
<div>
|
||||
<div className="text-center mb-4 d-flex">
|
||||
<h2 className="me-2">
|
||||
{action === 'forgot' ? 'Forgot Password' : 'Reset Password'}
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="bg-purple rounded py-4 px-3 my-3 mt-2"
|
||||
style={{ maxWidth: 400, width: '100%' }}
|
||||
>
|
||||
<div className="text-center">
|
||||
{action === 'forgot' && renderForgotPasswordForm()}
|
||||
{action === 'reset-password' && renderResetPasswordForm()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import Drawer from 'react-modern-drawer';
|
||||
import usePortal from 'react-useportal';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
import LogSidePanel from './LogSidePanel';
|
||||
import { RawLogTable } from './LogTable';
|
||||
import { LogView } from './types';
|
||||
import { ZIndexContext } from './zIndex';
|
||||
|
||||
import 'react-modern-drawer/dist/index.css';
|
||||
|
||||
export type Pattern = {
|
||||
pattern: string;
|
||||
count: number;
|
||||
level: string;
|
||||
id: string;
|
||||
samples: { body: string; id: string; timestamp: string; sort_key: string }[];
|
||||
service: string;
|
||||
trends: Record<string, number>;
|
||||
};
|
||||
|
||||
export default function PatternSidePanel({
|
||||
onClose,
|
||||
pattern,
|
||||
zIndex = 100,
|
||||
config,
|
||||
selectedSavedSearch,
|
||||
onPropertyAddClick,
|
||||
}: {
|
||||
config: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
selectedSavedSearch?: LogView | undefined;
|
||||
onPropertyAddClick?: (name: string, value: string | boolean | number) => void;
|
||||
pattern: Pattern;
|
||||
onClose: () => void;
|
||||
zIndex?: number;
|
||||
}) {
|
||||
const { Portal } = usePortal();
|
||||
|
||||
const { where: searchedQuery, dateRange: searchedTimeRange } = config;
|
||||
|
||||
const [openedLog, setOpenedLog] = useState<
|
||||
{ id: string; sortKey: string } | undefined
|
||||
>();
|
||||
|
||||
const generateSearchUrl = useCallback(
|
||||
(newQuery?: string, newTimeRange?: [Date, Date]) => {
|
||||
const qparams = new URLSearchParams({
|
||||
q: newQuery ?? searchedQuery,
|
||||
from: newTimeRange
|
||||
? newTimeRange[0].getTime().toString()
|
||||
: searchedTimeRange[0].getTime().toString(),
|
||||
to: newTimeRange
|
||||
? newTimeRange[1].getTime().toString()
|
||||
: searchedTimeRange[1].getTime().toString(),
|
||||
});
|
||||
return `/search${
|
||||
selectedSavedSearch != null ? `/${selectedSavedSearch._id}` : ''
|
||||
}?${qparams.toString()}`;
|
||||
},
|
||||
[searchedQuery, searchedTimeRange, selectedSavedSearch],
|
||||
);
|
||||
|
||||
const generateChartUrl = useCallback(
|
||||
({ aggFn, field, groupBy }: any) => {
|
||||
return `/chart?series=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
type: 'time',
|
||||
aggFn,
|
||||
field,
|
||||
where: searchedQuery,
|
||||
groupBy,
|
||||
}),
|
||||
)}`;
|
||||
},
|
||||
[searchedQuery],
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
['esc'],
|
||||
() => {
|
||||
onClose();
|
||||
},
|
||||
{
|
||||
enabled: openedLog == null,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
customIdSuffix={`session-side-panel-${pattern.id}`}
|
||||
duration={0}
|
||||
overlayOpacity={0.2}
|
||||
open={pattern.id != null}
|
||||
onClose={() => {
|
||||
if (!openedLog != null) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
size={'85vw'}
|
||||
style={{ background: '#1a1d23' }}
|
||||
className="border-start border-dark"
|
||||
zIndex={zIndex}
|
||||
>
|
||||
<ZIndexContext.Provider value={zIndex}>
|
||||
<div className="p-3">
|
||||
<div className="mt-3">
|
||||
<div className="fw-bold mb-2 fs-8">Pattern</div>
|
||||
<div
|
||||
className="bg-grey p-3 overflow-auto fs-7"
|
||||
style={{ maxHeight: 300 }}
|
||||
>
|
||||
{stripAnsi(pattern.pattern)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{openedLog != null ? (
|
||||
<Portal>
|
||||
<LogSidePanel
|
||||
logId={openedLog?.id}
|
||||
sortKey={openedLog?.sortKey}
|
||||
onClose={() => {
|
||||
setOpenedLog(undefined);
|
||||
}}
|
||||
onPropertyAddClick={onPropertyAddClick}
|
||||
generateSearchUrl={generateSearchUrl}
|
||||
generateChartUrl={generateChartUrl}
|
||||
/>
|
||||
</Portal>
|
||||
) : null}
|
||||
<div className="p-3 h-100 d-flex flex-column fs-8">
|
||||
<div className="mb-2">
|
||||
Showing a sample of {pattern.samples.length} matched logs out of ~
|
||||
{pattern.count} total
|
||||
</div>
|
||||
<RawLogTable
|
||||
logs={useMemo(
|
||||
() =>
|
||||
pattern.samples.map(sample => ({
|
||||
...sample,
|
||||
severity_text: pattern.level,
|
||||
_service: pattern.service,
|
||||
})),
|
||||
[pattern.samples, pattern.level, pattern.service],
|
||||
)}
|
||||
displayedColumns={[]}
|
||||
onRowExpandClick={useCallback(
|
||||
(id: any, sortKey: any) => {
|
||||
setOpenedLog({ id, sortKey });
|
||||
},
|
||||
[setOpenedLog],
|
||||
)}
|
||||
highlightedLineId={openedLog?.id}
|
||||
isLive={false}
|
||||
isLoading={false}
|
||||
hasNextPage={false}
|
||||
wrapLines={false}
|
||||
fetchNextPage={useCallback(() => {}, [])}
|
||||
onScroll={useCallback(() => {}, [])}
|
||||
/>
|
||||
</div>
|
||||
</ZIndexContext.Provider>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,498 +0,0 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
Row as TableRow,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import api from './api';
|
||||
import { Granularity, timeBucketByGranularity } from './ChartUtils';
|
||||
import LogLevel from './LogLevel';
|
||||
import { Pattern } from './PatternSidePanel';
|
||||
import { UNDEFINED_WIDTH } from './tableUtils';
|
||||
import { useWindowSize } from './utils';
|
||||
|
||||
const PatternTrendChartTooltip = (props: any) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const PatternTrendChart = ({
|
||||
data,
|
||||
dateRange,
|
||||
granularity,
|
||||
}: {
|
||||
data: { bucket: string; count: number }[];
|
||||
dateRange: [Date, Date];
|
||||
granularity: Granularity;
|
||||
}) => {
|
||||
const chartData = useMemo(() => {
|
||||
const computedBuckets = timeBucketByGranularity(
|
||||
dateRange[0],
|
||||
dateRange[1],
|
||||
granularity,
|
||||
);
|
||||
|
||||
return computedBuckets.map(bucket => {
|
||||
const match = data.find(
|
||||
d => new Date(d.bucket).getTime() === bucket.getTime(),
|
||||
);
|
||||
return {
|
||||
ts_bucket: bucket.getTime() / 1000,
|
||||
count: match?.count ?? 0,
|
||||
};
|
||||
});
|
||||
}, [data, granularity, dateRange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
// Hack, recharts will release real fix soon https://github.com/recharts/recharts/issues/172
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0}>
|
||||
<BarChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={chartData}
|
||||
syncId="hdx"
|
||||
syncMethod="value"
|
||||
margin={{ top: 4, left: 0, right: 4, bottom: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey={'ts_bucket'}
|
||||
domain={[
|
||||
dateRange[0].getTime() / 1000,
|
||||
dateRange[1].getTime() / 1000,
|
||||
]}
|
||||
interval="preserveStartEnd"
|
||||
scale="time"
|
||||
type="number"
|
||||
// tickFormatter={tick =>
|
||||
// format(new Date(tick * 1000), 'MMM d HH:mm')
|
||||
// }
|
||||
tickFormatter={tick => ''}
|
||||
minTickGap={50}
|
||||
tick={{ fontSize: 12, fontFamily: 'IBM Plex Mono, monospace' }}
|
||||
/>
|
||||
<YAxis
|
||||
width={40}
|
||||
minTickGap={25}
|
||||
tickFormatter={(value: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(value)
|
||||
}
|
||||
tick={{ fontSize: 12, fontFamily: 'IBM Plex Mono, monospace' }}
|
||||
/>
|
||||
<Bar dataKey="count" stackId="a" fill="#20c997" maxBarSize={24} />
|
||||
{/* <Line
|
||||
key={'count'}
|
||||
type="monotone"
|
||||
dataKey={'count'}
|
||||
stroke={'#20c997'}
|
||||
dot={false}
|
||||
/> */}
|
||||
<Tooltip content={<PatternTrendChartTooltip />} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoPatternTable = memo(
|
||||
({
|
||||
dateRange,
|
||||
patterns,
|
||||
highlightedPatternId,
|
||||
isLoading,
|
||||
onRowExpandClick,
|
||||
onShowEventsClick,
|
||||
wrapLines,
|
||||
}: {
|
||||
dateRange: [Date, Date];
|
||||
patterns: any;
|
||||
wrapLines: boolean;
|
||||
isLoading: boolean;
|
||||
onRowExpandClick: (pattern: Pattern) => void;
|
||||
highlightedPatternId: string | undefined;
|
||||
onShowEventsClick?: () => void;
|
||||
}) => {
|
||||
const { width } = useWindowSize();
|
||||
const isSmallScreen = (width ?? 1000) < 900;
|
||||
|
||||
//we need a reference to the scrolling element for logic down below
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const columns = useMemo<ColumnDef<any>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: () => '',
|
||||
cell: info => {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
className={cx('cursor-pointer', {
|
||||
'text-success': highlightedPatternId === info.getValue(),
|
||||
'text-muted-hover': highlightedPatternId !== info.getValue(),
|
||||
})}
|
||||
onMouseDown={e => {
|
||||
// For some reason this interfers with the onclick handler
|
||||
// inside a dashboard tile
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => {
|
||||
onRowExpandClick(info.row.original);
|
||||
}}
|
||||
>
|
||||
{'> '}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 8,
|
||||
enableResizing: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'count',
|
||||
header: 'Count',
|
||||
cell: info => (
|
||||
<span>
|
||||
~{' '}
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(Number.parseInt(info.getValue<string>()))}
|
||||
</span>
|
||||
),
|
||||
size: 70,
|
||||
},
|
||||
{
|
||||
accessorKey: 'trends.data',
|
||||
header: 'Trend',
|
||||
cell: info => {
|
||||
return (
|
||||
<div style={{ height: 50, width: '100%' }}>
|
||||
<PatternTrendChart
|
||||
data={info.getValue() as any[]}
|
||||
dateRange={dateRange}
|
||||
granularity={info.row.original.trends.granularity}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: isSmallScreen ? 70 : 120,
|
||||
},
|
||||
{
|
||||
accessorKey: 'level',
|
||||
header: 'Level',
|
||||
cell: info => (
|
||||
<span>
|
||||
<LogLevel level={info.getValue<string>()} />
|
||||
</span>
|
||||
),
|
||||
size: isSmallScreen ? 50 : 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'service',
|
||||
header: 'Service',
|
||||
cell: info => <span>{info.getValue<string>()}</span>,
|
||||
size: isSmallScreen ? 70 : 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'pattern',
|
||||
header: () => (
|
||||
<span>
|
||||
Pattern{' '}
|
||||
{onShowEventsClick && (
|
||||
<span>
|
||||
•{' '}
|
||||
<span
|
||||
role="button"
|
||||
className="text-muted-hover fw-normal text-decoration-underline"
|
||||
onClick={onShowEventsClick}
|
||||
>
|
||||
Show Events List
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
cell: info => <div>{stripAnsi(info.getValue<string>())}</div>,
|
||||
size: UNDEFINED_WIDTH,
|
||||
enableResizing: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
highlightedPatternId,
|
||||
onRowExpandClick,
|
||||
isSmallScreen,
|
||||
onShowEventsClick,
|
||||
dateRange,
|
||||
],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: patterns,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// debugTable: true,
|
||||
enableColumnResizing: true,
|
||||
columnResizeMode: 'onChange',
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: useCallback(() => 58, []),
|
||||
overscan: 10,
|
||||
paddingEnd: 20,
|
||||
});
|
||||
|
||||
const items = rowVirtualizer.getVirtualItems();
|
||||
|
||||
const [paddingTop, paddingBottom] =
|
||||
items.length > 0
|
||||
? [
|
||||
Math.max(0, items[0].start - rowVirtualizer.options.scrollMargin),
|
||||
Math.max(
|
||||
0,
|
||||
rowVirtualizer.getTotalSize() - items[items.length - 1].end,
|
||||
),
|
||||
]
|
||||
: [0, 0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-auto h-100 fs-8 bg-inherit"
|
||||
ref={tableContainerRef}
|
||||
// Fixes flickering scroll bar: https://github.com/TanStack/virtual/issues/426#issuecomment-1403438040
|
||||
// style={{ overflowAnchor: 'none' }}
|
||||
>
|
||||
<table className="w-100 bg-inherit" style={{ tableLayout: 'fixed' }}>
|
||||
<thead
|
||||
className="bg-inherit"
|
||||
style={{
|
||||
background: 'inherit',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, headerIndex) => {
|
||||
return (
|
||||
<th
|
||||
className="overflow-hidden text-truncate"
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
style={{
|
||||
width:
|
||||
header.getSize() === UNDEFINED_WIDTH
|
||||
? '100%'
|
||||
: header.getSize(),
|
||||
// Allow unknown width columns to shrink to 0
|
||||
minWidth:
|
||||
header.getSize() === UNDEFINED_WIDTH
|
||||
? 0
|
||||
: header.getSize(),
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`resizer text-gray-600 cursor-grab ${
|
||||
header.column.getIsResizing() ? 'isResizing' : ''
|
||||
}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 12,
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-three-dots-vertical" />
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{paddingTop > 0 && (
|
||||
<tr>
|
||||
<td colSpan={99999} style={{ height: `${paddingTop}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
{items.map(virtualRow => {
|
||||
const row = rows[virtualRow.index] as TableRow<any>;
|
||||
return (
|
||||
<tr
|
||||
role="button"
|
||||
onClick={() => {
|
||||
onRowExpandClick(row.original);
|
||||
}}
|
||||
key={virtualRow.key}
|
||||
className={cx('bg-default-dark-grey-hover', {
|
||||
'bg-light-grey': highlightedPatternId === row.original.id,
|
||||
})}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cx('align-top overflow-hidden py-1', {
|
||||
'text-break': wrapLines,
|
||||
'text-truncate': !wrapLines,
|
||||
})}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<td colSpan={800}>
|
||||
{(isLoading || patterns.length === 0) && (
|
||||
<div className="rounded fs-7 bg-grey text-center d-flex align-items-center justify-content-center mt-3">
|
||||
{isLoading ? (
|
||||
<div className="my-3">
|
||||
<div className="spin-animate d-inline-block">
|
||||
<i className="bi bi-arrow-repeat" />
|
||||
</div>{' '}
|
||||
Calculating patterns...
|
||||
</div>
|
||||
) : patterns.length === 0 ? (
|
||||
<div className="my-3">No patterns found.</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{paddingBottom > 0 && (
|
||||
<tr>
|
||||
<td colSpan={99999} style={{ height: `${paddingBottom}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default function PatternTable({
|
||||
config: { where, dateRange },
|
||||
onRowExpandClick,
|
||||
onShowEventsClick,
|
||||
highlightedPatternId,
|
||||
}: {
|
||||
config: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
highlightedPatternId: undefined | string;
|
||||
onRowExpandClick: (pattern: Pattern) => void;
|
||||
onShowEventsClick?: () => void;
|
||||
}) {
|
||||
const { data: histogramResults, isLoading: isHistogramResultsLoading } =
|
||||
api.useLogHistogram(
|
||||
where,
|
||||
dateRange?.[0] ?? new Date(),
|
||||
dateRange?.[1] ?? new Date(),
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: patterns, isFetching: isPatternsFetching } = api.useLogPatterns(
|
||||
{
|
||||
q: where,
|
||||
startDate: dateRange?.[0] ?? new Date(),
|
||||
endDate: dateRange?.[1] ?? new Date(),
|
||||
sampleRate: Math.min(
|
||||
10000 /
|
||||
(histogramResults?.data?.reduce(
|
||||
(p: number, v: any) => p + Number.parseInt(v.count),
|
||||
0,
|
||||
) +
|
||||
1),
|
||||
1,
|
||||
),
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
histogramResults != null &&
|
||||
dateRange?.[0] != null &&
|
||||
dateRange?.[1] != null,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading = isPatternsFetching;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MemoPatternTable
|
||||
wrapLines={true}
|
||||
dateRange={dateRange}
|
||||
highlightedPatternId={highlightedPatternId}
|
||||
patterns={patterns?.data ?? []}
|
||||
isLoading={isLoading}
|
||||
onRowExpandClick={onRowExpandClick}
|
||||
onShowEventsClick={onShowEventsClick}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { memo, useCallback, useState } from 'react';
|
||||
import usePortal from 'react-useportal';
|
||||
|
||||
import type { Pattern } from './PatternSidePanel';
|
||||
import PatternSidePanel from './PatternSidePanel';
|
||||
import PatternTable from './PatternTable';
|
||||
|
||||
function PatternTableWithSidePanel({
|
||||
config,
|
||||
onShowEventsClick,
|
||||
}: {
|
||||
config: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
onShowEventsClick?: () => void;
|
||||
}) {
|
||||
const [openedPattern, setOpenedPattern] = useState<Pattern | undefined>();
|
||||
|
||||
// Needed as sometimes the side panel will be contained with some
|
||||
// weird positioning and it breaks the slideout
|
||||
const { Portal } = usePortal();
|
||||
|
||||
return (
|
||||
<>
|
||||
{openedPattern != null ? (
|
||||
<Portal>
|
||||
<PatternSidePanel
|
||||
pattern={openedPattern}
|
||||
onClose={() => {
|
||||
setOpenedPattern(undefined);
|
||||
}}
|
||||
config={config}
|
||||
/>
|
||||
</Portal>
|
||||
) : null}
|
||||
<PatternTable
|
||||
config={config}
|
||||
highlightedPatternId={openedPattern?.id}
|
||||
onShowEventsClick={onShowEventsClick}
|
||||
onRowExpandClick={useCallback(
|
||||
(pattern: Pattern) => {
|
||||
setOpenedPattern(pattern);
|
||||
},
|
||||
[setOpenedPattern],
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const MemoPatternTableWithSidePanel = memo(PatternTableWithSidePanel);
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { useMemo } from 'react';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
|
||||
import type { PlaybarMarker } from './PlaybarSlider';
|
||||
import { PlaybarSlider } from './PlaybarSlider';
|
||||
import { useSessionEvents } from './sessionUtils';
|
||||
import { getShortUrl } from './utils';
|
||||
|
||||
export default function Playbar({
|
||||
playerState,
|
||||
setPlayerState,
|
||||
setFocus,
|
||||
playbackRange,
|
||||
focus,
|
||||
eventsConfig,
|
||||
}: {
|
||||
playerState: 'playing' | 'paused';
|
||||
setPlayerState: (playerState: 'playing' | 'paused') => void;
|
||||
focus: { ts: number; setBy: string } | undefined;
|
||||
setFocus: (focus: { ts: number; setBy: string }) => void;
|
||||
playbackRange: [Date, Date];
|
||||
eventsConfig: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
}) {
|
||||
// might be outdated? state update or something? that's why the max slider val can be wrong?
|
||||
const minTs = playbackRange[0].getTime();
|
||||
const maxTs = playbackRange[1].getTime();
|
||||
const maxSliderVal = Math.ceil(playbackRange[1].getTime() / 1000) * 1000;
|
||||
const minSliderVal = Math.floor(playbackRange[0].getTime() / 1000) * 1000;
|
||||
|
||||
const { events } = useSessionEvents({ config: eventsConfig });
|
||||
|
||||
const markers = useMemo<PlaybarMarker[]>(() => {
|
||||
return uniqBy(
|
||||
events
|
||||
?.filter(
|
||||
({ startOffset }) => startOffset >= minTs && startOffset <= maxTs,
|
||||
)
|
||||
.map(event => {
|
||||
const spanName = event['span_name'];
|
||||
const locationHref = event['location.href'];
|
||||
const shortLocationHref = getShortUrl(locationHref);
|
||||
|
||||
const errorMessage = event['error.message'];
|
||||
|
||||
const url = event['http.url'];
|
||||
const statusCode = event['http.status_code'];
|
||||
const method = event['http.method'];
|
||||
const shortUrl = getShortUrl(url);
|
||||
|
||||
const isNavigation =
|
||||
spanName === 'routeChange' || spanName === 'documentLoad';
|
||||
|
||||
const isError = event.severity_text === 'error' || statusCode >= 399;
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
ts: event.startOffset,
|
||||
percentage: Math.round(
|
||||
((event.startOffset - minTs) / (maxTs - minTs)) * 100,
|
||||
),
|
||||
description: isNavigation
|
||||
? `Navigated to ${shortLocationHref}`
|
||||
: url.length > 0
|
||||
? `${statusCode} ${method}${url.length > 0 ? ` ${shortUrl}` : ''}`
|
||||
: errorMessage != null && errorMessage.length > 0
|
||||
? errorMessage
|
||||
: spanName === 'intercom.onShow'
|
||||
? 'Intercom Chat Opened'
|
||||
: event.body,
|
||||
isError,
|
||||
};
|
||||
}) ?? [],
|
||||
'percentage',
|
||||
);
|
||||
}, [events, maxTs, minTs]);
|
||||
|
||||
return (
|
||||
<PlaybarSlider
|
||||
markers={markers}
|
||||
min={minSliderVal}
|
||||
max={maxSliderVal}
|
||||
value={focus?.ts}
|
||||
onChange={ts => {
|
||||
setFocus({ ts, setBy: 'slider' });
|
||||
}}
|
||||
setPlayerState={setPlayerState}
|
||||
playerState={playerState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Slider, Tooltip } from '@mantine/core';
|
||||
|
||||
import { useFormatTime } from './useFormatTime';
|
||||
import { truncateText } from './utils';
|
||||
|
||||
import styles from '../styles/PlaybarSlider.module.scss';
|
||||
|
||||
export type PlaybarMarker = {
|
||||
id: string;
|
||||
ts: number;
|
||||
description: string;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
type PlaybarSliderProps = {
|
||||
value?: number;
|
||||
min: number;
|
||||
max: number;
|
||||
markers?: PlaybarMarker[];
|
||||
playerState: 'playing' | 'paused';
|
||||
onChange: (value: number) => void;
|
||||
setPlayerState: (playerState: 'playing' | 'paused') => void;
|
||||
};
|
||||
|
||||
export const PlaybarSlider = ({
|
||||
min,
|
||||
max,
|
||||
value,
|
||||
markers,
|
||||
playerState,
|
||||
onChange,
|
||||
setPlayerState,
|
||||
}: PlaybarSliderProps) => {
|
||||
const formatTime = useFormatTime();
|
||||
|
||||
const valueLabelFormat = React.useCallback(
|
||||
(ts: number) => {
|
||||
const value = Math.max(ts - min, 0);
|
||||
const minutes = Math.floor(value / 1000 / 60);
|
||||
const seconds = Math.floor((value / 1000) % 60);
|
||||
const timestamp = `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
|
||||
const time = formatTime(ts, { format: 'short' });
|
||||
return `${timestamp} at ${time}`;
|
||||
},
|
||||
[formatTime, min],
|
||||
);
|
||||
|
||||
const markersContent = React.useMemo(
|
||||
() =>
|
||||
markers?.map(mark => (
|
||||
<Tooltip
|
||||
color={mark.isError ? 'red' : 'gray'}
|
||||
key={mark.id}
|
||||
label={truncateText(mark?.description ?? '', 240, '...', /\n/)}
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<div
|
||||
className={styles.markerDot}
|
||||
style={{
|
||||
backgroundColor: mark.isError
|
||||
? 'var(--mantine-color-red-6)'
|
||||
: 'var(--mantine-color-gray-6)',
|
||||
left: `${((mark.ts - min) / (max - min)) * 100}%`,
|
||||
}}
|
||||
onClick={() => onChange(mark.ts)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)),
|
||||
[markers, max, min, onChange],
|
||||
);
|
||||
|
||||
const [prevPlayerState, setPrevPlayerState] = React.useState(playerState);
|
||||
const handleMouseDown = React.useCallback(() => {
|
||||
setPrevPlayerState(playerState);
|
||||
setPlayerState('paused');
|
||||
}, [playerState, setPlayerState]);
|
||||
const handleMouseUp = React.useCallback(() => {
|
||||
setPlayerState(prevPlayerState);
|
||||
}, [prevPlayerState, setPlayerState]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.markers}>{markersContent}</div>
|
||||
<Slider
|
||||
color={playerState === 'playing' ? 'green' : 'gray.5'}
|
||||
size="sm"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value || min}
|
||||
step={1000}
|
||||
label={valueLabelFormat}
|
||||
onChange={onChange}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Drawer from 'react-modern-drawer';
|
||||
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Card,
|
||||
Flex,
|
||||
Grid,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { DrawerBody, DrawerHeader } from './components/DrawerUtils';
|
||||
import { KubeTimeline } from './components/KubeComponents';
|
||||
import api from './api';
|
||||
import {
|
||||
convertDateRangeToGranularityString,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
K8S_MEM_NUMBER_FORMAT,
|
||||
} from './ChartUtils';
|
||||
import HDXMultiSeriesTimeChart from './HDXMultiSeriesTimeChart';
|
||||
import { LogTableWithSidePanel } from './LogTableWithSidePanel';
|
||||
import { parseTimeQuery, useTimeQuery } from './timeQuery';
|
||||
import { useZIndex, ZIndexContext } from './zIndex';
|
||||
|
||||
import styles from '../styles/LogSidePanel.module.scss';
|
||||
|
||||
const CHART_HEIGHT = 300;
|
||||
const defaultTimeRange = parseTimeQuery('Past 1h', false);
|
||||
|
||||
const PodDetailsProperty = React.memo(
|
||||
({ label, value }: { label: string; value?: string }) => {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="pe-4">
|
||||
<Text size="xs" color="gray.6">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" color="gray.3">
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const PodDetails = ({
|
||||
podName,
|
||||
dateRange,
|
||||
}: {
|
||||
podName: string;
|
||||
dateRange: [Date, Date];
|
||||
}) => {
|
||||
const { data } = api.useLogBatch({
|
||||
q: `k8s.pod.name:"${podName}"`,
|
||||
limit: 1,
|
||||
startDate: dateRange[0] ?? new Date(),
|
||||
endDate: dateRange[1] ?? new Date(),
|
||||
extraFields: [
|
||||
'k8s.node.name',
|
||||
'k8s.pod.name',
|
||||
'k8s.pod.uid',
|
||||
'k8s.namespace.name',
|
||||
'k8s.deployment.name',
|
||||
],
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
const properties = data?.pages?.[0]?.data?.[0] || {};
|
||||
|
||||
// If all properties are empty, don't show the panel
|
||||
if (Object.values(properties).every(v => !v)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid.Col span={12}>
|
||||
<div className="p-2 gap-2 d-flex flex-wrap">
|
||||
<PodDetailsProperty label="Node" value={properties['k8s.node.name']} />
|
||||
<PodDetailsProperty label="Pod" value={properties['k8s.pod.name']} />
|
||||
<PodDetailsProperty label="Pod UID" value={properties['k8s.pod.uid']} />
|
||||
<PodDetailsProperty
|
||||
label="Namespace"
|
||||
value={properties['k8s.namespace.name']}
|
||||
/>
|
||||
<PodDetailsProperty
|
||||
label="Deployment"
|
||||
value={properties['k8s.deployment.name']}
|
||||
/>
|
||||
</div>
|
||||
</Grid.Col>
|
||||
);
|
||||
};
|
||||
|
||||
function PodLogs({
|
||||
where,
|
||||
dateRange,
|
||||
}: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
}) {
|
||||
const [resultType, setResultType] = React.useState<'all' | 'error'>('all');
|
||||
|
||||
const _where = where + (resultType === 'error' ? ' level:err' : '');
|
||||
|
||||
return (
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
<Flex justify="space-between" align="center">
|
||||
Latest Pod Logs & Spans
|
||||
<Flex gap="xs" align="center">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={resultType}
|
||||
onChange={(value: string) => {
|
||||
if (value === 'all' || value === 'error') {
|
||||
setResultType(value);
|
||||
}
|
||||
}}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Errors', value: 'error' },
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href={`/search?q=${encodeURIComponent(_where)}`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
<Anchor size="xs" color="dimmed">
|
||||
Search <i className="bi bi-box-arrow-up-right"></i>
|
||||
</Anchor>
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<LogTableWithSidePanel
|
||||
config={{
|
||||
dateRange,
|
||||
where: _where,
|
||||
columns: ['k8s.container.name'],
|
||||
}}
|
||||
isLive={false}
|
||||
onPropertySearchClick={() => {}}
|
||||
columnNameMap={{
|
||||
'k8s.container.name': 'Container',
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PodDetailsSidePanel() {
|
||||
const [podName, setPodName] = useQueryParam(
|
||||
'podName',
|
||||
withDefault(StringParam, ''),
|
||||
{
|
||||
updateType: 'replaceIn',
|
||||
},
|
||||
);
|
||||
|
||||
// If we're in a nested side panel, we need to use a higher z-index
|
||||
// TODO: This is a hack
|
||||
const [nodeName] = useQueryParam('nodeName', StringParam);
|
||||
const [namespaceName] = useQueryParam('namespaceName', StringParam);
|
||||
const isNested = !!nodeName || !!namespaceName;
|
||||
const contextZIndex = useZIndex();
|
||||
const drawerZIndex = contextZIndex + 10 + (isNested ? 100 : 0);
|
||||
|
||||
const where = React.useMemo(() => {
|
||||
return `k8s.pod.name:"${podName}"`;
|
||||
}, [podName]);
|
||||
|
||||
const { searchedTimeRange: dateRange } = useTimeQuery({
|
||||
defaultValue: 'Past 1h',
|
||||
defaultTimeRange: [
|
||||
defaultTimeRange?.[0]?.getTime() ?? -1,
|
||||
defaultTimeRange?.[1]?.getTime() ?? -1,
|
||||
],
|
||||
});
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setPodName(undefined);
|
||||
}, [setPodName]);
|
||||
|
||||
if (!podName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
enableOverlay
|
||||
overlayOpacity={0.1}
|
||||
duration={0}
|
||||
open={!!podName}
|
||||
onClose={handleClose}
|
||||
direction="right"
|
||||
size={isNested ? '70vw' : '80vw'}
|
||||
zIndex={drawerZIndex}
|
||||
>
|
||||
<ZIndexContext.Provider value={drawerZIndex}>
|
||||
<div className={styles.panel}>
|
||||
<DrawerHeader
|
||||
header={`Details for ${podName}`}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<DrawerBody>
|
||||
<Grid>
|
||||
<PodDetails podName={podName} dateRange={dateRange} />
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
CPU Usage
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
seriesReturnType: 'column',
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
groupBy: ['k8s.pod.name'],
|
||||
where,
|
||||
table: 'metrics',
|
||||
aggFn: 'avg',
|
||||
field: 'k8s.pod.cpu.utilization - Gauge',
|
||||
numberFormat: K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Memory Usage
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
seriesReturnType: 'column',
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
groupBy: ['k8s.pod.name'],
|
||||
where,
|
||||
table: 'metrics',
|
||||
aggFn: 'avg',
|
||||
field: 'k8s.pod.memory.usage - Gauge',
|
||||
numberFormat: K8S_MEM_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Latest Pod Events
|
||||
</Card.Section>
|
||||
<Card.Section>
|
||||
<ScrollArea
|
||||
viewportProps={{
|
||||
style: { maxHeight: CHART_HEIGHT },
|
||||
}}
|
||||
>
|
||||
<Box p="md" py="sm">
|
||||
<KubeTimeline q={`k8s.pod.name:"${podName}"`} />
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<PodLogs where={where} dateRange={dateRange} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</DrawerBody>
|
||||
</div>
|
||||
</ZIndexContext.Provider>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { Button, Form, Modal } from 'react-bootstrap';
|
||||
import { Button as MButton, Text } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
import api from './api';
|
||||
import { genEnglishExplanation } from './queryv2';
|
||||
|
||||
export default function SaveSearchModal({
|
||||
onHide,
|
||||
searchQuery,
|
||||
mode,
|
||||
searchName,
|
||||
searchID,
|
||||
onSaveSuccess,
|
||||
onUpdateSuccess,
|
||||
}: {
|
||||
onHide: () => void;
|
||||
searchQuery: string;
|
||||
mode: 'save' | 'update' | 'hidden';
|
||||
searchName: string;
|
||||
searchID: string;
|
||||
onSaveSuccess: (responseData: { _id: string }) => void;
|
||||
onUpdateSuccess: (responseData: { _id: string }) => void;
|
||||
}) {
|
||||
const saveLogView = api.useSaveLogView();
|
||||
const updateLogView = api.useUpdateLogView();
|
||||
|
||||
const [parsedEnglishQuery, setParsedEnglishQuery] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
genEnglishExplanation(searchQuery).then(q => {
|
||||
setParsedEnglishQuery(q);
|
||||
});
|
||||
}, [searchQuery]);
|
||||
|
||||
const onSubmitLogView = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target as typeof e.target & {
|
||||
name: { value: string };
|
||||
};
|
||||
// TODO: fallback to parsedEnglishQuery for better UX ??
|
||||
const name = target.name.value || parsedEnglishQuery;
|
||||
|
||||
if (mode === 'update') {
|
||||
updateLogView.mutate(
|
||||
{
|
||||
id: searchID,
|
||||
name,
|
||||
query: searchQuery,
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
onUpdateSuccess(response.data);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message:
|
||||
'An error occured. Please contact support for more details.',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
saveLogView.mutate(
|
||||
{
|
||||
name,
|
||||
query: searchQuery,
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
onSaveSuccess(response.data);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message:
|
||||
'An error occured. Please contact support for more details.',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
aria-labelledby="contained-modal-title-vcenter"
|
||||
centered
|
||||
onHide={onHide}
|
||||
show={mode !== 'hidden'}
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Body className="bg-grey rounded">
|
||||
<h5 className="text-muted">Save Search</h5>
|
||||
<Form onSubmit={onSubmitLogView}>
|
||||
<Form.Group className="mb-3 mt-4">
|
||||
<Text span fw="bold">
|
||||
Query:
|
||||
</Text>
|
||||
<Text span> {searchQuery}</Text>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-2 mt-2">
|
||||
<Form.Label className="text-start text-muted fs-7">Name</Form.Label>
|
||||
<Form.Control
|
||||
className="border-0 mb-4 px-3"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder={parsedEnglishQuery}
|
||||
size="sm"
|
||||
type="text"
|
||||
autoFocus
|
||||
defaultValue={searchName}
|
||||
/>
|
||||
</Form.Group>
|
||||
<MButton size="sm" variant="light" type="submit">
|
||||
{mode === 'update' ? 'Update' : 'Save'}
|
||||
</MButton>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,387 +0,0 @@
|
|||
import React from 'react';
|
||||
import cx from 'classnames';
|
||||
import { useAtom } from 'jotai';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
import {
|
||||
ActionIcon,
|
||||
Card,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { Icon } from './components/Icon';
|
||||
import api from './api';
|
||||
import { useSearchPageFilterState } from './searchFilters';
|
||||
|
||||
import classes from '../styles/SearchPage.module.scss';
|
||||
|
||||
const filtersVisibleAtom = atomWithStorage('searchPageFiltersVisible', true);
|
||||
|
||||
const EVENT_TYPE_OPTIONS = [
|
||||
{
|
||||
value: 'log',
|
||||
label: (
|
||||
<>
|
||||
Logs <Icon name="braces" className="text-slate-400" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'span',
|
||||
label: (
|
||||
<>
|
||||
Spans <Icon name="list-nested" className="text-slate-400" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const EVENT_LEVEL_OPTIONS = [
|
||||
{
|
||||
value: 'error',
|
||||
label: <Text c="red">Error</Text>,
|
||||
},
|
||||
{
|
||||
value: 'warn',
|
||||
label: <Text c="orange">Warn</Text>,
|
||||
},
|
||||
{
|
||||
value: 'ok',
|
||||
label: <Text c="green">Ok</Text>,
|
||||
},
|
||||
{
|
||||
value: 'info',
|
||||
label: <Text c="blue">Info</Text>,
|
||||
},
|
||||
{
|
||||
value: 'debug',
|
||||
label: <Text c="gray">Debug</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
type FilterCheckboxProps = {
|
||||
label: string | React.ReactNode;
|
||||
value?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
onClickOnly?: VoidFunction;
|
||||
};
|
||||
|
||||
export const TextButton = ({
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
onClick?: VoidFunction;
|
||||
label: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<UnstyledButton onClick={onClick} className={classes.textButton}>
|
||||
<Text size="xxs" c="gray.6" lh={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilterCheckbox = ({
|
||||
value,
|
||||
label,
|
||||
onChange,
|
||||
onClickOnly,
|
||||
}: FilterCheckboxProps) => {
|
||||
return (
|
||||
<div className={classes.filterCheckbox}>
|
||||
<Group
|
||||
gap={8}
|
||||
onClick={() => onChange?.(!value)}
|
||||
flex={1}
|
||||
wrap="nowrap"
|
||||
align="flex-start"
|
||||
>
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
size={13 as any}
|
||||
onChange={e => onChange?.(e.currentTarget.checked)}
|
||||
/>
|
||||
<Text size="xs" c="gray.5">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
{onClickOnly && <TextButton onClick={onClickOnly} label="Only" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type FilterGroupProps = {
|
||||
name: string;
|
||||
options: { value: string; label: string | React.ReactNode }[];
|
||||
optionsLoading?: boolean;
|
||||
selectedValues?: Set<string>;
|
||||
onChange: (value: string) => void;
|
||||
onClearClick: VoidFunction;
|
||||
onOnlyClick: (value: string) => void;
|
||||
};
|
||||
|
||||
const MAX_FILTER_GROUP_ITEMS = 10;
|
||||
|
||||
export const FilterGroup = ({
|
||||
name,
|
||||
options,
|
||||
optionsLoading,
|
||||
selectedValues = new Set(),
|
||||
onChange,
|
||||
onClearClick,
|
||||
onOnlyClick,
|
||||
}: FilterGroupProps) => {
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
|
||||
const augmentedOptions = React.useMemo(() => {
|
||||
return [
|
||||
...Array.from(selectedValues)
|
||||
.filter(value => !options.find(option => option.value === value))
|
||||
.map(value => ({ value, label: value })),
|
||||
...options,
|
||||
];
|
||||
}, [options, selectedValues]);
|
||||
|
||||
const displayedOptions = React.useMemo(() => {
|
||||
if (search) {
|
||||
return augmentedOptions.filter(option => {
|
||||
return (
|
||||
option.value &&
|
||||
option.value.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (isExpanded || augmentedOptions.length <= MAX_FILTER_GROUP_ITEMS) {
|
||||
return augmentedOptions;
|
||||
}
|
||||
|
||||
// Do not rearrange items if all selected values are visible without expanding
|
||||
const shouldSortBySelected =
|
||||
isExpanded ||
|
||||
augmentedOptions.some(
|
||||
(option, index) =>
|
||||
selectedValues.has(option.value) && index >= MAX_FILTER_GROUP_ITEMS,
|
||||
);
|
||||
|
||||
return augmentedOptions
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (!shouldSortBySelected) {
|
||||
return 0;
|
||||
}
|
||||
if (selectedValues.has(a.value) && !selectedValues.has(b.value)) {
|
||||
return -1;
|
||||
}
|
||||
if (!selectedValues.has(a.value) && selectedValues.has(b.value)) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
.slice(0, Math.max(MAX_FILTER_GROUP_ITEMS, selectedValues.size));
|
||||
}, [search, isExpanded, augmentedOptions, selectedValues]);
|
||||
|
||||
const showExpandButton =
|
||||
!search &&
|
||||
augmentedOptions.length > MAX_FILTER_GROUP_ITEMS &&
|
||||
selectedValues.size < augmentedOptions.length;
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
placeholder={name}
|
||||
leftSection={<Icon name="search" className="fs-8.5" />}
|
||||
value={search}
|
||||
w="60%"
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSearch(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
{selectedValues.size > 0 && (
|
||||
<TextButton
|
||||
label="Clear"
|
||||
onClick={() => {
|
||||
onClearClick();
|
||||
setSearch('');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap={0}>
|
||||
{displayedOptions.map(option => (
|
||||
<FilterCheckbox
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
value={selectedValues.has(option.value)}
|
||||
onChange={() => onChange(option.value)}
|
||||
onClickOnly={() => onOnlyClick(option.value)}
|
||||
/>
|
||||
))}
|
||||
{optionsLoading ? (
|
||||
<Group m={6} gap="xs">
|
||||
<Loader size={12} color="gray.6" />
|
||||
<Text c="dimmed" size="xs">
|
||||
Loading...
|
||||
</Text>
|
||||
</Group>
|
||||
) : displayedOptions.length === 0 ? (
|
||||
<Group m={6} gap="xs">
|
||||
<Text c="dimmed" size="xs">
|
||||
No options found
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{showExpandButton && (
|
||||
<div className="d-flex m-1">
|
||||
<TextButton
|
||||
label={
|
||||
isExpanded ? (
|
||||
<>
|
||||
<Icon name="chevron-up" /> Less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="chevron-down" /> Show more
|
||||
</>
|
||||
)
|
||||
}
|
||||
onClick={() => setExpanded(!isExpanded)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type SearchPageFiltersProps = {
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
};
|
||||
|
||||
export const SearchPageFilters = ({
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
}: SearchPageFiltersProps) => {
|
||||
const [filtersVisible] = useAtom(filtersVisibleAtom);
|
||||
|
||||
const { data: services, isLoading: isServicesLoading } = api.useServices();
|
||||
|
||||
const servicesOptions = React.useMemo(() => {
|
||||
return Object.keys(services?.data ?? {}).map(name => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}));
|
||||
}, [services]);
|
||||
|
||||
const environmentsOptions = React.useMemo(() => {
|
||||
const allAttrs = Object.values(services?.data ?? {});
|
||||
const envs = new Set<string>();
|
||||
for (const attrs of allAttrs) {
|
||||
for (const attr of attrs) {
|
||||
if (attr['deployment.environment']) {
|
||||
envs.add(attr['deployment.environment']);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(envs).map(env => ({ value: env, label: env }));
|
||||
}, [services]);
|
||||
|
||||
const { setFilterValue, filters, clearFilter } = useSearchPageFilterState({
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
});
|
||||
|
||||
if (!filtersVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.filtersPanel}>
|
||||
<ScrollArea h="100%" scrollbarSize={4}>
|
||||
<Stack gap="sm" p="xs">
|
||||
<Text size="xxs" c="dimmed" fw="bold">
|
||||
Filters
|
||||
</Text>
|
||||
|
||||
<FilterGroup
|
||||
name="Event Type"
|
||||
options={EVENT_TYPE_OPTIONS}
|
||||
selectedValues={filters['hyperdx_event_type']}
|
||||
onChange={value => setFilterValue('hyperdx_event_type', value)}
|
||||
onClearClick={() => clearFilter('hyperdx_event_type')}
|
||||
onOnlyClick={value =>
|
||||
setFilterValue('hyperdx_event_type', value, true)
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterGroup
|
||||
name="Level"
|
||||
options={EVENT_LEVEL_OPTIONS}
|
||||
selectedValues={filters['level']}
|
||||
onChange={value => setFilterValue('level', value)}
|
||||
onClearClick={() => clearFilter('level')}
|
||||
onOnlyClick={value => setFilterValue('level', value, true)}
|
||||
/>
|
||||
|
||||
<FilterGroup
|
||||
name="Service"
|
||||
options={servicesOptions}
|
||||
optionsLoading={isServicesLoading}
|
||||
selectedValues={filters['service']}
|
||||
onChange={value => setFilterValue('service', value)}
|
||||
onClearClick={() => clearFilter('service')}
|
||||
onOnlyClick={value => setFilterValue('service', value, true)}
|
||||
/>
|
||||
|
||||
<FilterGroup
|
||||
name="Environment"
|
||||
options={environmentsOptions}
|
||||
optionsLoading={isServicesLoading}
|
||||
selectedValues={filters['deployment.environment']}
|
||||
onChange={value => setFilterValue('deployment.environment', value)}
|
||||
onClearClick={() => clearFilter('deployment.environment')}
|
||||
onOnlyClick={value =>
|
||||
setFilterValue('deployment.environment', value, true)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToggleFilterButton = () => {
|
||||
const [filtersVisible, setFiltersVisible] = useAtom(filtersVisibleAtom);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
color="gray"
|
||||
mr="xs"
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
title="Toggle Filters"
|
||||
onClick={() => setFiltersVisible(!filtersVisible)}
|
||||
>
|
||||
<Icon
|
||||
name="funnel"
|
||||
className={cx(
|
||||
'fs-5',
|
||||
filtersVisible ? 'text-slate-200' : 'text-slate-500',
|
||||
)}
|
||||
/>
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
|
||||
import { FilterCheckbox, FilterGroup } from './SearchPage.components';
|
||||
|
||||
const meta = {
|
||||
title: 'SearchPage/Filters',
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
const [filterState, setFilterState] = React.useState<any>({});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack w={160} gap={0}>
|
||||
<FilterCheckbox
|
||||
label="Logs"
|
||||
value={filterState.logs}
|
||||
onChange={(checked: boolean) =>
|
||||
setFilterState({ ...filterState, logs: checked })
|
||||
}
|
||||
onClickOnly={() => setFilterState({ logs: true })}
|
||||
/>
|
||||
<FilterCheckbox
|
||||
label="Spans"
|
||||
value={filterState.spans}
|
||||
onChange={(checked: boolean) =>
|
||||
setFilterState({ ...filterState, spans: checked })
|
||||
}
|
||||
onClickOnly={() => setFilterState({ spans: true })}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Group = () => {
|
||||
return (
|
||||
<div style={{ width: 200 }}>
|
||||
<FilterGroup
|
||||
name="Level"
|
||||
options={[
|
||||
...Array.from({ length: 20 }).map((_, index) => ({
|
||||
value: `level${index}`,
|
||||
label: `Level ${index}`,
|
||||
})),
|
||||
{
|
||||
value: 'very-long-super-long-absolutely-ridiculously-long',
|
||||
label: 'very-long-super-long-absolutely-ridiculously-long',
|
||||
},
|
||||
]}
|
||||
selectedValues={new Set(['info'])}
|
||||
onChange={() => {}}
|
||||
onClearClick={() => {}}
|
||||
onOnlyClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupWithManyHiddenCheckedValues = () => {
|
||||
return (
|
||||
<div style={{ width: 200 }}>
|
||||
<FilterGroup
|
||||
name="Level"
|
||||
options={[
|
||||
...Array.from({ length: 20 }).map((_, index) => ({
|
||||
value: `level${index}`,
|
||||
label: `Level ${index}`,
|
||||
})),
|
||||
{
|
||||
value: 'very-long-super-long-absolutely-ridiculously-long',
|
||||
label: 'very-long-super-long-absolutely-ridiculously-long',
|
||||
},
|
||||
]}
|
||||
selectedValues={
|
||||
new Set(
|
||||
Array.from({ length: 10 }).map((_, index) => `level${index + 10}`),
|
||||
)
|
||||
}
|
||||
onChange={() => {}}
|
||||
onClearClick={() => {}}
|
||||
onOnlyClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupLoading = () => {
|
||||
return (
|
||||
<div style={{ width: 200 }}>
|
||||
<FilterGroup
|
||||
name="Level"
|
||||
options={[]}
|
||||
optionsLoading
|
||||
selectedValues={new Set(['info'])}
|
||||
onChange={() => {}}
|
||||
onClearClick={() => {}}
|
||||
onOnlyClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupEmpty = () => {
|
||||
return (
|
||||
<div style={{ width: 200 }}>
|
||||
<FilterGroup
|
||||
name="Level"
|
||||
options={[]}
|
||||
onChange={() => {}}
|
||||
onClearClick={() => {}}
|
||||
onOnlyClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
|
@ -1,985 +0,0 @@
|
|||
import {
|
||||
FormEvent,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import cx from 'classnames';
|
||||
import { clamp, sub } from 'date-fns';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ReferenceArea,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
StringParam,
|
||||
useQueryParam,
|
||||
useQueryParams,
|
||||
withDefault,
|
||||
} from 'use-query-params';
|
||||
import { ActionIcon, Indicator } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
import { TimePicker } from '@/components/TimePicker';
|
||||
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import api from './api';
|
||||
import CreateLogAlertModal from './CreateLogAlertModal';
|
||||
import { withAppNav } from './layout';
|
||||
import LogSidePanel from './LogSidePanel';
|
||||
import LogTable from './LogTable';
|
||||
import { MemoPatternTableWithSidePanel } from './PatternTableWithSidePanel';
|
||||
import SaveSearchModal from './SaveSearchModal';
|
||||
import SearchInput from './SearchInput';
|
||||
import { SearchPageFilters, ToggleFilterButton } from './SearchPage.components';
|
||||
import SearchPageActionBar from './SearchPageActionBar';
|
||||
import { Tags } from './Tags';
|
||||
import { useTimeQuery } from './timeQuery';
|
||||
import { useDisplayedColumns } from './useDisplayedColumns';
|
||||
import { FormatTime, useFormatTime } from './useFormatTime';
|
||||
|
||||
import 'react-modern-drawer/dist/index.css';
|
||||
import styles from '../styles/SearchPage.module.scss';
|
||||
|
||||
const HistogramBarChartTooltip = (props: any) => {
|
||||
const { active, payload, label } = props;
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-grey px-3 py-2 rounded fs-8">
|
||||
<div className="mb-2">
|
||||
<FormatTime value={label * 1000} format="withMs" />
|
||||
</div>
|
||||
{payload.map((p: any) => (
|
||||
<div key={p.name} style={{ color: p.color }}>
|
||||
{p.dataKey === 'error' ? 'Errors' : 'Other'}: {p.value} lines
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const HDXHistogram = memo(
|
||||
({
|
||||
config: { dateRange, where },
|
||||
onTimeRangeSelect,
|
||||
isLive,
|
||||
}: {
|
||||
config: {
|
||||
dateRange: [Date, Date];
|
||||
where: string;
|
||||
};
|
||||
onTimeRangeSelect: (start: Date, end: Date) => void;
|
||||
isLive: boolean;
|
||||
}) => {
|
||||
const { data: histogramResults, isLoading: isHistogramResultsLoading } =
|
||||
api.useLogHistogram(
|
||||
where,
|
||||
dateRange?.[0] ?? new Date(),
|
||||
dateRange?.[1] ?? new Date(),
|
||||
{
|
||||
keepPreviousData: isLive,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const data = useMemo(() => {
|
||||
const buckets = new Map();
|
||||
for (const row of histogramResults?.data ?? []) {
|
||||
buckets.set(row.ts_bucket, {
|
||||
...buckets.get(row.ts_bucket),
|
||||
[row.severity_group === '' ? 'info' : row.severity_group]: row.count,
|
||||
ts_bucket: row.ts_bucket,
|
||||
});
|
||||
}
|
||||
return Array.from(buckets.values());
|
||||
}, [histogramResults]);
|
||||
|
||||
const tsInterval =
|
||||
((data?.[1]?.ts_bucket ?? 0) - (data?.[0]?.ts_bucket ?? 0)) * 1000;
|
||||
|
||||
const [highlightStart, setHighlightStart] = useState<string | undefined>();
|
||||
const [highlightEnd, setHighlightEnd] = useState<string | undefined>();
|
||||
|
||||
const formatTime = useFormatTime();
|
||||
|
||||
return isHistogramResultsLoading ? (
|
||||
<div className="w-100 h-100 d-flex align-items-center justify-content-center">
|
||||
Loading Graph...
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0}>
|
||||
<BarChart
|
||||
syncId="hdx"
|
||||
syncMethod="value"
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
className="user-select-none cursor-crosshair"
|
||||
onMouseLeave={e => {
|
||||
setHighlightStart(undefined);
|
||||
setHighlightEnd(undefined);
|
||||
}}
|
||||
onMouseDown={e => e != null && setHighlightStart(e.activeLabel)}
|
||||
onMouseMove={e => highlightStart && setHighlightEnd(e.activeLabel)}
|
||||
onMouseUp={e => {
|
||||
if (e?.activeLabel != null && highlightStart === e.activeLabel) {
|
||||
setHighlightStart(undefined);
|
||||
setHighlightEnd(undefined);
|
||||
|
||||
return onTimeRangeSelect(
|
||||
new Date(
|
||||
Number.parseInt(highlightStart ?? e.activeLabel) * 1000,
|
||||
),
|
||||
new Date(
|
||||
Number.parseInt(highlightEnd ?? e.activeLabel) * 1000 +
|
||||
tsInterval,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (highlightStart != null && highlightEnd != null) {
|
||||
try {
|
||||
onTimeRangeSelect(
|
||||
new Date(
|
||||
Number.parseInt(
|
||||
highlightStart <= highlightEnd
|
||||
? highlightStart
|
||||
: highlightEnd,
|
||||
) * 1000,
|
||||
),
|
||||
new Date(
|
||||
Number.parseInt(
|
||||
highlightEnd >= highlightStart
|
||||
? highlightEnd
|
||||
: highlightStart,
|
||||
) * 1000,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('failed to highlight range', e);
|
||||
}
|
||||
setHighlightStart(undefined);
|
||||
setHighlightEnd(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey={'ts_bucket'}
|
||||
domain={[
|
||||
dateRange[0].getTime() / 1000,
|
||||
dateRange[1].getTime() / 1000,
|
||||
]}
|
||||
interval="preserveStartEnd"
|
||||
scale="time"
|
||||
type="number"
|
||||
tickFormatter={tick => formatTime(tick * 1000, { format: 'short' })}
|
||||
minTickGap={50}
|
||||
tick={{ fontSize: 12, fontFamily: 'IBM Plex Mono, monospace' }}
|
||||
/>
|
||||
<YAxis
|
||||
width={35}
|
||||
minTickGap={25}
|
||||
tickFormatter={(value: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(value)
|
||||
}
|
||||
tick={{ fontSize: 12, fontFamily: 'IBM Plex Mono, monospace' }}
|
||||
/>
|
||||
<Tooltip content={<HistogramBarChartTooltip />} />
|
||||
<Bar dataKey="info" stackId="a" fill="#50FA7B" maxBarSize={24} />
|
||||
<Bar dataKey="error" stackId="a" fill="#FF5D5B" maxBarSize={24} />
|
||||
{highlightStart && highlightEnd ? (
|
||||
<ReferenceArea
|
||||
// yAxisId="1"
|
||||
x1={highlightStart}
|
||||
x2={highlightEnd}
|
||||
strokeOpacity={0.3}
|
||||
/>
|
||||
) : null}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const HistogramResultCounter = ({
|
||||
config: { dateRange, where },
|
||||
}: {
|
||||
config: { dateRange: [Date, Date]; where: string };
|
||||
}) => {
|
||||
const { data: histogramResults, isLoading: isHistogramResultsLoading } =
|
||||
api.useLogHistogram(
|
||||
where,
|
||||
dateRange?.[0] ?? new Date(),
|
||||
dateRange?.[1] ?? new Date(),
|
||||
{
|
||||
staleTime: 1000 * 60 * 5,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{histogramResults != null ? (
|
||||
<>
|
||||
{histogramResults?.data?.reduce(
|
||||
(p: number, v: any) => p + Number.parseInt(v.count),
|
||||
0,
|
||||
)}{' '}
|
||||
Results
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LogViewerContainer = memo(function LogViewerContainer({
|
||||
onPropertySearchClick,
|
||||
onPropertyAddClick,
|
||||
config,
|
||||
generateSearchUrl,
|
||||
generateChartUrl,
|
||||
isLive,
|
||||
setIsLive,
|
||||
onShowPatternsClick,
|
||||
}: {
|
||||
config: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
onPropertySearchClick: (
|
||||
name: string,
|
||||
value: string | boolean | number,
|
||||
) => void;
|
||||
generateSearchUrl: (query?: string, timeRange?: [Date, Date]) => string;
|
||||
generateChartUrl: (config: {
|
||||
aggFn: string;
|
||||
field: string;
|
||||
groupBy: string[];
|
||||
}) => string;
|
||||
onPropertyAddClick: (name: string, value: string | boolean | number) => void;
|
||||
isLive: boolean;
|
||||
setIsLive: (isLive: boolean) => void;
|
||||
onShowPatternsClick: () => void;
|
||||
}) {
|
||||
const [openedLogQuery, setOpenedLogQuery] = useQueryParams(
|
||||
{
|
||||
lid: withDefault(StringParam, undefined),
|
||||
sk: withDefault(StringParam, undefined),
|
||||
},
|
||||
{
|
||||
updateType: 'pushIn',
|
||||
enableBatching: true,
|
||||
},
|
||||
);
|
||||
|
||||
const openedLog = useMemo(() => {
|
||||
if (openedLogQuery.lid != null && openedLogQuery.sk != null) {
|
||||
return {
|
||||
id: openedLogQuery.lid,
|
||||
sortKey: openedLogQuery.sk,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [openedLogQuery]);
|
||||
|
||||
const setOpenedLog = useCallback(
|
||||
(log: { id: string; sortKey: string } | undefined) => {
|
||||
if (log == null || openedLog?.id === log.id) {
|
||||
setOpenedLogQuery({ lid: undefined, sk: undefined });
|
||||
} else {
|
||||
setOpenedLogQuery({ lid: log.id, sk: log.sortKey });
|
||||
}
|
||||
},
|
||||
[openedLog, setOpenedLogQuery],
|
||||
);
|
||||
|
||||
const { displayedColumns, setDisplayedColumns, toggleColumn } =
|
||||
useDisplayedColumns();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary message="An error occurred while rendering the event details. Contact support for more help.">
|
||||
<LogSidePanel
|
||||
key={openedLog?.id}
|
||||
logId={openedLog?.id}
|
||||
sortKey={openedLog?.sortKey}
|
||||
onClose={() => {
|
||||
setOpenedLog(undefined);
|
||||
}}
|
||||
onPropertyAddClick={onPropertyAddClick}
|
||||
generateSearchUrl={generateSearchUrl}
|
||||
generateChartUrl={generateChartUrl}
|
||||
displayedColumns={displayedColumns}
|
||||
toggleColumn={toggleColumn}
|
||||
shareUrl={window.location.href}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<LogTable
|
||||
tableId="search-table"
|
||||
isLive={isLive}
|
||||
onScroll={useCallback(
|
||||
(scrollTop: number) => {
|
||||
// If the user scrolls a bit down, kick out of live mode
|
||||
if (scrollTop > 16) {
|
||||
setIsLive(false);
|
||||
}
|
||||
},
|
||||
[setIsLive],
|
||||
)}
|
||||
highlightedLineId={openedLog?.id}
|
||||
config={config}
|
||||
onPropertySearchClick={onPropertySearchClick}
|
||||
onRowExpandClick={useCallback(
|
||||
(id: string, sortKey: string) => {
|
||||
setOpenedLog({ id, sortKey });
|
||||
setIsLive(false);
|
||||
},
|
||||
[setOpenedLog, setIsLive],
|
||||
)}
|
||||
onShowPatternsClick={onShowPatternsClick}
|
||||
displayedColumns={displayedColumns}
|
||||
setDisplayedColumns={setDisplayedColumns}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function SearchPage() {
|
||||
const router = useRouter();
|
||||
const savedSearchId = router.query.savedSearchId;
|
||||
|
||||
const [resultsMode, setResultsMode] = useState<'search' | 'patterns'>(
|
||||
'search',
|
||||
);
|
||||
|
||||
const {
|
||||
isReady,
|
||||
isLive,
|
||||
searchedTimeRange,
|
||||
displayedTimeInputValue,
|
||||
setDisplayedTimeInputValue,
|
||||
onSearch,
|
||||
setIsLive,
|
||||
onTimeRangeSelect,
|
||||
} = useTimeQuery({});
|
||||
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
useEffect(() => {
|
||||
setIsFirstLoad(false);
|
||||
}, []);
|
||||
const [_searchedQuery, setSearchedQuery] = useQueryParam(
|
||||
'q',
|
||||
withDefault(StringParam, undefined),
|
||||
{
|
||||
// prevent hijacking browser back button
|
||||
updateType: isFirstLoad ? 'replaceIn' : 'pushIn',
|
||||
// Workaround for qparams not being set properly: https://github.com/pbeshai/use-query-params/issues/233
|
||||
enableBatching: true,
|
||||
},
|
||||
);
|
||||
// Allows us to determine if the user has changed the search query
|
||||
const searchedQuery = _searchedQuery ?? '';
|
||||
|
||||
// TODO: Set displayed query to qparam... in a less bad way?
|
||||
useEffect(() => {
|
||||
setDisplayedSearchQuery(searchedQuery);
|
||||
}, [searchedQuery]);
|
||||
|
||||
const [displayedSearchQuery, setDisplayedSearchQuery] = useState('');
|
||||
|
||||
const doSearch = useCallback(
|
||||
(query: string, timeQuery: string) => {
|
||||
onSearch(timeQuery);
|
||||
|
||||
setSearchedQuery(query);
|
||||
},
|
||||
[setSearchedQuery, onSearch],
|
||||
);
|
||||
|
||||
const onSearchSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
doSearch(displayedSearchQuery, displayedTimeInputValue);
|
||||
};
|
||||
|
||||
const searchInput = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
searchInput.current?.focus();
|
||||
}, [searchInput]);
|
||||
useHotkeys(
|
||||
'/',
|
||||
() => {
|
||||
searchInput.current?.focus();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
[searchInput],
|
||||
);
|
||||
|
||||
const [saveSearchModalMode, setSaveSearchModalMode] = useState<
|
||||
'update' | 'save' | 'hidden'
|
||||
>('hidden');
|
||||
const [configAlertModalShow, setConfigAlertModalShow] = useState(false);
|
||||
const deleteLogView = api.useDeleteLogView();
|
||||
const updateLogView = api.useUpdateLogView();
|
||||
const {
|
||||
data: logViewsData,
|
||||
isLoading: isLogViewsLoading,
|
||||
refetch: refetchLogViews,
|
||||
} = api.useLogViews();
|
||||
const logViews = useMemo(() => logViewsData?.data ?? [], [logViewsData]);
|
||||
|
||||
const selectedSavedSearch = logViews.find(v => v._id === savedSearchId);
|
||||
|
||||
// Populate searched query with saved query if searched query is unset (initial load)
|
||||
useEffect(() => {
|
||||
if (selectedSavedSearch != null && _searchedQuery == null) {
|
||||
setSearchedQuery(selectedSavedSearch?.query);
|
||||
}
|
||||
}, [selectedSavedSearch, setSearchedQuery, _searchedQuery]);
|
||||
|
||||
const isArcBrowser =
|
||||
typeof window !== 'undefined' &&
|
||||
window
|
||||
.getComputedStyle?.(document.documentElement)
|
||||
.getPropertyValue('--arc-palette-title');
|
||||
|
||||
useHotkeys(
|
||||
// Arc Browser uses CMD+S for toggling sidebar which conflicts with save search
|
||||
isArcBrowser ? ['ctrl+shift+s', 'meta+shift+s'] : ['ctrl+s', 'meta+s'],
|
||||
() => {
|
||||
setSaveSearchModalMode('save');
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[setSaveSearchModalMode],
|
||||
);
|
||||
|
||||
const onClickDeleteLogView = () => {
|
||||
if (selectedSavedSearch?._id) {
|
||||
deleteLogView.mutate(selectedSavedSearch._id, {
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: 'Saved search deleted.',
|
||||
});
|
||||
router.push(`/search`);
|
||||
refetchLogViews();
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message:
|
||||
'An error occurred. Please contact support for more details.',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onClickUpdateLogView = () => {
|
||||
if (selectedSavedSearch?._id && displayedSearchQuery) {
|
||||
updateLogView.mutate(
|
||||
{ id: selectedSavedSearch._id, query: displayedSearchQuery },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: 'Saved search updated.',
|
||||
});
|
||||
refetchLogViews();
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message:
|
||||
'An error occurred. Please contact support for more details.',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickConfigAlert = () => {
|
||||
setConfigAlertModalShow(true);
|
||||
};
|
||||
// ***********************************************************************
|
||||
|
||||
const onPropertySearchClick = useCallback(
|
||||
(name: string, value: string | number | boolean) => {
|
||||
const searchQuery = `${name}:${
|
||||
typeof value === 'string' ? `"${value}"` : value
|
||||
}`;
|
||||
doSearch(searchQuery, displayedTimeInputValue);
|
||||
setDisplayedSearchQuery(searchQuery);
|
||||
},
|
||||
[setDisplayedSearchQuery, doSearch, displayedTimeInputValue],
|
||||
);
|
||||
|
||||
const formatTime = useFormatTime();
|
||||
const generateSearchUrl = useCallback(
|
||||
(newQuery?: string, newTimeRange?: [Date, Date], lid?: string) => {
|
||||
const fromDate = newTimeRange ? newTimeRange[0] : searchedTimeRange[0];
|
||||
const toDate = newTimeRange ? newTimeRange[1] : searchedTimeRange[1];
|
||||
const qparams = new URLSearchParams({
|
||||
q: newQuery ?? searchedQuery,
|
||||
from: fromDate.getTime().toString(),
|
||||
to: toDate.getTime().toString(),
|
||||
tq: `${formatTime(fromDate)} - ${formatTime(toDate)}`,
|
||||
...(lid ? { lid } : {}),
|
||||
});
|
||||
return `/search${
|
||||
selectedSavedSearch != null ? `/${selectedSavedSearch._id}` : ''
|
||||
}?${qparams.toString()}`;
|
||||
},
|
||||
[searchedQuery, searchedTimeRange, selectedSavedSearch, formatTime],
|
||||
);
|
||||
|
||||
const generateChartUrl = useCallback(
|
||||
({ aggFn, field, groupBy, table }: any) => {
|
||||
return `/chart?series=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
aggFn,
|
||||
field,
|
||||
groupBy,
|
||||
table,
|
||||
type: 'time',
|
||||
where: searchedQuery,
|
||||
}),
|
||||
)}`;
|
||||
},
|
||||
[searchedQuery],
|
||||
);
|
||||
|
||||
const onPropertyAddClick = useCallback(
|
||||
(name: string, value: string | number | boolean) => {
|
||||
const searchQuery = `${name}:${
|
||||
typeof value === 'string' ? `"${value}"` : value
|
||||
}`;
|
||||
setDisplayedSearchQuery(v => v + (v.length > 0 ? ' ' : '') + searchQuery);
|
||||
},
|
||||
[setDisplayedSearchQuery],
|
||||
);
|
||||
|
||||
const chartsConfig = useMemo(() => {
|
||||
return {
|
||||
where: searchedQuery,
|
||||
dateRange: [
|
||||
searchedTimeRange[0] ?? new Date(),
|
||||
searchedTimeRange[1] ?? new Date(),
|
||||
] as [Date, Date],
|
||||
};
|
||||
}, [searchedQuery, searchedTimeRange]);
|
||||
|
||||
const [zoomOutFrom, zoomOutTo, zoomInFrom, zoomInTo] = useMemo(() => {
|
||||
if (searchedTimeRange[0] == null || searchedTimeRange[1] == null) {
|
||||
return [new Date(), new Date(), new Date(), new Date()];
|
||||
}
|
||||
|
||||
const rangeMs =
|
||||
searchedTimeRange[1].getTime() - searchedTimeRange[0].getTime();
|
||||
const qtrRangeSec = Math.max(Math.round(rangeMs / 1000 / 4), 1);
|
||||
|
||||
const zoomOutFrom = sub(searchedTimeRange[0] ?? new Date(), {
|
||||
seconds: qtrRangeSec,
|
||||
});
|
||||
const zoomOutTo = clamp(
|
||||
sub(searchedTimeRange[1] ?? new Date(), {
|
||||
seconds: -qtrRangeSec,
|
||||
}),
|
||||
{
|
||||
start: zoomOutFrom,
|
||||
end: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
const zoomInFrom = sub(searchedTimeRange[0] ?? new Date(), {
|
||||
seconds: -qtrRangeSec,
|
||||
});
|
||||
const zoomInTo = clamp(
|
||||
sub(searchedTimeRange[1] ?? new Date(), {
|
||||
seconds: qtrRangeSec,
|
||||
}),
|
||||
{
|
||||
start: zoomInFrom,
|
||||
end: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
return [zoomOutFrom, zoomOutTo, zoomInFrom, zoomInTo];
|
||||
}, [searchedTimeRange]);
|
||||
|
||||
// This ensures we only render this conditionally on the client
|
||||
// otherwise we get SSR hydration issues
|
||||
const [shouldShowLiveModeHint, setShouldShowLiveModeHint] = useState(false);
|
||||
useEffect(() => {
|
||||
setShouldShowLiveModeHint(isLive === false);
|
||||
}, [isLive]);
|
||||
|
||||
const onShowEventsClick = useCallback(() => {
|
||||
setResultsMode('search');
|
||||
}, [setResultsMode]);
|
||||
|
||||
const handleUpdateTags = useCallback(
|
||||
(newTags: string[]) => {
|
||||
if (selectedSavedSearch?._id) {
|
||||
updateLogView.mutate(
|
||||
{
|
||||
id: selectedSavedSearch?._id,
|
||||
query: displayedSearchQuery,
|
||||
tags: newTags,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchLogViews();
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message:
|
||||
'An error occurred. Please contact support for more details.',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
displayedSearchQuery,
|
||||
refetchLogViews,
|
||||
selectedSavedSearch?._id,
|
||||
updateLogView,
|
||||
],
|
||||
);
|
||||
const tagsCount = selectedSavedSearch?.tags?.length || 0;
|
||||
|
||||
const handleSearchQueryChange = useCallback(
|
||||
(newQuery: string) => {
|
||||
if (newQuery !== displayedSearchQuery) {
|
||||
doSearch(newQuery, displayedTimeInputValue);
|
||||
}
|
||||
},
|
||||
[displayedSearchQuery, displayedTimeInputValue, doSearch],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh' }}>
|
||||
<Head>
|
||||
<title>Search - HyperDX</title>
|
||||
</Head>
|
||||
<SaveSearchModal
|
||||
mode={saveSearchModalMode}
|
||||
onHide={() => setSaveSearchModalMode('hidden')}
|
||||
searchQuery={displayedSearchQuery}
|
||||
searchName={selectedSavedSearch?.name ?? ''}
|
||||
searchID={selectedSavedSearch?._id ?? ''}
|
||||
onSaveSuccess={responseData => {
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: 'Saved search created',
|
||||
});
|
||||
router.push(
|
||||
`/search/${responseData._id}?${new URLSearchParams({
|
||||
q: searchedQuery,
|
||||
...(isLive
|
||||
? {}
|
||||
: {
|
||||
from: searchedTimeRange[0].getTime().toString(),
|
||||
to: searchedTimeRange[1].getTime().toString(),
|
||||
}),
|
||||
}).toString()}`,
|
||||
);
|
||||
refetchLogViews();
|
||||
setSaveSearchModalMode('hidden');
|
||||
}}
|
||||
onUpdateSuccess={responseData => {
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: 'Saved search renamed',
|
||||
});
|
||||
refetchLogViews();
|
||||
setSaveSearchModalMode('hidden');
|
||||
}}
|
||||
/>
|
||||
<CreateLogAlertModal
|
||||
show={configAlertModalShow}
|
||||
onHide={() => setConfigAlertModalShow(false)}
|
||||
savedSearch={selectedSavedSearch}
|
||||
query={selectedSavedSearch?.query ?? displayedSearchQuery}
|
||||
onSaveSuccess={() => {
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: 'Alerts updated successfully.',
|
||||
});
|
||||
refetchLogViews();
|
||||
}}
|
||||
onDeleteSuccess={() => {
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: 'Alert deleted successfully.',
|
||||
});
|
||||
refetchLogViews();
|
||||
}}
|
||||
onSavedSearchCreateSuccess={responseData => {
|
||||
router.push(
|
||||
`/search/${responseData._id}?${new URLSearchParams({
|
||||
q: searchedQuery,
|
||||
...(isLive
|
||||
? {}
|
||||
: {
|
||||
from: searchedTimeRange[0].getTime().toString(),
|
||||
to: searchedTimeRange[1].getTime().toString(),
|
||||
}),
|
||||
}).toString()}`,
|
||||
);
|
||||
refetchLogViews();
|
||||
}}
|
||||
/>
|
||||
<div className="d-flex flex-column flex-grow-0 min-h-0 h-100 bg-hdx-dark">
|
||||
<div
|
||||
className="bg-body pb-3 pt-3 d-flex px-3 align-items-center"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--mantine-color-gray-9)',
|
||||
}}
|
||||
>
|
||||
<ToggleFilterButton />
|
||||
<form onSubmit={onSearchSubmit} className="d-flex flex-grow-1">
|
||||
<SearchInput
|
||||
inputRef={searchInput}
|
||||
value={displayedSearchQuery}
|
||||
onChange={useCallback(
|
||||
q => setDisplayedSearchQuery(q),
|
||||
[setDisplayedSearchQuery],
|
||||
)}
|
||||
onSearch={(searchQuery: string) =>
|
||||
doSearch(searchQuery, displayedTimeInputValue)
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="ms-2 w-100 d-flex"
|
||||
style={{ maxWidth: 360, height: 36 }}
|
||||
>
|
||||
<TimePicker
|
||||
inputValue={displayedTimeInputValue}
|
||||
setInputValue={setDisplayedTimeInputValue}
|
||||
onSearch={rangeStr => {
|
||||
doSearch(displayedSearchQuery, rangeStr);
|
||||
}}
|
||||
showLive={resultsMode === 'search'}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="submit"
|
||||
value="Search"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
border: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<SearchPageActionBar
|
||||
key={`${savedSearchId}`}
|
||||
onClickConfigAlert={onClickConfigAlert}
|
||||
onClickDeleteLogView={onClickDeleteLogView}
|
||||
onClickUpdateLogView={onClickUpdateLogView}
|
||||
selectedLogView={selectedSavedSearch}
|
||||
onClickRenameSearch={() => {
|
||||
setSaveSearchModalMode('update');
|
||||
}}
|
||||
onClickSaveSearch={() => {
|
||||
setSaveSearchModalMode('save');
|
||||
}}
|
||||
/>
|
||||
{!!selectedSavedSearch && (
|
||||
<Tags
|
||||
allowCreate
|
||||
values={selectedSavedSearch.tags || []}
|
||||
onChange={handleUpdateTags}
|
||||
>
|
||||
<Indicator
|
||||
label={tagsCount || '+'}
|
||||
size={20}
|
||||
color="gray"
|
||||
withBorder
|
||||
zIndex={1}
|
||||
>
|
||||
<ActionIcon size="lg" variant="default" ml="xs">
|
||||
<i className="bi bi-tags-fill text-slate-300"></i>
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
</Tags>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="d-flex flex-row flex-grow-0"
|
||||
style={{
|
||||
minHeight: 0,
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary message="Unable to render search filters">
|
||||
<SearchPageFilters
|
||||
searchQuery={searchedQuery}
|
||||
onSearchQueryChange={handleSearchQueryChange}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<div className="d-flex flex-column flex-grow-1">
|
||||
<div className="d-flex mx-4 mt-2 justify-content-between">
|
||||
<div className="fs-8 text-muted">
|
||||
{isReady ? (
|
||||
<HistogramResultCounter
|
||||
config={{
|
||||
where: searchedQuery,
|
||||
dateRange: [
|
||||
searchedTimeRange[0] ?? new Date(),
|
||||
searchedTimeRange[1] ?? new Date(),
|
||||
],
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="d-flex">
|
||||
<Link
|
||||
href={generateSearchUrl(searchedQuery, [
|
||||
zoomOutFrom,
|
||||
zoomOutTo,
|
||||
])}
|
||||
className="text-muted-hover text-decoration-none fs-8 me-3"
|
||||
>
|
||||
<i className="bi bi-zoom-out me-1"></i>Zoom Out
|
||||
</Link>
|
||||
<Link
|
||||
href={generateSearchUrl(searchedQuery, [
|
||||
zoomInFrom,
|
||||
zoomInTo,
|
||||
])}
|
||||
className="text-muted-hover text-decoration-none fs-8 me-3"
|
||||
>
|
||||
<i className="bi bi-zoom-in me-1"></i>Zoom In
|
||||
</Link>
|
||||
<Link
|
||||
href={generateChartUrl({
|
||||
table: 'logs',
|
||||
aggFn: 'count',
|
||||
field: undefined,
|
||||
groupBy: ['level'],
|
||||
})}
|
||||
className="text-muted-hover text-decoration-none fs-8"
|
||||
>
|
||||
<i className="bi bi-plus-circle me-1"></i>Create Chart
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: 110 }} className="my-2 px-3 w-100">
|
||||
{/* Hack, recharts will release real fix soon https://github.com/recharts/recharts/issues/172 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
paddingBottom: '110px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
{isReady ? (
|
||||
<HDXHistogram
|
||||
config={chartsConfig}
|
||||
onTimeRangeSelect={onTimeRangeSelect}
|
||||
isLive={isLive}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowLiveModeHint && resultsMode === 'search' && (
|
||||
<div
|
||||
className="d-flex justify-content-center"
|
||||
style={{ height: 0 }}
|
||||
>
|
||||
<div style={{ position: 'relative', top: -22, zIndex: 2 }}>
|
||||
<Button
|
||||
variant="outline-success"
|
||||
className="fs-8 bg-hdx-dark py-1"
|
||||
onClick={() => {
|
||||
setIsLive(true);
|
||||
}}
|
||||
>
|
||||
<i className="bi text-success bi-lightning-charge-fill me-2" />
|
||||
Resume Live Tail
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="px-3 flex-grow-1 bg-inherit"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
{isReady ? (
|
||||
resultsMode === 'search' || isLive ? (
|
||||
<LogViewerContainer
|
||||
config={chartsConfig}
|
||||
onPropertyAddClick={onPropertyAddClick}
|
||||
generateSearchUrl={generateSearchUrl}
|
||||
generateChartUrl={generateChartUrl}
|
||||
onPropertySearchClick={onPropertySearchClick}
|
||||
isLive={isLive}
|
||||
setIsLive={setIsLive}
|
||||
onShowPatternsClick={() => {
|
||||
setIsLive(false);
|
||||
setResultsMode('patterns');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MemoPatternTableWithSidePanel
|
||||
config={chartsConfig}
|
||||
onShowEventsClick={onShowEventsClick}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SearchPage.getLayout = withAppNav;
|
||||
|
||||
// TODO: Restore when we fix hydration errors
|
||||
// export default SearchPage;
|
||||
|
||||
const SearchPageDynamic = dynamic(async () => SearchPage, { ssr: false });
|
||||
// @ts-ignore
|
||||
SearchPageDynamic.getLayout = withAppNav;
|
||||
|
||||
export default SearchPageDynamic;
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
|
||||
|
||||
import { FloppyIcon } from './SVGIcons';
|
||||
import { useWindowSize } from './utils';
|
||||
|
||||
export default function SearchPageActionBar({
|
||||
onClickConfigAlert,
|
||||
onClickDeleteLogView,
|
||||
onClickSaveSearch,
|
||||
onClickRenameSearch,
|
||||
onClickUpdateLogView,
|
||||
selectedLogView,
|
||||
}: {
|
||||
onClickConfigAlert: () => void;
|
||||
onClickDeleteLogView: () => void;
|
||||
onClickSaveSearch: () => void;
|
||||
onClickRenameSearch: () => void;
|
||||
onClickUpdateLogView: () => void;
|
||||
selectedLogView: any;
|
||||
}) {
|
||||
const { width } = useWindowSize();
|
||||
const [isMoreActionsOpen, setIsMoreActionsOpen] = useState(false);
|
||||
const isSmallScreen = (width ?? 1000) < 900;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!selectedLogView && !isSmallScreen && (
|
||||
<Button
|
||||
variant="dark"
|
||||
className="text-muted-hover mx-2 d-flex align-items-center fs-7"
|
||||
style={{ height: 36 }}
|
||||
onClick={onClickSaveSearch}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<FloppyIcon width={14} />
|
||||
</div>
|
||||
<span className="d-none d-md-inline ms-2">Save</span>
|
||||
</Button>
|
||||
)}
|
||||
{selectedLogView && !isSmallScreen && (
|
||||
<Button
|
||||
variant="dark"
|
||||
className="text-muted-hover mx-2 d-flex align-items-center fs-7"
|
||||
style={{ height: 36 }}
|
||||
onClick={onClickUpdateLogView}
|
||||
>
|
||||
<div className="pe-2 d-flex align-items-center">
|
||||
<FloppyIcon width={14} />
|
||||
</div>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{!isSmallScreen && (
|
||||
<Button
|
||||
variant={'dark'}
|
||||
className="text-muted-hover me-2 fs-7"
|
||||
style={{ height: 36 }}
|
||||
// disabled={!selectedLogView}
|
||||
onClick={onClickConfigAlert}
|
||||
>
|
||||
<i className="bi bi-bell-fill fs-7.5" />
|
||||
<span className="d-none d-md-inline ms-2">
|
||||
{selectedLogView ? 'Alerts' : 'Alert'}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{(selectedLogView || isSmallScreen) && (
|
||||
<OverlayTrigger
|
||||
rootClose
|
||||
onToggle={opened => setIsMoreActionsOpen(opened)}
|
||||
show={isMoreActionsOpen}
|
||||
placement="bottom-end"
|
||||
delay={{ show: 0, hide: 0 }}
|
||||
trigger={['click']}
|
||||
overlay={
|
||||
<div>
|
||||
{isSmallScreen && (
|
||||
<>
|
||||
{!selectedLogView && (
|
||||
<div className="d-flex bg-body border rounded mt-2">
|
||||
<Button
|
||||
variant="dark"
|
||||
className="text-muted-hover mx-2 d-flex align-items-center fs-7"
|
||||
style={{ height: 36 }}
|
||||
onClick={onClickSaveSearch}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<FloppyIcon width={14} />
|
||||
</div>
|
||||
<span className="ms-2">Save</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{selectedLogView && (
|
||||
<div className="d-flex bg-body border rounded mt-2">
|
||||
<Button
|
||||
variant="dark"
|
||||
className="text-muted-hover mx-2 d-flex align-items-center fs-7"
|
||||
style={{ height: 36 }}
|
||||
onClick={onClickUpdateLogView}
|
||||
>
|
||||
<div className="pe-2 d-flex align-items-center">
|
||||
<FloppyIcon width={14} />
|
||||
</div>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isSmallScreen && (
|
||||
<div className="d-flex bg-body border rounded mt-2">
|
||||
<Button
|
||||
variant={'dark'}
|
||||
className="text-muted-hover me-2 fs-7"
|
||||
style={{ height: 36 }}
|
||||
// disabled={!selectedLogView}
|
||||
onClick={onClickConfigAlert}
|
||||
>
|
||||
<i className="bi bi-bell-fill fs-7.5" />
|
||||
<span className="ms-2">
|
||||
{selectedLogView ? 'Alerts' : 'Alert'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{selectedLogView && (
|
||||
<>
|
||||
<div className="d-flex bg-body border rounded mt-2">
|
||||
<Button
|
||||
variant="dark"
|
||||
className="text-muted-hover d-flex align-items-center fs-7 w-100"
|
||||
style={{ height: 36 }}
|
||||
onClick={onClickRenameSearch}
|
||||
>
|
||||
<i className="me-2 fs-7.5 bi bi-input-cursor-text" />
|
||||
Rename Saved Search
|
||||
</Button>
|
||||
</div>
|
||||
<div className="d-flex bg-body border rounded mt-2">
|
||||
<Button
|
||||
variant="dark"
|
||||
className="text-muted-hover d-flex align-items-center fs-7 w-100"
|
||||
style={{ height: 36 }}
|
||||
onClick={onClickDeleteLogView}
|
||||
>
|
||||
<i className="me-2 fs-7.5 bi bi-trash-fill" />
|
||||
Delete Saved Search
|
||||
</Button>
|
||||
</div>
|
||||
<div className="d-flex bg-body border rounded mt-2">
|
||||
<Button
|
||||
variant="dark"
|
||||
className="text-muted-hover d-flex align-items-center fs-7 w-100"
|
||||
style={{ height: 36 }}
|
||||
onClick={onClickSaveSearch}
|
||||
>
|
||||
<i className="me-2 fs-7.5 bi bi-plus" />
|
||||
Save as New Search
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="dark"
|
||||
className="text-muted-hover"
|
||||
style={{ height: 36 }}
|
||||
>
|
||||
<i className="bi bi-three-dots" />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,992 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { TimePicker } from '@/components/TimePicker';
|
||||
|
||||
import api from './api';
|
||||
import {
|
||||
convertDateRangeToGranularityString,
|
||||
ERROR_RATE_PERCENTAGE_NUMBER_FORMAT,
|
||||
INTEGER_NUMBER_FORMAT,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
K8S_MEM_NUMBER_FORMAT,
|
||||
MS_NUMBER_FORMAT,
|
||||
SINGLE_DECIMAL_NUMBER_FORMAT,
|
||||
} from './ChartUtils';
|
||||
import DBQuerySidePanel from './DBQuerySidePanel';
|
||||
import EndpointLatencyTile from './EndpointLatencyTile';
|
||||
import EndpointSidepanel from './EndpointSidePanel';
|
||||
import HDXListBarChart from './HDXListBarChart';
|
||||
import HDXMultiSeriesTableChart from './HDXMultiSeriesTableChart';
|
||||
import HDXMultiSeriesTimeChart from './HDXMultiSeriesTimeChart';
|
||||
import { InfraPodsStatusTable } from './KubernetesDashboardPage';
|
||||
import { withAppNav } from './layout';
|
||||
import { LogTableWithSidePanel } from './LogTableWithSidePanel';
|
||||
import { MemoPatternTableWithSidePanel } from './PatternTableWithSidePanel';
|
||||
import PodDetailsSidePanel from './PodDetailsSidePanel';
|
||||
import HdxSearchInput from './SearchInput';
|
||||
import { parseTimeQuery, useTimeQuery } from './timeQuery';
|
||||
import { ChartSeries } from './types';
|
||||
|
||||
const SearchInput = React.memo(
|
||||
({
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (q: string | null) => void;
|
||||
}) => {
|
||||
const [_searchQuery, _setSearchQuery] = React.useState<string | null>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const onSearchSubmit = React.useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSearchQuery(_searchQuery || null);
|
||||
},
|
||||
[_searchQuery, setSearchQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={onSearchSubmit}>
|
||||
<HdxSearchInput
|
||||
inputRef={searchInputRef}
|
||||
placeholder="Scope dashboard to..."
|
||||
value={_searchQuery ?? searchQuery}
|
||||
onChange={v => _setSearchQuery(v)}
|
||||
onSearch={() => {}}
|
||||
showHotkey={false}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const defaultTimeRange = parseTimeQuery('Past 1h', false);
|
||||
|
||||
const CHART_HEIGHT = 300;
|
||||
const DB_STATEMENT_PROPERTY = 'db.normalized_statement';
|
||||
|
||||
export default function ServiceDashboardPage() {
|
||||
const [activeTab, setActiveTab] = useQueryParam(
|
||||
'tab',
|
||||
withDefault(StringParam, 'http'),
|
||||
{ updateType: 'replaceIn' },
|
||||
);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useQueryParam(
|
||||
'q',
|
||||
withDefault(StringParam, ''),
|
||||
{ updateType: 'replaceIn' },
|
||||
);
|
||||
|
||||
const [service, setService] = useQueryParam(
|
||||
'service',
|
||||
withDefault(StringParam, ''),
|
||||
{ updateType: 'replaceIn' },
|
||||
);
|
||||
|
||||
const {
|
||||
searchedTimeRange: dateRange,
|
||||
displayedTimeInputValue,
|
||||
setDisplayedTimeInputValue,
|
||||
onSearch,
|
||||
} = useTimeQuery({
|
||||
defaultValue: 'Past 1h',
|
||||
defaultTimeRange: [
|
||||
defaultTimeRange?.[0]?.getTime() ?? -1,
|
||||
defaultTimeRange?.[1]?.getTime() ?? -1,
|
||||
],
|
||||
});
|
||||
|
||||
// Fetch services
|
||||
const { data: services, isLoading: isServicesLoading } = api.useServices();
|
||||
const servicesOptions = React.useMemo(() => {
|
||||
return Object.keys(services?.data ?? {}).map(name => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}));
|
||||
}, [services]);
|
||||
|
||||
const podNames = React.useMemo(() => {
|
||||
const podNames: Set<string> = new Set();
|
||||
if (service) {
|
||||
services?.data[service]?.forEach(values => {
|
||||
if (values['k8s.pod.name']) {
|
||||
podNames.add(values['k8s.pod.name']);
|
||||
}
|
||||
});
|
||||
}
|
||||
return [...podNames];
|
||||
}, [service, services]);
|
||||
|
||||
const whereClause = React.useMemo(() => {
|
||||
// TODO: Rework this query to correctly work on prod
|
||||
return [
|
||||
podNames.map(podName => `k8s.pod.name:"${podName}"`).join(' OR ') ||
|
||||
'k8s.pod.name:*',
|
||||
searchQuery,
|
||||
].join(' ');
|
||||
}, [podNames, searchQuery]);
|
||||
|
||||
// Generate chart config
|
||||
const scopeWhereQuery = React.useCallback(
|
||||
(where: string) => {
|
||||
const serviceQuery = service ? `service:"${service}" ` : '';
|
||||
const sQuery = searchQuery ? `(${searchQuery}) ` : '';
|
||||
const whereQuery = where ? `(${where})` : '';
|
||||
return `${serviceQuery}${sQuery}${whereQuery}`;
|
||||
},
|
||||
[service, searchQuery],
|
||||
);
|
||||
|
||||
// hack to fix when page shows all services even though service is selected
|
||||
if (isServicesLoading && service) {
|
||||
return (
|
||||
<div className="text-center text-slate-400 m-5">Loading services...</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>Service Dashboard - HyperDX</title>
|
||||
</Head>
|
||||
<EndpointSidepanel />
|
||||
<DBQuerySidePanel />
|
||||
<PodDetailsSidePanel />
|
||||
<div className="d-flex flex-column">
|
||||
<Group
|
||||
px="md"
|
||||
py="xs"
|
||||
className="border-bottom border-dark"
|
||||
gap="xs"
|
||||
align="center"
|
||||
>
|
||||
{/* Use Autocomplete instead? */}
|
||||
<Select
|
||||
searchable
|
||||
clearable
|
||||
allowDeselect
|
||||
placeholder="All Services"
|
||||
maxDropdownHeight={280}
|
||||
data={servicesOptions}
|
||||
disabled={isServicesLoading}
|
||||
radius="md"
|
||||
variant="filled"
|
||||
value={service}
|
||||
onChange={v => setService(v)}
|
||||
w={300}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<SearchInput
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
className="d-flex"
|
||||
style={{ width: 350, height: 36 }}
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
onSearch(displayedTimeInputValue);
|
||||
}}
|
||||
>
|
||||
<TimePicker
|
||||
inputValue={displayedTimeInputValue}
|
||||
setInputValue={setDisplayedTimeInputValue}
|
||||
onSearch={range => {
|
||||
onSearch(range);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</Group>
|
||||
</div>
|
||||
<Tabs
|
||||
color="gray"
|
||||
variant="pills"
|
||||
defaultValue="http"
|
||||
radius="md"
|
||||
keepMounted={false}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
>
|
||||
<div className="px-3 py-2 border-bottom border-dark">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="http">HTTP Service</Tabs.Tab>
|
||||
<Tabs.Tab value="database">Database</Tabs.Tab>
|
||||
<Tabs.Tab value="infrastructure">Infrastructure</Tabs.Tab>
|
||||
<Tabs.Tab value="errors">Errors</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<Tabs.Panel value="infrastructure">
|
||||
<Grid>
|
||||
{service && !podNames.length ? (
|
||||
<>
|
||||
<Grid.Col span={12}>
|
||||
<Card p="xl" ta="center">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
<div className="fs-8">
|
||||
<i className="bi bi-exclamation-circle-fill text-slate-400 fs-8 me-2" />
|
||||
No pods found for service{' '}
|
||||
<span className="text-white">{service}</span>
|
||||
</div>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</>
|
||||
) : null}
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
CPU Usage
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
seriesReturnType: 'column',
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
groupBy: ['k8s.pod.name'],
|
||||
where: whereClause,
|
||||
table: 'metrics',
|
||||
aggFn: 'avg',
|
||||
field: 'k8s.pod.cpu.utilization - Gauge',
|
||||
numberFormat: K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Memory Usage
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
seriesReturnType: 'column',
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
groupBy: ['k8s.pod.name'],
|
||||
where: whereClause,
|
||||
table: 'metrics',
|
||||
aggFn: 'avg',
|
||||
field: 'k8s.pod.memory.usage - Gauge',
|
||||
numberFormat: K8S_MEM_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<InfraPodsStatusTable
|
||||
dateRange={dateRange}
|
||||
where={whereClause}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
<Flex justify="space-between">
|
||||
Latest Kubernetes Warning Events
|
||||
<Link
|
||||
href={`/search?q=${encodeURIComponent(
|
||||
`${
|
||||
whereClause.trim().length > 0
|
||||
? `(${whereClause.trim()}) `
|
||||
: ''
|
||||
}(k8s.resource.name:"events" -level:"normal")`,
|
||||
)}&from=${dateRange[0].getTime()}&to=${dateRange[1].getTime()}`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
<Anchor size="xs" color="dimmed">
|
||||
Search <i className="bi bi-box-arrow-up-right"></i>
|
||||
</Anchor>
|
||||
</Link>
|
||||
</Flex>
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<LogTableWithSidePanel
|
||||
config={{
|
||||
dateRange,
|
||||
where: `${
|
||||
whereClause.trim().length > 0
|
||||
? `(${whereClause.trim()}) `
|
||||
: ''
|
||||
}(k8s.resource.name:"events" -level:"normal")`,
|
||||
columns: [
|
||||
'object.regarding.kind',
|
||||
'object.regarding.name',
|
||||
],
|
||||
}}
|
||||
columnNameMap={{
|
||||
'object.regarding.kind': 'Kind',
|
||||
'object.regarding.name': 'Name',
|
||||
}}
|
||||
isLive={false}
|
||||
onPropertySearchClick={() => {}}
|
||||
showServiceColumn={false}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="http">
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<RequestErrorRateCard
|
||||
dateRange={dateRange}
|
||||
scopeWhereQuery={scopeWhereQuery}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Request Throughput
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
series: [
|
||||
{
|
||||
displayName: 'Requests',
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: [],
|
||||
numberFormat: {
|
||||
...INTEGER_NUMBER_FORMAT,
|
||||
unit: 'requests',
|
||||
},
|
||||
},
|
||||
],
|
||||
seriesReturnType: 'column',
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
20 Top Most Time Consuming Endpoints
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXListBarChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
series: [
|
||||
{
|
||||
displayName: 'Total',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'sum',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: ['span_name'],
|
||||
sortOrder: 'desc',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
displayName: 'Req/Min',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'count_per_min',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: ['span_name'],
|
||||
numberFormat: SINGLE_DECIMAL_NUMBER_FORMAT,
|
||||
},
|
||||
{
|
||||
displayName: 'P95',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'p95',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: ['span_name'],
|
||||
numberFormat: {
|
||||
factor: 1,
|
||||
output: 'number',
|
||||
mantissa: 2,
|
||||
thousandSeparated: true,
|
||||
average: false,
|
||||
decimalBytes: false,
|
||||
unit: 'ms',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Median',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'p50',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: ['span_name'],
|
||||
numberFormat: {
|
||||
factor: 1,
|
||||
output: 'number',
|
||||
mantissa: 2,
|
||||
thousandSeparated: true,
|
||||
average: false,
|
||||
decimalBytes: false,
|
||||
unit: 'ms',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Errors/Min',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'count_per_min',
|
||||
field: '',
|
||||
where: scopeWhereQuery(
|
||||
'span.kind:"server" level:"error"',
|
||||
),
|
||||
groupBy: ['span_name'],
|
||||
numberFormat: SINGLE_DECIMAL_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
}}
|
||||
getRowSearchLink={row => {
|
||||
const searchParams = new URLSearchParams(
|
||||
window.location.search,
|
||||
);
|
||||
searchParams.set('endpoint', `${row.group}`);
|
||||
return (
|
||||
window.location.pathname +
|
||||
'?' +
|
||||
searchParams.toString()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<EndpointLatencyTile
|
||||
dateRange={dateRange}
|
||||
scopeWhereQuery={scopeWhereQuery}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<EndpointTableCard
|
||||
dateRange={dateRange}
|
||||
scopeWhereQuery={scopeWhereQuery}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="database">
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Total Time Consumed per Query
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
displayType: 'stacked_bar',
|
||||
series: [
|
||||
{
|
||||
displayName: 'Total Query Time',
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'sum',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery(''),
|
||||
groupBy: [DB_STATEMENT_PROPERTY],
|
||||
numberFormat: MS_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
seriesReturnType: 'column',
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Throughput per Query
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
displayType: 'stacked_bar',
|
||||
series: [
|
||||
{
|
||||
displayName: 'Total Query Count',
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: scopeWhereQuery(''),
|
||||
groupBy: [DB_STATEMENT_PROPERTY],
|
||||
numberFormat: {
|
||||
...INTEGER_NUMBER_FORMAT,
|
||||
unit: 'queries',
|
||||
},
|
||||
},
|
||||
],
|
||||
seriesReturnType: 'column',
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<DatabaseTimeConsumingQueryCard
|
||||
dateRange={dateRange}
|
||||
scopeWhereQuery={scopeWhereQuery}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="errors">
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Error Events per Service
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(
|
||||
dateRange,
|
||||
60,
|
||||
),
|
||||
seriesReturnType: 'column',
|
||||
displayType: 'stacked_bar',
|
||||
series: [
|
||||
{
|
||||
type: 'time',
|
||||
groupBy: ['service'],
|
||||
where: scopeWhereQuery('level:"error"'),
|
||||
table: 'logs',
|
||||
aggFn: 'count',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Error Patterns
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm">
|
||||
<MemoPatternTableWithSidePanel
|
||||
config={{
|
||||
where: scopeWhereQuery('level:"error"'),
|
||||
dateRange,
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Tabs.Panel>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointTableCard({
|
||||
scopeWhereQuery,
|
||||
dateRange,
|
||||
}: {
|
||||
dateRange: [Date, Date];
|
||||
scopeWhereQuery: (where: string) => string;
|
||||
}) {
|
||||
const [chartType, setChartType] = useState<'time' | 'error'>('time');
|
||||
|
||||
return (
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py={5} withBorder>
|
||||
<Flex justify="space-between" align="center">
|
||||
Top 20{' '}
|
||||
{chartType === 'time' ? 'Most Time Consuming' : 'Highest Error Rate'}{' '}
|
||||
Endpoints
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={chartType}
|
||||
onChange={(value: string) => {
|
||||
if (value === 'time' || value === 'error') {
|
||||
setChartType(value);
|
||||
}
|
||||
}}
|
||||
data={[
|
||||
{ label: 'Sort by Time', value: 'time' },
|
||||
{ label: 'Sort by Errors', value: 'error' },
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTableChart
|
||||
getRowSearchLink={row => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.set('endpoint', `${row.group}`);
|
||||
return window.location.pathname + '?' + searchParams.toString();
|
||||
}}
|
||||
config={{
|
||||
groupColumnName: 'Endpoint',
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(dateRange, 60),
|
||||
series: [
|
||||
{
|
||||
displayName: 'Req/Min',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'count_per_min',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: ['span_name'],
|
||||
numberFormat: SINGLE_DECIMAL_NUMBER_FORMAT,
|
||||
columnWidthPercent: 12,
|
||||
},
|
||||
{
|
||||
displayName: 'P95',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'p95',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: ['span_name'],
|
||||
numberFormat: {
|
||||
factor: 1,
|
||||
output: 'number',
|
||||
mantissa: 2,
|
||||
thousandSeparated: true,
|
||||
average: false,
|
||||
decimalBytes: false,
|
||||
unit: 'ms',
|
||||
},
|
||||
columnWidthPercent: 12,
|
||||
},
|
||||
{
|
||||
displayName: 'Median',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'p50',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: ['span_name'],
|
||||
numberFormat: {
|
||||
factor: 1,
|
||||
output: 'number',
|
||||
mantissa: 2,
|
||||
thousandSeparated: true,
|
||||
average: false,
|
||||
decimalBytes: false,
|
||||
unit: 'ms',
|
||||
},
|
||||
columnWidthPercent: 12,
|
||||
},
|
||||
{
|
||||
displayName: 'Total',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'sum',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: ['span_name'],
|
||||
columnWidthPercent: 12,
|
||||
visible: false,
|
||||
...(chartType === 'time'
|
||||
? {
|
||||
sortOrder: 'desc',
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
displayName: 'Errors/Min',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'count_per_min',
|
||||
field: '',
|
||||
where: scopeWhereQuery('span.kind:"server" level:"error"'),
|
||||
groupBy: ['span_name'],
|
||||
numberFormat: SINGLE_DECIMAL_NUMBER_FORMAT,
|
||||
columnWidthPercent: 12,
|
||||
...(chartType === 'error'
|
||||
? {
|
||||
sortOrder: 'desc',
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
seriesReturnType: 'column',
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestErrorRateCard({
|
||||
scopeWhereQuery,
|
||||
dateRange,
|
||||
}: {
|
||||
dateRange: [Date, Date];
|
||||
scopeWhereQuery: (where: string) => string;
|
||||
}) {
|
||||
const [chartType, setChartType] = useState<'overall' | 'grouped_by_endpoint'>(
|
||||
'overall',
|
||||
);
|
||||
|
||||
return (
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py={5} withBorder>
|
||||
<Flex justify="space-between" align="center">
|
||||
Request Error Rate
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={chartType}
|
||||
onChange={(value: string) => {
|
||||
if (value === 'overall' || value === 'grouped_by_endpoint') {
|
||||
setChartType(value);
|
||||
}
|
||||
}}
|
||||
data={[
|
||||
{ label: 'Overall', value: 'overall' },
|
||||
{ label: 'By Endpoint', value: 'grouped_by_endpoint' },
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
<HDXMultiSeriesTimeChart
|
||||
key={chartType}
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(dateRange, 60),
|
||||
displayType: chartType === 'overall' ? 'line' : 'stacked_bar',
|
||||
series: [
|
||||
{
|
||||
displayName: 'Error Rate %',
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
where: scopeWhereQuery('span.kind:"server" level:"error"'),
|
||||
groupBy: chartType === 'overall' ? [] : ['span_name'],
|
||||
numberFormat: ERROR_RATE_PERCENTAGE_NUMBER_FORMAT,
|
||||
},
|
||||
{
|
||||
table: 'logs',
|
||||
type: 'time',
|
||||
aggFn: 'count',
|
||||
field: '',
|
||||
where: scopeWhereQuery('span.kind:"server"'),
|
||||
groupBy: chartType === 'overall' ? [] : ['span_name'],
|
||||
numberFormat: ERROR_RATE_PERCENTAGE_NUMBER_FORMAT,
|
||||
},
|
||||
],
|
||||
seriesReturnType: 'ratio',
|
||||
}}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseTimeConsumingQueryCard({
|
||||
scopeWhereQuery,
|
||||
dateRange,
|
||||
}: {
|
||||
dateRange: [Date, Date];
|
||||
scopeWhereQuery: (where: string) => string;
|
||||
}) {
|
||||
const [chartType, setChartType] = useState<'table' | 'list'>('list');
|
||||
|
||||
const series: ChartSeries[] = [
|
||||
{
|
||||
visible: false,
|
||||
displayName: 'Total',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'sum',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery(''),
|
||||
groupBy: [DB_STATEMENT_PROPERTY],
|
||||
sortOrder: 'desc',
|
||||
columnWidthPercent: 12,
|
||||
},
|
||||
{
|
||||
displayName: 'Queries/Min',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'count_per_min',
|
||||
where: scopeWhereQuery(''),
|
||||
groupBy: [DB_STATEMENT_PROPERTY],
|
||||
numberFormat: SINGLE_DECIMAL_NUMBER_FORMAT,
|
||||
columnWidthPercent: 12,
|
||||
},
|
||||
{
|
||||
displayName: 'P95',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'p95',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery(''),
|
||||
groupBy: [DB_STATEMENT_PROPERTY],
|
||||
numberFormat: {
|
||||
factor: 1,
|
||||
output: 'number',
|
||||
mantissa: 2,
|
||||
thousandSeparated: true,
|
||||
average: false,
|
||||
decimalBytes: false,
|
||||
unit: 'ms',
|
||||
},
|
||||
columnWidthPercent: 12,
|
||||
},
|
||||
{
|
||||
displayName: 'Median',
|
||||
table: 'logs',
|
||||
type: 'table',
|
||||
aggFn: 'p50',
|
||||
field: 'duration',
|
||||
where: scopeWhereQuery(''),
|
||||
groupBy: [DB_STATEMENT_PROPERTY],
|
||||
numberFormat: {
|
||||
factor: 1,
|
||||
output: 'number',
|
||||
mantissa: 2,
|
||||
thousandSeparated: true,
|
||||
average: false,
|
||||
decimalBytes: false,
|
||||
unit: 'ms',
|
||||
},
|
||||
columnWidthPercent: 12,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py={5} withBorder>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Top 20 Most Time Consuming Queries</Text>
|
||||
<Box>
|
||||
<Button.Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color={chartType === 'list' ? 'green' : 'dark.2'}
|
||||
size="xs"
|
||||
title="List"
|
||||
onClick={() => setChartType('list')}
|
||||
>
|
||||
<i className="bi bi-filter-left" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color={chartType === 'table' ? 'green' : 'dark.2'}
|
||||
size="xs"
|
||||
title="Table"
|
||||
onClick={() => setChartType('table')}
|
||||
>
|
||||
<i className="bi bi-table" />
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={CHART_HEIGHT}>
|
||||
{chartType === 'list' ? (
|
||||
<HDXListBarChart
|
||||
hoverCardPosition="top"
|
||||
config={{
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(dateRange, 60),
|
||||
series,
|
||||
}}
|
||||
getRowSearchLink={row => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.set('db_query', `${row.group}`);
|
||||
return window.location.pathname + '?' + searchParams.toString();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<HDXMultiSeriesTableChart
|
||||
config={{
|
||||
groupColumnName: 'Normalized Query',
|
||||
dateRange,
|
||||
granularity: convertDateRangeToGranularityString(dateRange, 60),
|
||||
series,
|
||||
seriesReturnType: 'column',
|
||||
}}
|
||||
getRowSearchLink={row => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.set('db_query', `${row.group}`);
|
||||
return window.location.pathname + '?' + searchParams.toString();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
ServiceDashboardPage.getLayout = withAppNav;
|
||||
|
|
@ -20,8 +20,6 @@ import {
|
|||
import {
|
||||
ERROR_RATE_PERCENTAGE_NUMBER_FORMAT,
|
||||
INTEGER_NUMBER_FORMAT,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
K8S_MEM_NUMBER_FORMAT,
|
||||
MS_NUMBER_FORMAT,
|
||||
} from '@/ChartUtils';
|
||||
import { TSource } from '@/commonTypes';
|
||||
|
|
@ -43,32 +41,11 @@ import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
|||
import { withAppNav } from '@/layout';
|
||||
import { Filter } from '@/renderChartConfig';
|
||||
import SearchInputV2 from '@/SearchInputV2';
|
||||
import { getExpressions } from '@/serviceDashboard';
|
||||
import { useSource, useSources } from '@/source';
|
||||
import { Histogram } from '@/SVGIcons';
|
||||
import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery';
|
||||
|
||||
// TODO: Make configurable
|
||||
export const CH_COLUMNS = {
|
||||
service: 'ServiceName',
|
||||
spanName: 'SpanName',
|
||||
spanKind: 'SpanKind',
|
||||
level: `SpanAttributes['level']`, // ???
|
||||
k8sResourceName: "SpanAttributes['k8s.resource.name']",
|
||||
k8sPodName: "SpanAttributes['k8s.pod.name']",
|
||||
httpScheme: "SpanAttributes['http.scheme']",
|
||||
duration: 'Duration',
|
||||
serverAddress: "SpanAttributes['server.address']",
|
||||
httpHost: "SpanAttributes['http.host']",
|
||||
traceId: 'TraceID',
|
||||
};
|
||||
export const CH_IS_ERROR = `${CH_COLUMNS.level} = 'Error'`;
|
||||
export const CH_IS_SERVER_KIND = `${CH_COLUMNS.spanKind} IN ['Server', 'SPAN_KIND_SERVER']`;
|
||||
export const DB_STATEMENT_PROPERTY = `SpanAttributes['db.query.text']`;
|
||||
export const IS_DB_SPAN_FILTER: Filter = {
|
||||
type: 'sql',
|
||||
condition: `${DB_STATEMENT_PROPERTY} <> ''`,
|
||||
};
|
||||
|
||||
type AppliedConfig = {
|
||||
source?: string | null;
|
||||
service?: string | null;
|
||||
|
|
@ -76,27 +53,24 @@ type AppliedConfig = {
|
|||
whereLanguage?: 'sql' | 'lucene' | null;
|
||||
};
|
||||
|
||||
export const durationInMsExpr = (
|
||||
source: { durationExpression?: string; durationPrecision?: number } = {},
|
||||
) => {
|
||||
const durationExpression = source.durationExpression || CH_COLUMNS.duration;
|
||||
const durationPrecision = source.durationPrecision || 9;
|
||||
|
||||
// precision is per second
|
||||
return `${durationExpression}/1e${durationPrecision - 3}`;
|
||||
};
|
||||
|
||||
function getScopedFilters(appliedConfig: AppliedConfig): Filter[] {
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
function getScopedFilters(
|
||||
source: TSource,
|
||||
appliedConfig: AppliedConfig,
|
||||
includeIsSpanKindServer = true,
|
||||
): Filter[] {
|
||||
const expressions = getExpressions(source);
|
||||
const filters: Filter[] = [];
|
||||
// Database spans are of kind Client. To be cleaned up in HDX-1219
|
||||
if (includeIsSpanKindServer) {
|
||||
filters.push({
|
||||
type: 'sql',
|
||||
condition: CH_IS_SERVER_KIND,
|
||||
},
|
||||
];
|
||||
condition: expressions.isSpanKindServer,
|
||||
});
|
||||
}
|
||||
if (appliedConfig.service) {
|
||||
filters.push({
|
||||
type: 'sql',
|
||||
condition: `${CH_COLUMNS.service} = '${appliedConfig.service}'`,
|
||||
condition: `${expressions.service} IN ('${appliedConfig.service}')`,
|
||||
});
|
||||
}
|
||||
return filters;
|
||||
|
|
@ -112,6 +86,7 @@ function ServiceSelectControlled({
|
|||
onCreate?: () => void;
|
||||
} & UseControllerProps<any>) {
|
||||
const { data: source } = useSource({ id: sourceId });
|
||||
const expressions = getExpressions(source);
|
||||
|
||||
const queriedConfig = {
|
||||
...source,
|
||||
|
|
@ -123,10 +98,10 @@ function ServiceSelectControlled({
|
|||
select: [
|
||||
{
|
||||
alias: 'service',
|
||||
valueExpression: `distinct(${CH_COLUMNS.service})`,
|
||||
valueExpression: `distinct(${expressions.service})`,
|
||||
},
|
||||
],
|
||||
where: `${CH_COLUMNS.service} IS NOT NULL`,
|
||||
where: `${expressions.service} IS NOT NULL`,
|
||||
whereLanguage: 'sql' as const,
|
||||
limit: { limit: 200 },
|
||||
};
|
||||
|
|
@ -174,6 +149,7 @@ export function EndpointLatencyChart({
|
|||
appliedConfig?: AppliedConfig;
|
||||
extraFilters?: Filter[];
|
||||
}) {
|
||||
const expressions = getExpressions(source);
|
||||
const [latencyChartType, setLatencyChartType] = useState<
|
||||
'line' | 'histogram'
|
||||
>('line');
|
||||
|
|
@ -212,6 +188,7 @@ export function EndpointLatencyChart({
|
|||
(latencyChartType === 'line' ? (
|
||||
<DBTimeChart
|
||||
showDisplaySwitcher={false}
|
||||
sourceId={source.id}
|
||||
config={{
|
||||
...source,
|
||||
where: appliedConfig.where || '',
|
||||
|
|
@ -221,24 +198,27 @@ export function EndpointLatencyChart({
|
|||
alias: '95th Percentile',
|
||||
aggFn: 'quantile',
|
||||
level: 0.95,
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
},
|
||||
{
|
||||
alias: 'Median',
|
||||
aggFn: 'quantile',
|
||||
level: 0.5,
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
},
|
||||
{
|
||||
alias: 'Avg',
|
||||
aggFn: 'avg',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
},
|
||||
],
|
||||
filters: [...extraFilters, ...getScopedFilters(appliedConfig)],
|
||||
filters: [
|
||||
...extraFilters,
|
||||
...getScopedFilters(source, appliedConfig),
|
||||
],
|
||||
numberFormat: MS_NUMBER_FORMAT,
|
||||
dateRange,
|
||||
}}
|
||||
|
|
@ -252,10 +232,13 @@ export function EndpointLatencyChart({
|
|||
select: [
|
||||
{
|
||||
alias: 'data',
|
||||
valueExpression: `histogram(20)(${durationInMsExpr(source)})`,
|
||||
valueExpression: `histogram(20)(${expressions.durationInMillis})`,
|
||||
},
|
||||
],
|
||||
filters: [...extraFilters, ...getScopedFilters(appliedConfig)],
|
||||
filters: [
|
||||
...extraFilters,
|
||||
...getScopedFilters(source, appliedConfig),
|
||||
],
|
||||
dateRange,
|
||||
}}
|
||||
/>
|
||||
|
|
@ -272,6 +255,7 @@ function HttpTab({
|
|||
appliedConfig: AppliedConfig;
|
||||
}) {
|
||||
const { data: source } = useSource({ id: appliedConfig.source });
|
||||
const expressions = getExpressions(source);
|
||||
|
||||
const [reqChartType, setReqChartType] = useQueryState(
|
||||
'reqChartType',
|
||||
|
|
@ -311,6 +295,7 @@ function HttpTab({
|
|||
</Group>
|
||||
{source && (
|
||||
<DBTimeChart
|
||||
sourceId={source.id}
|
||||
config={{
|
||||
...source,
|
||||
where: appliedConfig.where || '',
|
||||
|
|
@ -321,7 +306,7 @@ function HttpTab({
|
|||
: DisplayType.StackedBar,
|
||||
select: [
|
||||
{
|
||||
valueExpression: `countIf(${CH_IS_ERROR}) / count()`,
|
||||
valueExpression: `countIf(${expressions.isError}) / count()`,
|
||||
alias: 'Error Rate %',
|
||||
},
|
||||
],
|
||||
|
|
@ -329,14 +314,14 @@ function HttpTab({
|
|||
filters: [
|
||||
{
|
||||
type: 'sql',
|
||||
condition: `${CH_COLUMNS.httpScheme} = 'http'`,
|
||||
condition: `${expressions.httpScheme} = 'http'`,
|
||||
},
|
||||
...getScopedFilters(appliedConfig),
|
||||
...getScopedFilters(source, appliedConfig),
|
||||
],
|
||||
groupBy:
|
||||
reqChartType === 'overall'
|
||||
? undefined
|
||||
: source.spanNameExpression || CH_COLUMNS.spanName,
|
||||
: source.spanNameExpression || expressions.spanName,
|
||||
dateRange: searchedTimeRange,
|
||||
}}
|
||||
showDisplaySwitcher={false}
|
||||
|
|
@ -353,6 +338,7 @@ function HttpTab({
|
|||
</Group>
|
||||
{source && (
|
||||
<DBTimeChart
|
||||
sourceId={source.id}
|
||||
config={{
|
||||
...source,
|
||||
where: appliedConfig.where || '',
|
||||
|
|
@ -371,7 +357,7 @@ function HttpTab({
|
|||
},
|
||||
],
|
||||
numberFormat: { ...INTEGER_NUMBER_FORMAT, unit: 'requests' },
|
||||
filters: getScopedFilters(appliedConfig),
|
||||
filters: getScopedFilters(source, appliedConfig),
|
||||
dateRange: searchedTimeRange,
|
||||
}}
|
||||
/>
|
||||
|
|
@ -399,12 +385,12 @@ function HttpTab({
|
|||
{
|
||||
alias: 'Endpoint',
|
||||
valueExpression:
|
||||
source.spanNameExpression || CH_COLUMNS.spanName,
|
||||
source.spanNameExpression || expressions.spanName,
|
||||
},
|
||||
{
|
||||
alias: 'Total (ms)',
|
||||
aggFn: 'sum',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
},
|
||||
{
|
||||
|
|
@ -416,28 +402,28 @@ function HttpTab({
|
|||
{
|
||||
alias: 'P95 (ms)',
|
||||
aggFn: 'quantile',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
level: 0.5,
|
||||
},
|
||||
{
|
||||
alias: 'Median (ms)',
|
||||
aggFn: 'quantile',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
level: 0.95,
|
||||
},
|
||||
|
||||
{
|
||||
alias: 'Errors/Min',
|
||||
valueExpression: `countIf(${CH_IS_ERROR}) /
|
||||
valueExpression: `countIf(${expressions.isError}) /
|
||||
age('mi', toDateTime(${startTime / 1000}), toDateTime(${endTime / 1000}))`,
|
||||
},
|
||||
],
|
||||
selectGroupBy: false,
|
||||
groupBy: source.spanNameExpression || CH_COLUMNS.spanName,
|
||||
groupBy: source.spanNameExpression || expressions.spanName,
|
||||
orderBy: '"Total (ms)" DESC',
|
||||
filters: getScopedFilters(appliedConfig),
|
||||
filters: getScopedFilters(source, appliedConfig),
|
||||
dateRange: searchedTimeRange,
|
||||
numberFormat: MS_NUMBER_FORMAT,
|
||||
}}
|
||||
|
|
@ -488,43 +474,34 @@ function HttpTab({
|
|||
{
|
||||
alias: 'Endpoint',
|
||||
valueExpression:
|
||||
source.spanNameExpression || CH_COLUMNS.spanName,
|
||||
source.spanNameExpression || expressions.spanName,
|
||||
},
|
||||
{
|
||||
alias: 'Req/Min',
|
||||
valueExpression: `
|
||||
count() /
|
||||
age('mi', toDateTime(${startTime / 1000}), toDateTime(${endTime / 1000}))`,
|
||||
valueExpression: `round(count() /
|
||||
age('mi', toDateTime(${startTime / 1000}), toDateTime(${endTime / 1000})), 1)`,
|
||||
},
|
||||
{
|
||||
alias: 'P95 (ms)',
|
||||
aggFn: 'quantile',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
aggCondition: '',
|
||||
level: 0.5,
|
||||
valueExpression: `round(quantile(0.95)(${expressions.durationInMillis}), 2)`,
|
||||
},
|
||||
{
|
||||
alias: 'Median (ms)',
|
||||
aggFn: 'quantile',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
aggCondition: '',
|
||||
level: 0.95,
|
||||
valueExpression: `round(quantile(0.5)(${expressions.durationInMillis}), 2)`,
|
||||
},
|
||||
{
|
||||
alias: 'Total (ms)',
|
||||
aggFn: 'sum',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
aggCondition: '',
|
||||
valueExpression: `round(sum(${expressions.durationInMillis}), 2)`,
|
||||
},
|
||||
{
|
||||
alias: 'Errors/Min',
|
||||
valueExpression: `countIf(${CH_IS_ERROR}) /
|
||||
age('mi', toDateTime(${startTime / 1000}), toDateTime(${endTime / 1000}))`,
|
||||
valueExpression: `round(countIf(${expressions.isError}) /
|
||||
age('mi', toDateTime(${startTime / 1000}), toDateTime(${endTime / 1000})), 1)`,
|
||||
},
|
||||
],
|
||||
filters: getScopedFilters(appliedConfig),
|
||||
filters: getScopedFilters(source, appliedConfig),
|
||||
selectGroupBy: false,
|
||||
groupBy: source.spanNameExpression || CH_COLUMNS.spanName,
|
||||
groupBy: source.spanNameExpression || expressions.spanName,
|
||||
dateRange: searchedTimeRange,
|
||||
orderBy:
|
||||
topEndpointsChartType === 'time'
|
||||
|
|
@ -548,6 +525,7 @@ function DatabaseTab({
|
|||
appliedConfig: AppliedConfig;
|
||||
}) {
|
||||
const { data: source } = useSource({ id: appliedConfig.source });
|
||||
const expressions = getExpressions(source);
|
||||
|
||||
const [chartType, setChartType] = useState<'table' | 'list'>('list');
|
||||
|
||||
|
|
@ -568,6 +546,7 @@ function DatabaseTab({
|
|||
</Group>
|
||||
{source && (
|
||||
<DBTimeChart
|
||||
sourceId={source.id}
|
||||
config={{
|
||||
...source,
|
||||
displayType: DisplayType.StackedBar,
|
||||
|
|
@ -577,16 +556,16 @@ function DatabaseTab({
|
|||
{
|
||||
alias: 'Total Query Time',
|
||||
aggFn: 'sum',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
...getScopedFilters(appliedConfig),
|
||||
IS_DB_SPAN_FILTER,
|
||||
...getScopedFilters(source, appliedConfig, false),
|
||||
{ type: 'sql', condition: expressions.isDbSpan },
|
||||
],
|
||||
numberFormat: MS_NUMBER_FORMAT,
|
||||
groupBy: DB_STATEMENT_PROPERTY,
|
||||
groupBy: expressions.dbStatement,
|
||||
dateRange: searchedTimeRange,
|
||||
}}
|
||||
/>
|
||||
|
|
@ -602,6 +581,7 @@ function DatabaseTab({
|
|||
</Group>
|
||||
{source && (
|
||||
<DBTimeChart
|
||||
sourceId={source.id}
|
||||
config={{
|
||||
...source,
|
||||
displayType: DisplayType.StackedBar,
|
||||
|
|
@ -611,19 +591,19 @@ function DatabaseTab({
|
|||
{
|
||||
alias: 'Total Query Count',
|
||||
aggFn: 'count',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
...getScopedFilters(appliedConfig),
|
||||
IS_DB_SPAN_FILTER,
|
||||
...getScopedFilters(source, appliedConfig, false),
|
||||
{ type: 'sql', condition: expressions.isDbSpan },
|
||||
],
|
||||
numberFormat: {
|
||||
...INTEGER_NUMBER_FORMAT,
|
||||
unit: 'queries',
|
||||
},
|
||||
groupBy: DB_STATEMENT_PROPERTY,
|
||||
groupBy: expressions.dbStatement,
|
||||
dateRange: searchedTimeRange,
|
||||
}}
|
||||
/>
|
||||
|
|
@ -672,19 +652,19 @@ function DatabaseTab({
|
|||
where: appliedConfig.where || '',
|
||||
whereLanguage: appliedConfig.whereLanguage || 'sql',
|
||||
dateRange: searchedTimeRange,
|
||||
groupBy: DB_STATEMENT_PROPERTY,
|
||||
groupBy: expressions.dbStatement,
|
||||
selectGroupBy: false,
|
||||
orderBy: '"Total" DESC',
|
||||
select: [
|
||||
{
|
||||
alias: 'Statement',
|
||||
valueExpression: DB_STATEMENT_PROPERTY,
|
||||
valueExpression: expressions.dbStatement,
|
||||
},
|
||||
{
|
||||
alias: 'Total',
|
||||
aggFn: 'sum',
|
||||
aggCondition: '',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
},
|
||||
{
|
||||
alias: 'Queries/Min',
|
||||
|
|
@ -695,28 +675,28 @@ function DatabaseTab({
|
|||
{
|
||||
alias: 'P95 (ms)',
|
||||
aggFn: 'quantile',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
level: 0.5,
|
||||
},
|
||||
{
|
||||
alias: 'Median (ms)',
|
||||
aggFn: 'quantile',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
level: 0.95,
|
||||
},
|
||||
{
|
||||
alias: 'Median',
|
||||
aggFn: 'quantile',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
level: 0.5,
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
...getScopedFilters(appliedConfig),
|
||||
IS_DB_SPAN_FILTER,
|
||||
...getScopedFilters(source, appliedConfig, false),
|
||||
{ type: 'sql', condition: expressions.isDbSpan },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
|
@ -728,19 +708,19 @@ function DatabaseTab({
|
|||
where: appliedConfig.where || '',
|
||||
whereLanguage: appliedConfig.whereLanguage || 'sql',
|
||||
dateRange: searchedTimeRange,
|
||||
groupBy: DB_STATEMENT_PROPERTY,
|
||||
groupBy: expressions.dbStatement,
|
||||
orderBy: '"Total" DESC',
|
||||
selectGroupBy: false,
|
||||
select: [
|
||||
{
|
||||
alias: 'Statement',
|
||||
valueExpression: DB_STATEMENT_PROPERTY,
|
||||
valueExpression: expressions.dbStatement,
|
||||
},
|
||||
{
|
||||
alias: 'Total',
|
||||
aggFn: 'sum',
|
||||
aggCondition: '',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
},
|
||||
{
|
||||
alias: 'Queries/Min',
|
||||
|
|
@ -751,26 +731,29 @@ function DatabaseTab({
|
|||
{
|
||||
alias: 'P95 (ms)',
|
||||
aggFn: 'quantile',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
level: 0.5,
|
||||
},
|
||||
{
|
||||
alias: 'Median (ms)',
|
||||
aggFn: 'quantile',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
level: 0.95,
|
||||
},
|
||||
{
|
||||
alias: 'Median',
|
||||
aggFn: 'quantile',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
aggCondition: '',
|
||||
level: 0.5,
|
||||
},
|
||||
],
|
||||
filters: getScopedFilters(appliedConfig),
|
||||
filters: [
|
||||
...getScopedFilters(source, appliedConfig, false),
|
||||
{ type: 'sql', condition: expressions.isDbSpan },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -780,121 +763,6 @@ function DatabaseTab({
|
|||
);
|
||||
}
|
||||
|
||||
// Kubernetes Tab
|
||||
// TODO this is a placeholder for now
|
||||
// TODO where to get metrics from?
|
||||
const K8S_POD_CPU_UTILIZATION = "SpanAttributes['k8s.pod.cpu.utilization']";
|
||||
const K8S_POD_MEM_USAGE = "SpanAttributes['k8s.pod.memory.usage']";
|
||||
const K8S_POD_NAME = "SpanAttributes['k8s.pod.name']";
|
||||
|
||||
const K8S_METRICS = {
|
||||
CONTAINER_RESTARTS: 'k8s.container.restarts',
|
||||
POD_PHASE: 'k8s.pod.phase',
|
||||
POD_UPTIME: 'k8s.pod.uptime',
|
||||
POD_CPU_UTILIZATION: 'k8s.pod.cpu.utilization',
|
||||
POD_CPU_LIMIT_UTILIZATION: 'k8s.pod.cpu.limit.utilization',
|
||||
POD_MEM_USAGE: 'k8s.pod.memory.usage',
|
||||
POD_MEM_LIMIT_UTILIZATION: 'k8s.pod.memory.limit.utilization',
|
||||
};
|
||||
|
||||
function KubernetesTab({
|
||||
searchedTimeRange,
|
||||
appliedConfig,
|
||||
}: {
|
||||
searchedTimeRange: [Date, Date];
|
||||
appliedConfig: AppliedConfig;
|
||||
}) {
|
||||
const { data: source } = useSource({ id: appliedConfig.source });
|
||||
|
||||
return (
|
||||
<Grid mt="md" grow={false} w="100%" maw="100%" overflow="hidden">
|
||||
<Grid.Col span={6}>
|
||||
<ChartBox style={{ height: 350 }}>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Text size="sm" c="gray.4">
|
||||
CPU Usage
|
||||
</Text>
|
||||
</Group>
|
||||
{source && (
|
||||
<DBTimeChart
|
||||
config={{
|
||||
...source,
|
||||
// TODO: This should come from source
|
||||
where: appliedConfig.where || '',
|
||||
whereLanguage: appliedConfig.whereLanguage || 'sql',
|
||||
select: [
|
||||
{
|
||||
alias: 'CPU',
|
||||
aggFn: 'avg',
|
||||
// TODO: aggCondition: 'METRIC_NAME = K8S_METRICS.POD_CPU_UTILIZATION',
|
||||
// TODO: valueExpression: 'METRIC_VALUE',
|
||||
valueExpression: K8S_POD_CPU_UTILIZATION,
|
||||
aggCondition: `${K8S_POD_NAME} <> ''`,
|
||||
aggConditionLanguage: 'sql',
|
||||
},
|
||||
],
|
||||
filters: getScopedFilters(appliedConfig),
|
||||
numberFormat: K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
groupBy: K8S_POD_NAME,
|
||||
dateRange: searchedTimeRange,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ChartBox>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<ChartBox style={{ height: 350 }}>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Text size="sm" c="gray.4">
|
||||
Memory Usage
|
||||
</Text>
|
||||
</Group>
|
||||
{source && (
|
||||
<DBTimeChart
|
||||
config={{
|
||||
...source,
|
||||
where: appliedConfig.where || '',
|
||||
whereLanguage: appliedConfig.whereLanguage || 'sql',
|
||||
select: [
|
||||
{
|
||||
alias: 'Memory',
|
||||
aggFn: 'avg',
|
||||
valueExpression: K8S_POD_MEM_USAGE,
|
||||
aggCondition: `${K8S_POD_NAME} <> ''`,
|
||||
aggConditionLanguage: 'sql',
|
||||
},
|
||||
],
|
||||
filters: getScopedFilters(appliedConfig),
|
||||
numberFormat: K8S_MEM_NUMBER_FORMAT,
|
||||
groupBy: K8S_POD_NAME,
|
||||
dateRange: searchedTimeRange,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ChartBox>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<ChartBox style={{ height: 350 }}>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Text size="sm" c="gray.4">
|
||||
Pods
|
||||
</Text>
|
||||
</Group>
|
||||
</ChartBox>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<ChartBox style={{ height: 350 }}>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Text size="sm" c="gray.4">
|
||||
Kubernetes Warning Events
|
||||
</Text>
|
||||
</Group>
|
||||
</ChartBox>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
// Errors Tab
|
||||
function ErrorsTab({
|
||||
searchedTimeRange,
|
||||
|
|
@ -904,6 +772,7 @@ function ErrorsTab({
|
|||
appliedConfig: AppliedConfig;
|
||||
}) {
|
||||
const { data: source } = useSource({ id: appliedConfig.source });
|
||||
const expressions = getExpressions(source);
|
||||
|
||||
return (
|
||||
<Grid mt="md" grow={false} w="100%" maw="100%" overflow="hidden">
|
||||
|
|
@ -916,6 +785,7 @@ function ErrorsTab({
|
|||
</Group>
|
||||
{source && (
|
||||
<DBTimeChart
|
||||
sourceId={source.id}
|
||||
config={{
|
||||
...source,
|
||||
where: appliedConfig.where || '',
|
||||
|
|
@ -930,11 +800,11 @@ function ErrorsTab({
|
|||
filters: [
|
||||
{
|
||||
type: 'sql',
|
||||
condition: CH_IS_ERROR,
|
||||
condition: expressions.isError,
|
||||
},
|
||||
...getScopedFilters(appliedConfig),
|
||||
...getScopedFilters(source, appliedConfig),
|
||||
],
|
||||
groupBy: source.serviceNameExpression || CH_COLUMNS.service,
|
||||
groupBy: source.serviceNameExpression || expressions.service,
|
||||
dateRange: searchedTimeRange,
|
||||
}}
|
||||
/>
|
||||
|
|
@ -958,12 +828,9 @@ const appliedConfigMap = {
|
|||
function ServicesDashboardPage() {
|
||||
const [tab, setTab] = useQueryState(
|
||||
'tab',
|
||||
parseAsStringEnum<string>([
|
||||
parseAsStringEnum<string>(['http', 'database', 'errors']).withDefault(
|
||||
'http',
|
||||
'database',
|
||||
'infrastructure',
|
||||
'errors',
|
||||
]).withDefault('http'),
|
||||
),
|
||||
);
|
||||
|
||||
const { data: sources } = useSources();
|
||||
|
|
@ -1098,44 +965,43 @@ function ServicesDashboardPage() {
|
|||
</Group>
|
||||
</Group>
|
||||
</form>
|
||||
<Tabs
|
||||
mt="md"
|
||||
keepMounted={false}
|
||||
defaultValue="http"
|
||||
onChange={setTab}
|
||||
value={tab}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="http">HTTP Service</Tabs.Tab>
|
||||
<Tabs.Tab value="database">Database</Tabs.Tab>
|
||||
<Tabs.Tab value="infrastructure">Kubernetes</Tabs.Tab>
|
||||
<Tabs.Tab value="errors">Errors</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="http">
|
||||
<HttpTab
|
||||
appliedConfig={appliedConfig}
|
||||
searchedTimeRange={searchedTimeRange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="database">
|
||||
<DatabaseTab
|
||||
appliedConfig={appliedConfig}
|
||||
searchedTimeRange={searchedTimeRange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="infrastructure">
|
||||
<KubernetesTab
|
||||
appliedConfig={appliedConfig}
|
||||
searchedTimeRange={searchedTimeRange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="errors">
|
||||
<ErrorsTab
|
||||
appliedConfig={appliedConfig}
|
||||
searchedTimeRange={searchedTimeRange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
{source?.kind !== 'trace' ? (
|
||||
<Group align="center" justify="center" h="300px">
|
||||
<Text c="gray">Please select a trace source</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Tabs
|
||||
mt="md"
|
||||
keepMounted={false}
|
||||
defaultValue="http"
|
||||
onChange={setTab}
|
||||
value={tab}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="http">HTTP Service</Tabs.Tab>
|
||||
<Tabs.Tab value="database">Database</Tabs.Tab>
|
||||
<Tabs.Tab value="errors">Errors</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="http">
|
||||
<HttpTab
|
||||
appliedConfig={appliedConfig}
|
||||
searchedTimeRange={searchedTimeRange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="database">
|
||||
<DatabaseTab
|
||||
appliedConfig={appliedConfig}
|
||||
searchedTimeRange={searchedTimeRange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="errors">
|
||||
<ErrorsTab
|
||||
appliedConfig={appliedConfig}
|
||||
searchedTimeRange={searchedTimeRange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,276 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import cx from 'classnames';
|
||||
import { ScrollArea, Skeleton, Stack } from '@mantine/core';
|
||||
import { useThrottledCallback, useThrottledValue } from '@mantine/hooks';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import { useSessionEvents } from './sessionUtils';
|
||||
import { useFormatTime } from './useFormatTime';
|
||||
import { formatmmss, getShortUrl } from './utils';
|
||||
|
||||
import styles from '../styles/SessionSubpanelV2.module.scss';
|
||||
|
||||
type SessionEvent = {
|
||||
id: string;
|
||||
sortKey: string;
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
eventSource: 'navigation' | 'chat' | 'network' | 'custom';
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: Date;
|
||||
formattedTimestamp: string;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
const EVENT_ROW_SOURCE_ICONS = {
|
||||
navigation: 'bi bi-geo-alt',
|
||||
chat: 'bi bi-chat-dots',
|
||||
network: 'bi bi-arrow-left-right',
|
||||
custom: 'bi bi-cursor',
|
||||
};
|
||||
|
||||
const EventRow = React.forwardRef(
|
||||
(
|
||||
{
|
||||
dataIndex,
|
||||
event,
|
||||
isHighlighted,
|
||||
onClick,
|
||||
onTimeClick,
|
||||
}: {
|
||||
dataIndex: number;
|
||||
event: SessionEvent;
|
||||
isHighlighted: boolean;
|
||||
onClick: VoidFunction;
|
||||
onTimeClick: VoidFunction;
|
||||
},
|
||||
ref: React.Ref<HTMLDivElement>,
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
data-index={dataIndex}
|
||||
ref={ref}
|
||||
className={cx(styles.eventRow, {
|
||||
[styles.eventRowError]: event.isError,
|
||||
[styles.eventRowSuccess]: event.isSuccess,
|
||||
[styles.eventRowHighlighted]: isHighlighted,
|
||||
})}
|
||||
>
|
||||
<div className={styles.eventRowIcon}>
|
||||
<i
|
||||
className={
|
||||
EVENT_ROW_SOURCE_ICONS[event.eventSource] || 'bi bi-terminal'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.eventRowContent} onClick={onClick}>
|
||||
<div className={styles.eventRowTitle}>
|
||||
{event.title}{' '}
|
||||
{event.duration > 0 && <span>{event.duration}ms</span>}
|
||||
</div>
|
||||
<div className={styles.eventRowDescription} title={event.description}>
|
||||
{event.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.eventRowTimestamp} onClick={onTimeClick}>
|
||||
<i className="bi bi-play-fill me-1 fs-8" />
|
||||
{event.formattedTimestamp}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const SessionEventList = ({
|
||||
config: { where, dateRange },
|
||||
onClick,
|
||||
onTimeClick,
|
||||
focus,
|
||||
minTs,
|
||||
showRelativeTime,
|
||||
eventsFollowPlayerPosition,
|
||||
}: {
|
||||
config: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
// highlightedResultId: string | undefined;
|
||||
focus: { ts: number; setBy: string } | undefined;
|
||||
minTs: number;
|
||||
showRelativeTime: boolean;
|
||||
onClick: (logId: string, sortKey: string) => void;
|
||||
onTimeClick: (ts: number) => void;
|
||||
eventsFollowPlayerPosition: boolean;
|
||||
}) => {
|
||||
const { events, isFetching: isSessionEventsFetching } = useSessionEvents({
|
||||
config: { where, dateRange },
|
||||
});
|
||||
|
||||
const formatTime = useFormatTime();
|
||||
|
||||
const rows = React.useMemo(() => {
|
||||
return (
|
||||
events?.map((event, i) => {
|
||||
const { startOffset, endOffset } = event;
|
||||
const tookMs = endOffset - startOffset;
|
||||
|
||||
const isHighlighted = false;
|
||||
|
||||
const url = event['http.url'];
|
||||
const statusCode = event['http.status_code'];
|
||||
const method = event['http.method'];
|
||||
const shortUrl = getShortUrl(url);
|
||||
|
||||
const isNetworkRequest =
|
||||
method != '' && method != null && url != null && url != '';
|
||||
|
||||
const errorMessage = event['error.message'];
|
||||
|
||||
const body = event['body'];
|
||||
const component = event['component'];
|
||||
const spanName = event['span_name'];
|
||||
const locationHref = event['location.href'];
|
||||
const otelLibraryName = event['otel.library.name'];
|
||||
const shortLocationHref = getShortUrl(locationHref);
|
||||
const isException =
|
||||
event['exception.group_id'] != '' &&
|
||||
event['exception.group_id'] != null;
|
||||
|
||||
const isCustomEvent = otelLibraryName === 'custom-action';
|
||||
const isNavigation =
|
||||
spanName === 'routeChange' || spanName === 'documentLoad';
|
||||
|
||||
const isError = event.severity_text === 'error' || statusCode > 499;
|
||||
|
||||
const isSuccess = !isError && statusCode < 400 && statusCode > 99;
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
sortKey: event.sort_key,
|
||||
isError,
|
||||
isSuccess,
|
||||
eventSource: isNavigation
|
||||
? 'navigation'
|
||||
: isNetworkRequest
|
||||
? 'network'
|
||||
: isCustomEvent
|
||||
? 'custom'
|
||||
: spanName === 'intercom.onShow'
|
||||
? 'chat'
|
||||
: 'log',
|
||||
title: isNavigation
|
||||
? `Navigated`
|
||||
: isException
|
||||
? 'Exception'
|
||||
: url.length > 0
|
||||
? `${statusCode} ${method}`
|
||||
: errorMessage != null && errorMessage.length > 0
|
||||
? 'console.error'
|
||||
: spanName === 'intercom.onShow'
|
||||
? 'Intercom Chat Opened'
|
||||
: isCustomEvent
|
||||
? spanName
|
||||
: component === 'console'
|
||||
? spanName
|
||||
: 'console.error',
|
||||
description: isNavigation
|
||||
? shortLocationHref
|
||||
: url.length > 0
|
||||
? shortUrl
|
||||
: errorMessage != null && errorMessage.length > 0
|
||||
? errorMessage
|
||||
: component === 'console'
|
||||
? body
|
||||
: '',
|
||||
timestamp: new Date(startOffset),
|
||||
formattedTimestamp: showRelativeTime
|
||||
? formatmmss(startOffset - minTs)
|
||||
: formatTime(startOffset, {
|
||||
format: 'time',
|
||||
}),
|
||||
duration: endOffset - startOffset,
|
||||
} as SessionEvent;
|
||||
}) ?? []
|
||||
);
|
||||
}, [events, showRelativeTime, minTs, formatTime]);
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// The virtualizer
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 36,
|
||||
});
|
||||
|
||||
// Sync scroll position to the DOM Player time
|
||||
const currentEventIndex = useThrottledValue(
|
||||
rows.findIndex(row => row.timestamp.getTime() >= (focus?.ts ?? 0)) - 1,
|
||||
500,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
rowVirtualizer &&
|
||||
currentEventIndex >= 0 &&
|
||||
eventsFollowPlayerPosition
|
||||
) {
|
||||
rowVirtualizer.scrollToIndex(currentEventIndex, {
|
||||
align: 'center',
|
||||
});
|
||||
}
|
||||
}, [currentEventIndex, eventsFollowPlayerPosition, rowVirtualizer]);
|
||||
|
||||
if (isSessionEventsFetching) {
|
||||
return (
|
||||
<Stack p="sm" gap="xs">
|
||||
<Skeleton height={36} />
|
||||
<Skeleton height={36} />
|
||||
<Skeleton height={36} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" scrollbarSize={4} viewportRef={parentRef}>
|
||||
<div className={styles.eventListContainer}>
|
||||
{/* The large inner element to hold all of the items */}
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Only the visible items in the virtualizer, manually positioned to be in view */}
|
||||
{rowVirtualizer.getVirtualItems().map(virtualItem => {
|
||||
const row = rows[virtualItem.index];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<EventRow
|
||||
event={row}
|
||||
dataIndex={virtualItem.index}
|
||||
isHighlighted={currentEventIndex === virtualItem.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
onClick={() => onClick(row.id, row.sortKey)}
|
||||
onTimeClick={() => onTimeClick(row.timestamp.getTime())}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import Drawer from 'react-modern-drawer';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
import api from './api';
|
||||
import SessionSubpanel from './SessionSubpanel';
|
||||
import { formatDistanceToNowStrictShort } from './utils';
|
||||
import { ZIndexContext } from './zIndex';
|
||||
|
||||
import 'react-modern-drawer/dist/index.css';
|
||||
|
||||
export default function SessionSidePanel({
|
||||
sessionId,
|
||||
dateRange,
|
||||
onClose,
|
||||
onPropertyAddClick,
|
||||
generateSearchUrl,
|
||||
generateChartUrl,
|
||||
zIndex = 100,
|
||||
}: {
|
||||
sessionId: string;
|
||||
dateRange: [Date, Date];
|
||||
onClose: () => void;
|
||||
onPropertyAddClick?: (name: string, value: string) => void;
|
||||
generateSearchUrl: (query?: string, timeRange?: [Date, Date]) => string;
|
||||
generateChartUrl: (config: {
|
||||
aggFn: string;
|
||||
field: string;
|
||||
groupBy: string[];
|
||||
}) => string;
|
||||
zIndex?: number;
|
||||
}) {
|
||||
// TODO: DRY with sessions page?
|
||||
const { data: tableData } = api.useSessions({
|
||||
startDate: dateRange[0],
|
||||
endDate: dateRange[1],
|
||||
q: `rum_session_id: "${sessionId}"`,
|
||||
});
|
||||
|
||||
const session = tableData?.data[0];
|
||||
|
||||
// Keep track of sub-drawers so we can disable closing this root drawer
|
||||
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
|
||||
|
||||
useHotkeys(
|
||||
['esc'],
|
||||
() => {
|
||||
onClose();
|
||||
},
|
||||
{
|
||||
enabled: subDrawerOpen === false,
|
||||
},
|
||||
);
|
||||
|
||||
// console.log({ logId: sessionId, subDrawerOpen });
|
||||
const maxTime =
|
||||
session != null ? new Date(session?.maxTimestamp) : new Date();
|
||||
// const minTime =
|
||||
// session != null ? new Date(session?.['min_timestamp']) : new Date();
|
||||
const timeAgo = formatDistanceToNowStrictShort(maxTime);
|
||||
// const durationStr = new Date(maxTime.getTime() - minTime.getTime())
|
||||
// .toISOString()
|
||||
// .slice(11, 19);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
customIdSuffix={`session-side-panel-${sessionId}`}
|
||||
duration={0}
|
||||
overlayOpacity={0.5}
|
||||
open={sessionId != null}
|
||||
onClose={() => {
|
||||
if (!subDrawerOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
size={'82vw'}
|
||||
style={{ background: '#0F1216' }}
|
||||
className="border-start border-dark"
|
||||
zIndex={zIndex}
|
||||
>
|
||||
<ZIndexContext.Provider value={zIndex}>
|
||||
<div className="d-flex flex-column h-100">
|
||||
<div>
|
||||
<div className="p-3 d-flex align-items-center justify-content-between border-bottom border-dark">
|
||||
<div style={{ width: '50%', maxWidth: 500 }}>
|
||||
{session?.userEmail || `Anonymous Session ${sessionId}`}
|
||||
<div className="text-muted fs-8 mt-1">
|
||||
<span>Last active {timeAgo} ago</span>
|
||||
<span className="mx-2">·</span>
|
||||
{Number.parseInt(session?.errorCount ?? '0') > 0 ? (
|
||||
<>
|
||||
<span className="text-danger fs-8">
|
||||
{session?.errorCount} Errors
|
||||
</span>
|
||||
<span className="mx-2">·</span>
|
||||
</>
|
||||
) : null}
|
||||
<span>{session?.sessionCount} Events</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex">
|
||||
<CopyToClipboard
|
||||
text={window.location.href}
|
||||
onCopy={() => {
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: 'Copied link to clipboard',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="dark"
|
||||
className="text-muted-hover mx-2 d-flex align-items-center fs-8"
|
||||
size="sm"
|
||||
>
|
||||
<i className="bi bi-link-45deg me-2 fs-7.5" />
|
||||
Share Session
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
<Button
|
||||
variant="dark"
|
||||
className="text-muted-hover d-flex align-items-center"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<i className="bi bi-x-lg" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{sessionId != null ? (
|
||||
<SessionSubpanel
|
||||
start={dateRange[0]}
|
||||
end={dateRange[1]}
|
||||
rumSessionId={sessionId}
|
||||
onPropertyAddClick={onPropertyAddClick}
|
||||
generateSearchUrl={generateSearchUrl}
|
||||
generateChartUrl={generateChartUrl}
|
||||
setDrawerOpen={setSubDrawerOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ZIndexContext.Provider>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,478 +0,0 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
|
||||
import DOMPlayer from './DOMPlayer';
|
||||
import LogSidePanel from './LogSidePanel';
|
||||
import Playbar from './Playbar';
|
||||
import SearchInput from './SearchInput';
|
||||
import { SessionEventList } from './SessionEventList';
|
||||
import { FormatTime } from './useFormatTime';
|
||||
import { formatmmss, useLocalStorage, usePrevious } from './utils';
|
||||
|
||||
import styles from '../styles/SessionSubpanelV2.module.scss';
|
||||
|
||||
const MemoPlaybar = memo(Playbar);
|
||||
|
||||
export default function SessionSubpanel({
|
||||
onPropertyAddClick,
|
||||
generateChartUrl,
|
||||
generateSearchUrl,
|
||||
setDrawerOpen,
|
||||
rumSessionId,
|
||||
start,
|
||||
end,
|
||||
initialTs,
|
||||
}: {
|
||||
generateSearchUrl: (query?: string, timeRange?: [Date, Date]) => string;
|
||||
generateChartUrl: (config: {
|
||||
aggFn: string;
|
||||
field: string;
|
||||
groupBy: string[];
|
||||
}) => string;
|
||||
|
||||
onPropertyAddClick?: (name: string, value: string) => void;
|
||||
setDrawerOpen: (open: boolean) => void;
|
||||
rumSessionId: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
initialTs?: number;
|
||||
}) {
|
||||
const [selectedLog, setSelectedLog] = useState<
|
||||
| {
|
||||
id: string;
|
||||
sortKey: string;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
||||
// Without portaling the nested drawer close overlay will not render properly
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
containerRef.current = document.createElement('div');
|
||||
|
||||
if (containerRef.current) {
|
||||
document.body.appendChild(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (containerRef.current) {
|
||||
document.body.removeChild(containerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const portaledPanel =
|
||||
containerRef.current != null
|
||||
? ReactDOM.createPortal(
|
||||
<LogSidePanel
|
||||
key={selectedLog?.id}
|
||||
logId={selectedLog?.id}
|
||||
sortKey={selectedLog?.sortKey}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
setSelectedLog(undefined);
|
||||
}}
|
||||
onPropertyAddClick={onPropertyAddClick}
|
||||
generateSearchUrl={generateSearchUrl}
|
||||
generateChartUrl={generateChartUrl}
|
||||
isNestedPanel
|
||||
/>,
|
||||
containerRef.current,
|
||||
)
|
||||
: null;
|
||||
|
||||
const [tsQuery, setTsQuery] = useQueryState(
|
||||
'ts',
|
||||
parseAsInteger.withOptions({ history: 'replace' }),
|
||||
);
|
||||
const prevTsQuery = usePrevious(tsQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevTsQuery == null && tsQuery != null) {
|
||||
_setFocus({ ts: tsQuery, setBy: 'url' });
|
||||
}
|
||||
}, [prevTsQuery, tsQuery]);
|
||||
|
||||
const debouncedSetTsQuery = useRef(
|
||||
throttle(async (ts: number) => {
|
||||
setTsQuery(ts);
|
||||
}, 1000),
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setTsQuery(null);
|
||||
};
|
||||
}, [setTsQuery]);
|
||||
|
||||
const [focus, _setFocus] = useState<
|
||||
{ ts: number; setBy: string } | undefined
|
||||
>(
|
||||
initialTs != null
|
||||
? {
|
||||
ts: initialTs,
|
||||
setBy: 'parent',
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const setFocus = useCallback(
|
||||
(focus: { ts: number; setBy: string }) => {
|
||||
if (focus.setBy === 'player') {
|
||||
debouncedSetTsQuery(focus.ts);
|
||||
} else {
|
||||
setTsQuery(focus.ts);
|
||||
}
|
||||
_setFocus(focus);
|
||||
},
|
||||
[_setFocus, setTsQuery, debouncedSetTsQuery],
|
||||
);
|
||||
const [playerState, setPlayerState] = useState<'paused' | 'playing'>(
|
||||
'paused',
|
||||
);
|
||||
|
||||
// Event Filter Input =========================
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [_inputQuery, setInputQuery] = useState<string | undefined>(undefined);
|
||||
const inputQuery = _inputQuery ?? '';
|
||||
const [_searchedQuery, setSearchedQuery] = useQueryState('session_q', {
|
||||
history: 'push',
|
||||
});
|
||||
|
||||
// Hacky way to set the input query when we search
|
||||
useEffect(() => {
|
||||
if (_searchedQuery != null && _inputQuery == null) {
|
||||
setInputQuery(_searchedQuery);
|
||||
}
|
||||
}, [_searchedQuery, _inputQuery]);
|
||||
|
||||
// Allows us to determine if the user has changed the search query
|
||||
const searchedQuery = _searchedQuery ?? '';
|
||||
|
||||
// Clear search query when we close the panel
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setSearchedQuery(null, { history: 'replace' });
|
||||
};
|
||||
}, [setSearchedQuery]);
|
||||
|
||||
// Focused Tab ===============================
|
||||
const [tab, setTab] = useState<string>('highlighted');
|
||||
|
||||
// Playbar ====================================
|
||||
const [showRelativeTime, setShowRelativeTime] = useLocalStorage(
|
||||
'hdx-session-subpanel-show-relative-time',
|
||||
false,
|
||||
);
|
||||
const [playerSpeed, setPlayerSpeed] = useLocalStorage(
|
||||
'hdx-session-subpanel-player-speed',
|
||||
1,
|
||||
);
|
||||
const [skipInactive, setSkipInactive] = useLocalStorage(
|
||||
'hdx-session-subpanel-skip-inactive',
|
||||
true,
|
||||
);
|
||||
const [eventsFollowPlayerPosition, setEventsFollowPlayerPosition] =
|
||||
useLocalStorage('hdx-session-subpanel-events-follow-player-position', true);
|
||||
|
||||
// XXX: This is a hack for the hack, we offset start/end by 4 hours
|
||||
// to ensure we capture all rrweb events on query. However, we
|
||||
// need to un-offset the time for the playback slider to show sane values.
|
||||
// these values get updated by the DOM player when events are loaded
|
||||
const [playerStartTs, setPlayerStartTs] = useState<number>(
|
||||
start.getTime() + 4 * 60 * 60 * 1000,
|
||||
);
|
||||
const [playerEndTs, setPlayerEndTs] = useState<number>(
|
||||
end.getTime() - 4 * 60 * 60 * 1000,
|
||||
);
|
||||
const playbackRange = useMemo(() => {
|
||||
return [new Date(playerStartTs), new Date(playerEndTs)] as [Date, Date];
|
||||
}, [playerStartTs, playerEndTs]);
|
||||
|
||||
const playBarEventsConfig = useMemo(
|
||||
() => ({
|
||||
where: `rum_session_id:"${rumSessionId}" (http.status_code:>299 OR component:"error" OR span_name:"routeChange" OR span_name:"documentLoad" OR span_name:"intercom.onShow" OR otel.library.name:"custom-action" OR exception.group_id:*) ${searchedQuery}`,
|
||||
dateRange: [start, end] as [Date, Date],
|
||||
}),
|
||||
[rumSessionId, start, end, searchedQuery],
|
||||
);
|
||||
const [playerFullWidth, setPlayerFullWidth] = useState(false);
|
||||
|
||||
const sessionEventListConfig = useMemo(
|
||||
() => ({
|
||||
where: `rum_session_id:"${rumSessionId}" (http.status_code:>${
|
||||
tab === 'events' ? '0' : '299'
|
||||
} OR component:"error" ${
|
||||
tab === 'events' ? 'OR component:"console"' : ''
|
||||
} OR span_name:"routeChange" OR span_name:"documentLoad" OR span_name:"intercom.onShow" OR otel.library.name:"custom-action" OR exception.group_id:*) ${searchedQuery}`,
|
||||
dateRange: [start, end] as [Date, Date],
|
||||
}),
|
||||
[rumSessionId, start, end, tab, searchedQuery],
|
||||
);
|
||||
|
||||
const handleSetPlayerSpeed = useCallback(() => {
|
||||
if (playerSpeed == 1) {
|
||||
setPlayerSpeed(2);
|
||||
} else if (playerSpeed == 2) {
|
||||
setPlayerSpeed(4);
|
||||
} else if (playerSpeed == 4) {
|
||||
setPlayerSpeed(8);
|
||||
} else if (playerSpeed == 8) {
|
||||
setPlayerSpeed(1);
|
||||
}
|
||||
}, [playerSpeed]);
|
||||
|
||||
const minTs = playbackRange[0].getTime();
|
||||
const maxTs = playbackRange[1].getTime();
|
||||
|
||||
const togglePlayerState = useCallback(() => {
|
||||
setPlayerState(state => (state === 'playing' ? 'paused' : 'playing'));
|
||||
}, [setPlayerState]);
|
||||
|
||||
const skipBackward = useCallback(() => {
|
||||
setFocus({
|
||||
ts: Math.max((focus?.ts ?? minTs) - 15000, minTs),
|
||||
setBy: 'skip-backward',
|
||||
});
|
||||
}, [setFocus, focus?.ts, minTs]);
|
||||
|
||||
const skipForward = useCallback(() => {
|
||||
setFocus({
|
||||
ts: Math.min((focus?.ts ?? minTs) + 15000, maxTs),
|
||||
setBy: 'skip-forward',
|
||||
});
|
||||
}, [setFocus, focus?.ts, minTs, maxTs]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{selectedLog != null && portaledPanel}
|
||||
<div className={cx(styles.eventList, { 'd-none': playerFullWidth })}>
|
||||
<div className={styles.eventListHeader}>
|
||||
<form
|
||||
style={{ zIndex: 100, width: '100%' }}
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
setSearchedQuery(inputQuery);
|
||||
}}
|
||||
>
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
value={inputQuery}
|
||||
onChange={value => setInputQuery(value)}
|
||||
onSearch={() => {}}
|
||||
placeholder="Filter events"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 0,
|
||||
height: 0,
|
||||
border: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
<Group gap={6}>
|
||||
<SegmentedControl
|
||||
flex={1}
|
||||
radius="md"
|
||||
bg="gray.9"
|
||||
color="gray.7"
|
||||
size="xs"
|
||||
data={[
|
||||
{ value: 'highlighted', label: 'Highlighted' },
|
||||
{ value: 'events', label: 'All Events' },
|
||||
]}
|
||||
value={tab}
|
||||
onChange={value => setTab(value)}
|
||||
/>
|
||||
<Tooltip label="Sync with player position" color="gray">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant={eventsFollowPlayerPosition ? 'filled' : 'subtle'}
|
||||
color={eventsFollowPlayerPosition ? 'gray' : 'gray.8'}
|
||||
onClick={() =>
|
||||
setEventsFollowPlayerPosition(!eventsFollowPlayerPosition)
|
||||
}
|
||||
>
|
||||
<i className="bi bi-chevron-bar-contract fs-6" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<SessionEventList
|
||||
eventsFollowPlayerPosition={eventsFollowPlayerPosition}
|
||||
config={sessionEventListConfig}
|
||||
onClick={useCallback(
|
||||
(id: any, sortKey: any) => {
|
||||
setDrawerOpen(true);
|
||||
setSelectedLog({ id, sortKey });
|
||||
},
|
||||
[setDrawerOpen, setSelectedLog],
|
||||
)}
|
||||
focus={focus}
|
||||
onTimeClick={useCallback(
|
||||
ts => {
|
||||
setFocus({ ts, setBy: 'timeline' });
|
||||
},
|
||||
[setFocus],
|
||||
)}
|
||||
minTs={minTs}
|
||||
showRelativeTime={showRelativeTime}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.player}>
|
||||
<DOMPlayer
|
||||
playerState={playerState}
|
||||
setPlayerState={setPlayerState}
|
||||
focus={focus}
|
||||
setPlayerTime={useCallback(
|
||||
ts => {
|
||||
if (focus?.setBy !== 'player' || focus?.ts !== ts) {
|
||||
setFocus({ ts, setBy: 'player' });
|
||||
}
|
||||
},
|
||||
[focus, setFocus],
|
||||
)}
|
||||
config={{
|
||||
sessionId: rumSessionId,
|
||||
dateRange: [start, end],
|
||||
}}
|
||||
playerSpeed={playerSpeed}
|
||||
skipInactive={skipInactive}
|
||||
setPlayerStartTimestamp={setPlayerStartTs}
|
||||
setPlayerEndTimestamp={setPlayerEndTs}
|
||||
setPlayerFullWidth={setPlayerFullWidth}
|
||||
playerFullWidth={playerFullWidth}
|
||||
resizeKey={`${playerFullWidth}`}
|
||||
/>
|
||||
|
||||
<div className={styles.playerPlaybar}>
|
||||
<MemoPlaybar
|
||||
playerState={playerState}
|
||||
setPlayerState={setPlayerState}
|
||||
focus={focus}
|
||||
setFocus={setFocus}
|
||||
playbackRange={playbackRange}
|
||||
eventsConfig={playBarEventsConfig}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.playerToolbar}>
|
||||
<div className={styles.playerTimestamp}>
|
||||
<Tooltip label="Toggle relative time" color="gray">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setShowRelativeTime(!showRelativeTime)}
|
||||
size="compact-xs"
|
||||
>
|
||||
{showRelativeTime ? (
|
||||
<>
|
||||
{formatmmss((focus?.ts ?? 0) - minTs)}
|
||||
<span className="fw-normal text-slate-300 ms-2">
|
||||
{' / '}
|
||||
{formatmmss(maxTs - minTs)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<FormatTime value={focus?.ts || minTs} format="time" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Group align="center" justify="center" gap="xs">
|
||||
<Tooltip label="Go 15 seconds back" color="gray">
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="gray.8"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={skipBackward}
|
||||
disabled={(focus?.ts || 0) <= minTs}
|
||||
>
|
||||
<i className="bi bi-arrow-counterclockwise fs-6" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={playerState === 'playing' ? 'Pause' : 'Play'}
|
||||
color="gray"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="gray.8"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
onClick={togglePlayerState}
|
||||
>
|
||||
<i
|
||||
className={`bi fs-4 ${
|
||||
playerState === 'paused' ? 'bi-play-fill' : 'bi-pause-fill'
|
||||
}`}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Skip 15 seconds" color="gray">
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="gray.8"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={skipForward}
|
||||
disabled={(focus?.ts || 0) >= maxTs}
|
||||
>
|
||||
<i className="bi bi-arrow-clockwise fs-6" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Group align="center" justify="flex-end" gap="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="gray"
|
||||
variant="light"
|
||||
fw="normal"
|
||||
rightSection={
|
||||
<i
|
||||
className={`bi ${
|
||||
skipInactive ? 'bi-toggle-off' : 'bi-toggle-on'
|
||||
} fs-6 pe-1`}
|
||||
/>
|
||||
}
|
||||
onClick={() => setSkipInactive(!skipInactive)}
|
||||
>
|
||||
Skip Idle
|
||||
<Divider orientation="vertical" ml="sm" />
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="gray"
|
||||
variant="light"
|
||||
fw="normal"
|
||||
rightSection={
|
||||
<span className="fw-bold pe-1">{playerSpeed}x</span>
|
||||
}
|
||||
onClick={handleSetPlayerSpeed}
|
||||
>
|
||||
Speed
|
||||
<Divider orientation="vertical" ml="sm" />
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,532 +0,0 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { sub } from 'date-fns';
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
import { NumberParam } from 'serialize-query-params';
|
||||
import {
|
||||
StringParam,
|
||||
useQueryParam,
|
||||
useQueryParams,
|
||||
withDefault,
|
||||
} from 'use-query-params';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import { TimePicker } from '@/components/TimePicker';
|
||||
|
||||
import api from './api';
|
||||
import Dropdown from './Dropdown';
|
||||
import { withAppNav } from './layout';
|
||||
import SearchInput from './SearchInput';
|
||||
import SessionSidePanel from './SessionSidePanel';
|
||||
import { parseTimeQuery, useTimeQuery } from './timeQuery';
|
||||
import { FormatTime } from './useFormatTime';
|
||||
import { formatDistanceToNowStrictShort } from './utils';
|
||||
|
||||
function SessionCard({
|
||||
email,
|
||||
maxTime,
|
||||
minTime,
|
||||
numErrors,
|
||||
numEvents,
|
||||
onClick,
|
||||
sessionId,
|
||||
teamId,
|
||||
teamName,
|
||||
userName,
|
||||
}: {
|
||||
email: string;
|
||||
maxTime: Date;
|
||||
minTime: Date;
|
||||
numErrors: number;
|
||||
numEvents: number;
|
||||
onClick: () => void;
|
||||
sessionId: string;
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
userName: string;
|
||||
}) {
|
||||
const timeAgo = formatDistanceToNowStrictShort(maxTime);
|
||||
const durationStr = new Date(maxTime.getTime() - minTime.getTime())
|
||||
.toISOString()
|
||||
.slice(11, 19);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-hdx-dark rounded p-3 d-flex align-items-center justify-content-between text-white-hover-success-trigger"
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
style={{ width: '50%', maxWidth: 500 }}
|
||||
className="child-hover-trigger"
|
||||
>
|
||||
{email || `Anonymous Session ${sessionId}`}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted fs-8">{numEvents} Events</div>
|
||||
{numErrors > 0 && (
|
||||
<div className="text-danger fs-8">{numErrors} Errors</div>
|
||||
)}
|
||||
<div className="text-muted fs-8">Duration {durationStr}</div>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<div>Last active {timeAgo} ago</div>
|
||||
<div className="text-muted fs-8 mt-1">
|
||||
Started on <FormatTime value={minTime} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionCardList({
|
||||
config: { where, dateRange },
|
||||
onClick,
|
||||
}: {
|
||||
config: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
onClick: (sessionId: string, dateRange: [Date, Date]) => void;
|
||||
}) {
|
||||
const { data: tableData, isLoading: isTableDataLoading } = api.useSessions({
|
||||
startDate: dateRange[0],
|
||||
endDate: dateRange[1],
|
||||
q: where,
|
||||
});
|
||||
|
||||
const sessions = tableData?.data ?? [];
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The virtualizer
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: sessions.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 86,
|
||||
paddingEnd: 16,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{isTableDataLoading === true && (
|
||||
<div className="text-center mt-8">
|
||||
<div
|
||||
className="spinner-border me-2"
|
||||
role="status"
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
Searching sessions...
|
||||
</div>
|
||||
)}
|
||||
{!isTableDataLoading && sessions.length === 0 && (
|
||||
<div className="text-center align-items-center justify-content-center my-3">
|
||||
No results found.
|
||||
<div className="text-muted mt-3">
|
||||
Try checking the query explainer in the search bar if there are any
|
||||
search syntax issues.
|
||||
</div>
|
||||
<div className="text-muted mt-3">
|
||||
Add new data sources by setting up a HyperDX integration.
|
||||
</div>
|
||||
<Button
|
||||
variant="outline-success"
|
||||
className="fs-7 mt-3"
|
||||
target="_blank"
|
||||
href="/docs/install/browser"
|
||||
>
|
||||
Install HyperDX Browser Integration
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={parentRef}
|
||||
style={{
|
||||
height: `100%`,
|
||||
overflow: 'auto', // Make it scroll!
|
||||
}}
|
||||
>
|
||||
{/* The large inner element to hold all of the items */}
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Only the visible items in the virtualizer, manually positioned to be in view */}
|
||||
{rowVirtualizer.getVirtualItems().map(virtualItem => {
|
||||
const row = sessions[virtualItem.index];
|
||||
|
||||
const {
|
||||
errorCount,
|
||||
maxTimestamp,
|
||||
minTimestamp,
|
||||
sessionCount,
|
||||
sessionId,
|
||||
teamId,
|
||||
teamName,
|
||||
userEmail,
|
||||
userName,
|
||||
} = row;
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
data-index={virtualItem.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<SessionCard
|
||||
sessionId={sessionId}
|
||||
email={userEmail}
|
||||
userName={userName}
|
||||
teamName={teamName}
|
||||
teamId={teamId}
|
||||
numEvents={Number(sessionCount)}
|
||||
numErrors={Number(errorCount)}
|
||||
maxTime={new Date(maxTimestamp)}
|
||||
minTime={new Date(minTimestamp)}
|
||||
onClick={() => {
|
||||
onClick(sessionId, [
|
||||
sub(new Date(minTimestamp), { hours: 4 }),
|
||||
sub(new Date(maxTimestamp), { hours: -4 }),
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: This is a hack to set the default time range
|
||||
const defaultTimeRange = parseTimeQuery('Past 1h', false);
|
||||
export default function SessionsPage() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [inputQuery, setInputQuery] = useState<string>('');
|
||||
|
||||
const [_searchedQuery, setSearchedQuery] = useQueryParam(
|
||||
'q',
|
||||
withDefault(StringParam, undefined),
|
||||
{
|
||||
updateType: 'pushIn',
|
||||
// Workaround for qparams not being set properly: https://github.com/pbeshai/use-query-params/issues/233
|
||||
enableBatching: true,
|
||||
},
|
||||
);
|
||||
// Allows us to determine if the user has changed the search query
|
||||
const searchedQuery = _searchedQuery ?? '';
|
||||
|
||||
// TODO: Set displayed query to qparam... in a less bad way?
|
||||
useEffect(() => {
|
||||
setInputQuery(searchedQuery);
|
||||
}, [searchedQuery]);
|
||||
|
||||
const {
|
||||
searchedTimeRange,
|
||||
displayedTimeInputValue,
|
||||
setDisplayedTimeInputValue,
|
||||
onSearch,
|
||||
} = useTimeQuery({
|
||||
defaultValue: 'Past 1h',
|
||||
defaultTimeRange: [
|
||||
defaultTimeRange?.[0]?.getTime() ?? -1,
|
||||
defaultTimeRange?.[1]?.getTime() ?? -1,
|
||||
],
|
||||
});
|
||||
|
||||
const [startDate, endDate] = searchedTimeRange;
|
||||
|
||||
const [selectedSessionQuery, setSelectedSessionQuery] = useQueryParams(
|
||||
{
|
||||
sid: withDefault(StringParam, undefined),
|
||||
sfrom: withDefault(NumberParam, undefined),
|
||||
sto: withDefault(NumberParam, undefined),
|
||||
},
|
||||
{
|
||||
updateType: 'pushIn',
|
||||
enableBatching: true,
|
||||
},
|
||||
);
|
||||
|
||||
const selectedSession = useMemo(() => {
|
||||
if (selectedSessionQuery.sid == null) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: selectedSessionQuery.sid,
|
||||
dateRange: [
|
||||
new Date(selectedSessionQuery.sfrom ?? 0),
|
||||
new Date(selectedSessionQuery.sto ?? 0),
|
||||
] as [Date, Date],
|
||||
};
|
||||
}, [selectedSessionQuery]);
|
||||
const setSelectedSession = useCallback(
|
||||
(
|
||||
session:
|
||||
| {
|
||||
id: string;
|
||||
dateRange: [Date, Date];
|
||||
}
|
||||
| undefined,
|
||||
) => {
|
||||
if (session == null) {
|
||||
setSelectedSessionQuery({
|
||||
sid: undefined,
|
||||
sfrom: undefined,
|
||||
sto: undefined,
|
||||
});
|
||||
} else {
|
||||
setSelectedSessionQuery({
|
||||
sid: session.id,
|
||||
sfrom: session.dateRange[0].getTime(),
|
||||
sto: session.dateRange[1].getTime(),
|
||||
});
|
||||
}
|
||||
},
|
||||
[setSelectedSessionQuery],
|
||||
);
|
||||
|
||||
const generateSearchUrl = useCallback(
|
||||
(newQuery?: string, newTimeRange?: [Date, Date]) => {
|
||||
const qparams = new URLSearchParams({
|
||||
q: newQuery ?? searchedQuery,
|
||||
from: newTimeRange
|
||||
? newTimeRange[0].getTime().toString()
|
||||
: startDate.getTime().toString(),
|
||||
to: newTimeRange
|
||||
? newTimeRange[1].getTime().toString()
|
||||
: endDate.getTime().toString(),
|
||||
});
|
||||
return `/search?${qparams.toString()}`;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const generateChartUrl = useCallback(
|
||||
({ aggFn, field, where, groupBy }: any) => {
|
||||
return `/chart?series=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
type: 'time',
|
||||
aggFn,
|
||||
field,
|
||||
where,
|
||||
groupBy,
|
||||
}),
|
||||
)}`;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const [isEmailFilterExpanded, setIsEmailFilterExpanded] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="SessionsPage">
|
||||
<Head>
|
||||
<title>Client Sessions - HyperDX</title>
|
||||
</Head>
|
||||
{selectedSession != null && (
|
||||
<SessionSidePanel
|
||||
key={`session-page-session-side-panel-${selectedSession.id}`}
|
||||
sessionId={selectedSession.id}
|
||||
dateRange={selectedSession.dateRange}
|
||||
onClose={() => {
|
||||
setSelectedSession(undefined);
|
||||
}}
|
||||
generateSearchUrl={generateSearchUrl}
|
||||
generateChartUrl={({ aggFn, field, groupBy }) =>
|
||||
generateChartUrl({
|
||||
aggFn,
|
||||
field,
|
||||
groupBy,
|
||||
where: `rum_session_id:"${selectedSession.id}"`,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="d-flex flex-column flex-grow-1 px-3 pt-3">
|
||||
<div className="d-flex justify-content-between">
|
||||
<div className="fs-5 mb-3 fw-500">Client Sessions</div>
|
||||
<div className="flex-grow-1" style={{ maxWidth: 350 }}>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
onSearch(displayedTimeInputValue);
|
||||
}}
|
||||
>
|
||||
<TimePicker
|
||||
inputValue={displayedTimeInputValue}
|
||||
setInputValue={setDisplayedTimeInputValue}
|
||||
onSearch={range => {
|
||||
onSearch(range);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
value="Search Time Range"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
border: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="d-flex align-items-center me-2">
|
||||
<span
|
||||
className="rounded fs-8 text-nowrap border border-dark p-2"
|
||||
style={{
|
||||
borderTopRightRadius: '0 !important',
|
||||
borderBottomRightRadius: '0 !important',
|
||||
}}
|
||||
title="Filters"
|
||||
>
|
||||
<i className="bi bi-funnel"></i>
|
||||
</span>{' '}
|
||||
<div className="d-flex align-items-center w-100 flex-grow-1">
|
||||
<Button
|
||||
variant="dark"
|
||||
type="button"
|
||||
className="text-muted-hover d-flex align-items-center fs-8 p-2"
|
||||
onClick={() => setIsEmailFilterExpanded(v => !v)}
|
||||
style={
|
||||
isEmailFilterExpanded
|
||||
? {
|
||||
borderRadius: 0,
|
||||
}
|
||||
: {
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
Email
|
||||
</Button>
|
||||
{isEmailFilterExpanded && (
|
||||
<form
|
||||
className="d-flex"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
|
||||
// TODO: Transition to react-hook-form or controlled state
|
||||
// @ts-ignore
|
||||
const value = e.target.value.value;
|
||||
// @ts-ignore
|
||||
const op = e.target.op.value;
|
||||
|
||||
setSearchedQuery(
|
||||
(
|
||||
inputQuery +
|
||||
(op === 'is'
|
||||
? ` userEmail:"${value}"`
|
||||
: op === 'is_not'
|
||||
? ` -userEmail:"${value}"`
|
||||
: ` userEmail:${value}`)
|
||||
).trim(),
|
||||
);
|
||||
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: 'Added filter to search query',
|
||||
});
|
||||
inputRef.current?.focus();
|
||||
|
||||
// @ts-ignore
|
||||
e.target.value.value = '';
|
||||
}}
|
||||
>
|
||||
<Dropdown
|
||||
name="op"
|
||||
className="border border-dark fw-normal fs-8 p-2"
|
||||
style={{ borderRadius: 0, minWidth: 100 }}
|
||||
options={[
|
||||
{
|
||||
value: 'contains',
|
||||
text: 'contains',
|
||||
},
|
||||
{ value: 'is', text: 'is' },
|
||||
{ value: 'is_not', text: 'is not' },
|
||||
]}
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Form.Control
|
||||
type="text"
|
||||
id="value"
|
||||
name="value"
|
||||
className="fs-8 p-2 w-100"
|
||||
style={{ borderRadius: 0 }}
|
||||
placeholder="value"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
className="text-muted-hover d-flex align-items-center fs-8 p-2"
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
className="d-flex align-items-center flex-grow-1"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
setSearchedQuery(inputQuery);
|
||||
}}
|
||||
>
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
value={inputQuery}
|
||||
onChange={value => setInputQuery(value)}
|
||||
onSearch={() => {}}
|
||||
placeholder="Search for a session by email, id..."
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
border: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div style={{ minHeight: 0 }} className="mt-4">
|
||||
<SessionCardList
|
||||
onClick={(sessionId, dateRange) => {
|
||||
setSelectedSession({ id: sessionId, dateRange });
|
||||
}}
|
||||
config={{
|
||||
where: searchedQuery,
|
||||
dateRange: searchedTimeRange as [Date, Date],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SessionsPage.getLayout = withAppNav;
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { Card, Flex, Text } from '@mantine/core';
|
||||
|
||||
import api from './api';
|
||||
import { LogTableWithSidePanel } from './LogTableWithSidePanel';
|
||||
|
||||
export default function SlowestEventsTile({
|
||||
dateRange,
|
||||
height,
|
||||
scopeWhereQuery,
|
||||
title,
|
||||
}: {
|
||||
dateRange: [Date, Date];
|
||||
height: number;
|
||||
scopeWhereQuery: (where: string) => string;
|
||||
title: React.ReactNode;
|
||||
}) {
|
||||
const { data, isError, isLoading } = api.useMultiSeriesChart({
|
||||
series: [
|
||||
{
|
||||
type: 'table',
|
||||
aggFn: 'p95',
|
||||
field: 'duration',
|
||||
groupBy: [],
|
||||
table: 'logs',
|
||||
where: scopeWhereQuery(''),
|
||||
},
|
||||
],
|
||||
endDate: dateRange[1] ?? new Date(),
|
||||
startDate: dateRange[0] ?? new Date(),
|
||||
seriesReturnType: 'column',
|
||||
});
|
||||
|
||||
const p95 = data?.data?.[0]?.['series_0.data'];
|
||||
|
||||
const roundedP95 = Math.round(p95 ?? 0);
|
||||
|
||||
return (
|
||||
<Card p="md">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
<Flex justify="space-between">
|
||||
{title}
|
||||
<Text size="xs" c="dark.2">
|
||||
(Slower than {roundedP95}ms)
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card.Section>
|
||||
<Card.Section p="md" py="sm" h={height}>
|
||||
{isLoading ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
Calculating...
|
||||
</div>
|
||||
) : isError || p95 == null ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
Error Calculating
|
||||
</div>
|
||||
) : (
|
||||
<LogTableWithSidePanel
|
||||
config={{
|
||||
dateRange,
|
||||
where: scopeWhereQuery(`duration:>${roundedP95}`),
|
||||
columns: ['duration'],
|
||||
}}
|
||||
isLive={false}
|
||||
onPropertySearchClick={() => {}}
|
||||
/>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -627,6 +627,7 @@ export default function EditTimeChartForm({
|
|||
style={{ minHeight: 400 }}
|
||||
>
|
||||
<DBTimeChart
|
||||
sourceId={sourceId}
|
||||
config={queriedConfig}
|
||||
onTimeRangeSelect={onTimeRangeSelect}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,128 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Paper, Text } from '@mantine/core';
|
||||
import { usePrevious } from '@mantine/hooks';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import router from 'next/router';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
import get from 'lodash/get';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Input,
|
||||
Menu,
|
||||
Paper,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
import { TSource } from '@/commonTypes';
|
||||
import HyperJson from '@/components/HyperJson';
|
||||
import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson';
|
||||
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
||||
import { getEventBody } from '@/source';
|
||||
import { mergePath } from '@/utils';
|
||||
|
||||
import { RowSidePanelContext } from './DBRowSidePanel';
|
||||
|
||||
function filterObjectRecursively(obj: any, filter: string): any {
|
||||
if (typeof obj !== 'object' || obj === null || filter === '') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
key.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(typeof value === 'string' &&
|
||||
value.toLowerCase().includes(filter.toLowerCase()))
|
||||
) {
|
||||
result[key] = value;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const v = filterObjectRecursively(value, filter);
|
||||
// Skip empty objects
|
||||
if (Object.keys(v).length > 0) {
|
||||
result[key] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const viewerOptionsAtom = atomWithStorage('hdx_json_viewer_options', {
|
||||
normallyExpanded: true,
|
||||
lineWrap: true,
|
||||
tabulate: true,
|
||||
});
|
||||
|
||||
function HyperJsonMenu() {
|
||||
const [jsonOptions, setJsonOptions] = useAtom(viewerOptionsAtom);
|
||||
|
||||
return (
|
||||
<Menu width={240} withinPortal={false}>
|
||||
<Menu.Target>
|
||||
<ActionIcon size="md" variant="filled" color="gray">
|
||||
<i className="bi bi-gear" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label lh={1} py={6}>
|
||||
Properties view options
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
onClick={() =>
|
||||
setJsonOptions({
|
||||
...jsonOptions,
|
||||
normallyExpanded: !jsonOptions.normallyExpanded,
|
||||
})
|
||||
}
|
||||
lh="1"
|
||||
py={8}
|
||||
rightSection={
|
||||
jsonOptions.normallyExpanded ? (
|
||||
<i className="ps-2 bi bi-check2" />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
Expand all properties
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() =>
|
||||
setJsonOptions({
|
||||
...jsonOptions,
|
||||
lineWrap: !jsonOptions.lineWrap,
|
||||
})
|
||||
}
|
||||
lh="1"
|
||||
py={8}
|
||||
rightSection={
|
||||
jsonOptions.lineWrap ? <i className="ps-2 bi bi-check2" /> : null
|
||||
}
|
||||
>
|
||||
Preserve line breaks
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
lh="1"
|
||||
py={8}
|
||||
rightSection={
|
||||
jsonOptions.tabulate ? <i className="ps-2 bi bi-check2" /> : null
|
||||
}
|
||||
onClick={() =>
|
||||
setJsonOptions({
|
||||
...jsonOptions,
|
||||
tabulate: !jsonOptions.tabulate,
|
||||
})
|
||||
}
|
||||
>
|
||||
Tabulate
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRowData({
|
||||
source,
|
||||
|
|
@ -65,6 +182,16 @@ export function RowDataPanel({
|
|||
rowId: string | undefined | null;
|
||||
}) {
|
||||
const { data, isLoading, isError } = useRowData({ source, rowId });
|
||||
const {
|
||||
onPropertyAddClick,
|
||||
generateSearchUrl,
|
||||
generateChartUrl,
|
||||
displayedColumns,
|
||||
toggleColumn,
|
||||
} = useContext(RowSidePanelContext);
|
||||
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [debouncedFilter] = useDebouncedValue(filter, 100);
|
||||
|
||||
const rowData = useMemo(() => {
|
||||
const firstRow = { ...(data?.data?.[0] ?? {}) };
|
||||
|
|
@ -76,19 +203,189 @@ export function RowDataPanel({
|
|||
delete firstRow['__hdx_timestamp'];
|
||||
delete firstRow['__hdx_trace_id'];
|
||||
delete firstRow['__hdx_body'];
|
||||
return firstRow;
|
||||
}, [data]);
|
||||
|
||||
return filterObjectRecursively(firstRow, debouncedFilter);
|
||||
}, [data, debouncedFilter]);
|
||||
|
||||
const getLineActions = useCallback<GetLineActions>(
|
||||
({ keyPath, value }) => {
|
||||
const actions: LineAction[] = [];
|
||||
|
||||
// only strings for now
|
||||
if (onPropertyAddClick != null && typeof value === 'string' && value) {
|
||||
actions.push({
|
||||
key: 'add-to-search',
|
||||
label: (
|
||||
<>
|
||||
<i className="bi bi-funnel-fill me-1" />
|
||||
Add to Filters
|
||||
</>
|
||||
),
|
||||
title: 'Add to Filters',
|
||||
onClick: () => {
|
||||
onPropertyAddClick(mergePath(keyPath), value);
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: `Added "${mergePath(keyPath)} = ${value}" to filters`,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (generateSearchUrl && typeof value !== 'object') {
|
||||
actions.push({
|
||||
key: 'search',
|
||||
label: (
|
||||
<>
|
||||
<i className="bi bi-search me-1" />
|
||||
Search
|
||||
</>
|
||||
),
|
||||
title: 'Search for this value only',
|
||||
onClick: () => {
|
||||
router.push(
|
||||
generateSearchUrl(
|
||||
`${mergePath(keyPath)} = ${
|
||||
typeof value === 'string' ? `'${value}'` : value
|
||||
}`,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* TODO: Handle bools properly (they show up as number...) */
|
||||
if (generateChartUrl && typeof value === 'number') {
|
||||
actions.push({
|
||||
key: 'chart',
|
||||
label: <i className="bi bi-graph-up" />,
|
||||
title: 'Chart',
|
||||
onClick: () => {
|
||||
router.push(
|
||||
generateChartUrl({
|
||||
aggFn: 'avg',
|
||||
field: `${keyPath.join('.')}`,
|
||||
groupBy: [],
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (toggleColumn && typeof value !== 'object') {
|
||||
const keyPathString = mergePath(keyPath);
|
||||
const isIncluded = displayedColumns?.includes(keyPathString);
|
||||
actions.push({
|
||||
key: 'toggle-column',
|
||||
label: isIncluded ? (
|
||||
<>
|
||||
<i className="bi bi-dash fs-7 me-1" />
|
||||
Column
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-plus fs-7 me-1" />
|
||||
Column
|
||||
</>
|
||||
),
|
||||
title: isIncluded
|
||||
? `Remove ${keyPathString} column from results table`
|
||||
: `Add ${keyPathString} column to results table`,
|
||||
onClick: () => {
|
||||
toggleColumn(keyPathString);
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: `Column "${keyPathString}" ${
|
||||
isIncluded ? 'removed from' : 'added to'
|
||||
} results table`,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const handleCopyObject = () => {
|
||||
const copiedObj =
|
||||
keyPath.length === 0 ? rowData : get(rowData, keyPath);
|
||||
window.navigator.clipboard.writeText(
|
||||
JSON.stringify(copiedObj, null, 2),
|
||||
);
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: `Copied object to clipboard`,
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof value === 'object') {
|
||||
actions.push({
|
||||
key: 'copy-object',
|
||||
label: 'Copy Object',
|
||||
onClick: handleCopyObject,
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
key: 'copy-value',
|
||||
label: 'Copy Value',
|
||||
onClick: () => {
|
||||
window.navigator.clipboard.writeText(
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: JSON.stringify(value, null, 2),
|
||||
);
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: `Value copied to clipboard`,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
[
|
||||
displayedColumns,
|
||||
generateChartUrl,
|
||||
generateSearchUrl,
|
||||
onPropertyAddClick,
|
||||
rowData,
|
||||
toggleColumn,
|
||||
],
|
||||
);
|
||||
|
||||
const jsonOptions = useAtomValue(viewerOptionsAtom);
|
||||
|
||||
return (
|
||||
<div className="flex-grow-1 bg-body overflow-auto">
|
||||
<Paper py="xs" withBorder>
|
||||
<Group mx="xl" gap="xs">
|
||||
<Input
|
||||
size="xs"
|
||||
w="100%"
|
||||
maw="400px"
|
||||
placeholder="Search properties by key or value"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.currentTarget.value)}
|
||||
leftSection={<i className="bi bi-search" />}
|
||||
/>
|
||||
{filter && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={() => setFilter('')}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-grow-1" />
|
||||
<HyperJsonMenu />
|
||||
</Group>
|
||||
</Paper>
|
||||
<Paper bg="transparent" mt="sm">
|
||||
{rowData != null ? (
|
||||
<HyperJson
|
||||
data={rowData}
|
||||
normallyExpanded={true}
|
||||
tabulate={true}
|
||||
lineWrap={true}
|
||||
getLineActions={undefined}
|
||||
getLineActions={getLineActions}
|
||||
{...jsonOptions}
|
||||
/>
|
||||
) : (
|
||||
<Text>No data</Text>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { add } from 'date-fns';
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
|
@ -20,40 +27,33 @@ import DBTracePanel from './DBTracePanel';
|
|||
import 'react-modern-drawer/dist/index.css';
|
||||
import styles from '@/../styles/LogSidePanel.module.scss';
|
||||
|
||||
export const RowSidePanelContext = createContext<{
|
||||
onPropertyAddClick?: (keyPath: string, value: string) => void;
|
||||
generateSearchUrl?: (query?: string) => string;
|
||||
generateChartUrl?: (config: {
|
||||
aggFn: string;
|
||||
field: string;
|
||||
groupBy: string[];
|
||||
}) => string;
|
||||
displayedColumns?: string[];
|
||||
toggleColumn?: (column: string) => void;
|
||||
shareUrl?: string;
|
||||
}>({});
|
||||
|
||||
export default function DBRowSidePanel({
|
||||
rowId: rowId,
|
||||
source,
|
||||
// where,
|
||||
q,
|
||||
onClose,
|
||||
onPropertyAddClick,
|
||||
generateSearchUrl,
|
||||
generateChartUrl,
|
||||
isNestedPanel = false,
|
||||
displayedColumns,
|
||||
toggleColumn,
|
||||
shareUrl: shareUrlProp,
|
||||
}: {
|
||||
// where?: string;
|
||||
source: TSource;
|
||||
q?: string;
|
||||
rowId: string | undefined;
|
||||
onClose: () => void;
|
||||
onPropertyAddClick?: (name: string, value: string) => void;
|
||||
generateSearchUrl: (
|
||||
query?: string,
|
||||
timeRange?: [Date, Date],
|
||||
lid?: string,
|
||||
) => string;
|
||||
generateChartUrl: (config: {
|
||||
aggFn: string;
|
||||
field: string;
|
||||
groupBy: string[];
|
||||
}) => string;
|
||||
isNestedPanel?: boolean;
|
||||
displayedColumns?: string[];
|
||||
toggleColumn?: (column: string) => void;
|
||||
shareUrl?: string;
|
||||
}) {
|
||||
const contextZIndex = useZIndex();
|
||||
const drawerZIndex = contextZIndex + 10;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import produce from 'immer';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
|
|
@ -16,8 +15,8 @@ import {
|
|||
|
||||
import { useAllFields, useGetKeyValues } from '@/hooks/useMetadata';
|
||||
import { ChartConfigWithDateRange } from '@/renderChartConfig';
|
||||
import { Filter } from '@/renderChartConfig';
|
||||
import { useSearchPageFilterState } from '@/searchFilters';
|
||||
import { mergePath } from '@/utils';
|
||||
|
||||
import classes from '../../styles/SearchPage.module.scss';
|
||||
|
||||
|
|
@ -248,21 +247,23 @@ export const FilterGroup = ({
|
|||
);
|
||||
};
|
||||
|
||||
type FilterStateHook = ReturnType<typeof useSearchPageFilterState>;
|
||||
|
||||
export const DBSearchPageFilters = ({
|
||||
filters: filterState,
|
||||
clearAllFilters,
|
||||
clearFilter,
|
||||
setFilterValue,
|
||||
isLive,
|
||||
filters,
|
||||
chartConfig,
|
||||
onFilterChange,
|
||||
analysisMode,
|
||||
setAnalysisMode,
|
||||
}: {
|
||||
analysisMode: 'results' | 'delta' | 'pattern';
|
||||
setAnalysisMode: (mode: 'results' | 'delta' | 'pattern') => void;
|
||||
isLive: boolean;
|
||||
filters: Filter[];
|
||||
chartConfig: ChartConfigWithDateRange;
|
||||
onFilterChange: (filters: Filter[]) => void;
|
||||
}) => {
|
||||
} & FilterStateHook) => {
|
||||
const { data, isLoading } = useAllFields({
|
||||
databaseName: chartConfig.from.databaseName,
|
||||
tableName: chartConfig.from.tableName,
|
||||
|
|
@ -281,25 +282,26 @@ export const DBSearchPageFilters = ({
|
|||
// First show low cardinality fields
|
||||
const isLowCardinality = (type: string) =>
|
||||
type.includes('LowCardinality');
|
||||
return isLowCardinality(a.type) ? -1 : isLowCardinality(b.type) ? 1 : 0;
|
||||
return isLowCardinality(a.type) && !isLowCardinality(b.type) ? -1 : 1;
|
||||
})
|
||||
.filter(
|
||||
field => field.jsType && ['string'].includes(field.jsType),
|
||||
// todo: add number type with sliders :D
|
||||
)
|
||||
// query only low cardinality fields by default
|
||||
.filter(field => showMoreFields || field.type.includes('LowCardinality'))
|
||||
.map(({ path }) => {
|
||||
const [key, ...rest] = path;
|
||||
if (rest.length === 0) {
|
||||
return key;
|
||||
}
|
||||
return `${key}['${rest.join("']['")}']`;
|
||||
.map(({ path, type }) => {
|
||||
return { type, path: mergePath(path) };
|
||||
})
|
||||
.filter(
|
||||
field =>
|
||||
showMoreFields ||
|
||||
field.type.includes('LowCardinality') || // query only low cardinality fields by default
|
||||
Object.keys(filterState).includes(field.path), // keep selected fields
|
||||
)
|
||||
.map(({ path }) => path)
|
||||
.filter(path => !['Body', 'Timestamp'].includes(path));
|
||||
|
||||
return strings;
|
||||
}, [data, showMoreFields]);
|
||||
}, [data, filterState, showMoreFields]);
|
||||
|
||||
// Special case for live tail
|
||||
const [dateRange, setDateRange] = useState<[Date, Date]>(
|
||||
|
|
@ -323,16 +325,6 @@ export const DBSearchPageFilters = ({
|
|||
keys: datum,
|
||||
});
|
||||
|
||||
const {
|
||||
filters: filterState,
|
||||
setFilterValue,
|
||||
clearFilter,
|
||||
clearAllFilters,
|
||||
} = useSearchPageFilterState({
|
||||
searchQuery: filters ?? undefined,
|
||||
onFilterChange,
|
||||
});
|
||||
|
||||
const shownFacets = useMemo(() => {
|
||||
const _facets: { key: string; value: string[] }[] = [];
|
||||
for (const facet of facets ?? []) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import cx from 'classnames';
|
||||
import { add } from 'date-fns';
|
||||
import { Box, Button, Code, Collapse, Text } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
|
||||
|
|
@ -8,6 +10,7 @@ import {
|
|||
Granularity,
|
||||
useTimeChartSettings,
|
||||
} from '@/ChartUtils';
|
||||
import { convertGranularityToSeconds } from '@/ChartUtils';
|
||||
import { ClickHouseQueryError, formatResponseForTimeChart } from '@/clickhouse';
|
||||
import { DisplayType } from '@/DisplayType';
|
||||
import { MemoChart } from '@/HDXMultiSeriesTimeChart';
|
||||
|
|
@ -20,6 +23,7 @@ import { SQLPreview } from './ChartSQLPreview';
|
|||
|
||||
export function DBTimeChart({
|
||||
config,
|
||||
sourceId,
|
||||
onSettled,
|
||||
alertThreshold,
|
||||
alertThresholdType,
|
||||
|
|
@ -32,6 +36,7 @@ export function DBTimeChart({
|
|||
showLegend = true,
|
||||
}: {
|
||||
config: ChartConfigWithDateRange;
|
||||
sourceId?: string;
|
||||
onSettled?: () => void;
|
||||
alertThreshold?: number;
|
||||
alertThresholdType?: 'above' | 'below';
|
||||
|
|
@ -112,6 +117,30 @@ export function DBTimeChart({
|
|||
| undefined
|
||||
>(undefined);
|
||||
|
||||
const clickedActiveLabelDate = useMemo(() => {
|
||||
return activeClickPayload?.activeLabel != null
|
||||
? new Date(Number.parseInt(activeClickPayload.activeLabel) * 1000)
|
||||
: undefined;
|
||||
}, [activeClickPayload]);
|
||||
|
||||
const qparams = useMemo(() => {
|
||||
if (!clickedActiveLabelDate || !sourceId) {
|
||||
return null;
|
||||
}
|
||||
const from = clickedActiveLabelDate.getTime();
|
||||
const to = add(clickedActiveLabelDate, {
|
||||
seconds: convertGranularityToSeconds(granularity),
|
||||
}).getTime();
|
||||
return new URLSearchParams({
|
||||
source: sourceId,
|
||||
where: config.where,
|
||||
whereLanguage: config.whereLanguage || 'lucene',
|
||||
filters: JSON.stringify(config.filters),
|
||||
from: from.toString(),
|
||||
to: to.toString(),
|
||||
});
|
||||
}, [clickedActiveLabelDate, config, granularity, sourceId]);
|
||||
|
||||
return isLoading && !data ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
Loading Chart Data...
|
||||
|
|
@ -166,30 +195,30 @@ export function DBTimeChart({
|
|||
top: 0,
|
||||
}}
|
||||
>
|
||||
{/* {activeClickPayload != null && clickedActiveLabelDate != null ? (
|
||||
<div
|
||||
className="bg-grey px-3 py-2 rounded fs-8"
|
||||
style={{
|
||||
zIndex: 5,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
visibility: 'visible',
|
||||
transform: `translate(${
|
||||
activeClickPayload.xPerc > 0.5
|
||||
? (activeClickPayload?.x ?? 0) - 130
|
||||
: (activeClickPayload?.x ?? 0) + 4
|
||||
}px, ${activeClickPayload?.y ?? 0}px)`,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/search?${qparams?.toString()}`}
|
||||
className="text-white-hover text-decoration-none"
|
||||
>
|
||||
<i className="bi bi-search me-1"></i> View Events
|
||||
</Link>
|
||||
</div>
|
||||
) : null} */}
|
||||
{activeClickPayload != null && qparams != null ? (
|
||||
<div
|
||||
className="bg-grey px-3 py-2 rounded fs-8"
|
||||
style={{
|
||||
zIndex: 5,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
visibility: 'visible',
|
||||
transform: `translate(${
|
||||
activeClickPayload.xPerc > 0.5
|
||||
? (activeClickPayload?.x ?? 0) - 130
|
||||
: (activeClickPayload?.x ?? 0) + 4
|
||||
}px, ${activeClickPayload?.y ?? 0}px)`,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/search?${qparams?.toString()}`}
|
||||
className="text-white-hover text-decoration-none"
|
||||
>
|
||||
<i className="bi bi-search me-1"></i> View Events
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{/* {totalGroups > groupKeys.length ? (
|
||||
<div
|
||||
className="bg-grey px-3 py-2 rounded fs-8"
|
||||
|
|
|
|||
|
|
@ -82,6 +82,90 @@ export default function OnboardingModal({
|
|||
You can always add and edit connections later.
|
||||
</Text>
|
||||
)}
|
||||
<Divider label="OR" my="md" />
|
||||
<Button
|
||||
variant="outline"
|
||||
w="100%"
|
||||
color="gray.4"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await createConnectionMutation.mutateAsync({
|
||||
connection: {
|
||||
id: 'local',
|
||||
name: 'Demo',
|
||||
host: 'https://demo-ch.hyperdx.io',
|
||||
username: 'demo',
|
||||
password: 'demo',
|
||||
},
|
||||
});
|
||||
const traceSource = await createSourceMutation.mutateAsync({
|
||||
source: {
|
||||
kind: 'trace',
|
||||
name: 'Demo Traces',
|
||||
connection: 'local',
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'otel_traces',
|
||||
},
|
||||
timestampValueExpression: 'Timestamp',
|
||||
defaultTableSelectExpression:
|
||||
'Timestamp, ServiceName, StatusCode, round(Duration / 1e6), SpanName',
|
||||
serviceNameExpression: 'ServiceName',
|
||||
eventAttributesExpression: 'SpanAttributes',
|
||||
resourceAttributesExpression: 'ResourceAttributes',
|
||||
traceIdExpression: 'TraceId',
|
||||
spanIdExpression: 'SpanId',
|
||||
implicitColumnExpression: 'SpanName',
|
||||
durationExpression: 'Duration',
|
||||
durationPrecision: 9,
|
||||
parentSpanIdExpression: 'ParentSpanId',
|
||||
spanKindExpression: 'SpanKind',
|
||||
spanNameExpression: 'SpanName',
|
||||
logSourceId: 'l-758211293',
|
||||
statusCodeExpression: 'StatusCode',
|
||||
statusMessageExpression: 'StatusMessage',
|
||||
},
|
||||
});
|
||||
await createSourceMutation.mutateAsync({
|
||||
source: {
|
||||
kind: 'log',
|
||||
name: 'Demo Logs',
|
||||
connection: 'local',
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'otel_logs',
|
||||
},
|
||||
timestampValueExpression: 'TimestampTime',
|
||||
defaultTableSelectExpression:
|
||||
'Timestamp, ServiceName, SeverityText, Body',
|
||||
serviceNameExpression: 'ServiceName',
|
||||
severityTextExpression: 'SeverityText',
|
||||
eventAttributesExpression: 'LogAttributes',
|
||||
resourceAttributesExpression: 'ResourceAttributes',
|
||||
traceSourceId: traceSource.id,
|
||||
traceIdExpression: 'TraceId',
|
||||
spanIdExpression: 'SpanId',
|
||||
implicitColumnExpression: 'Body',
|
||||
},
|
||||
});
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Connected to HyperDX demo server.',
|
||||
});
|
||||
setStep(undefined);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Error',
|
||||
message:
|
||||
'Could not connect to the HyperDX demo server, please try again later.',
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Connect to Demo Server
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{step === 'source' && (
|
||||
|
|
@ -110,105 +194,6 @@ export default function OnboardingModal({
|
|||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Divider label="OR" my="md" />
|
||||
<Button
|
||||
variant="outline"
|
||||
w="100%"
|
||||
color="gray.4"
|
||||
onClick={() => {
|
||||
createConnectionMutation.mutate(
|
||||
{
|
||||
connection: {
|
||||
id: 'local',
|
||||
name: 'Demo',
|
||||
host: 'https://demo-ch.hyperdx.io',
|
||||
username: 'demo',
|
||||
password: 'demo',
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Error',
|
||||
message:
|
||||
'Could not connect to the HyperDX demo server, please try again later.',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
createSourceMutation.mutate(
|
||||
{
|
||||
source: {
|
||||
kind: 'trace',
|
||||
name: 'Demo Traces',
|
||||
connection: 'local',
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'otel_traces',
|
||||
},
|
||||
timestampValueExpression: 'Timestamp',
|
||||
defaultTableSelectExpression:
|
||||
'Timestamp, ServiceName, StatusCode, round(Duration / 1e6), SpanName',
|
||||
serviceNameExpression: 'ServiceName',
|
||||
eventAttributesExpression: 'SpanAttributes',
|
||||
resourceAttributesExpression: 'ResourceAttributes',
|
||||
traceIdExpression: 'TraceId',
|
||||
spanIdExpression: 'SpanId',
|
||||
implicitColumnExpression: 'SpanName',
|
||||
durationExpression: 'Duration',
|
||||
durationPrecision: 9,
|
||||
parentSpanIdExpression: 'ParentSpanId',
|
||||
spanNameExpression: 'SpanName',
|
||||
logSourceId: 'l-758211293',
|
||||
statusCodeExpression: 'StatusCode',
|
||||
statusMessageExpression: 'StatusMessage',
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: traceSource => {
|
||||
createSourceMutation.mutate(
|
||||
{
|
||||
source: {
|
||||
kind: 'log',
|
||||
name: 'Demo Logs',
|
||||
connection: 'local',
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'otel_logs',
|
||||
},
|
||||
timestampValueExpression: 'TimestampTime',
|
||||
defaultTableSelectExpression:
|
||||
'Timestamp, ServiceName, SeverityText, Body',
|
||||
serviceNameExpression: 'ServiceName',
|
||||
severityTextExpression: 'SeverityText',
|
||||
eventAttributesExpression: 'LogAttributes',
|
||||
resourceAttributesExpression: 'ResourceAttributes',
|
||||
traceSourceId: traceSource.id,
|
||||
traceIdExpression: 'TraceId',
|
||||
spanIdExpression: 'SpanId',
|
||||
implicitColumnExpression: 'Body',
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Connected to HyperDX demo server.',
|
||||
});
|
||||
setStep(undefined);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Connect to Demo Server
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,7 @@ import { DBTimeChart } from '@/components/DBTimeChart';
|
|||
import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils';
|
||||
import SlowestEventsTile from '@/components/ServiceDashboardSlowestEventsTile';
|
||||
import { Filter } from '@/renderChartConfig';
|
||||
import {
|
||||
CH_COLUMNS,
|
||||
DB_STATEMENT_PROPERTY,
|
||||
durationInMsExpr,
|
||||
} from '@/ServicesDashboardPage';
|
||||
import { getExpressions } from '@/serviceDashboard';
|
||||
import { useSource } from '@/source';
|
||||
import { useZIndex, ZIndexContext } from '@/zIndex';
|
||||
|
||||
|
|
@ -30,6 +26,7 @@ export default function ServiceDashboardDbQuerySidePanel({
|
|||
searchedTimeRange: [Date, Date];
|
||||
}) {
|
||||
const { data: source } = useSource({ id: sourceId });
|
||||
const expressions = getExpressions(source);
|
||||
|
||||
const [dbQuery, setDbQuery] = useQueryState('dbquery', parseAsString);
|
||||
const onClose = useCallback(() => {
|
||||
|
|
@ -43,17 +40,17 @@ export default function ServiceDashboardDbQuerySidePanel({
|
|||
const filters: Filter[] = [
|
||||
{
|
||||
type: 'sql',
|
||||
condition: `${DB_STATEMENT_PROPERTY} = '${dbQuery}'`,
|
||||
condition: `${expressions.dbStatement} IN ('${dbQuery}')`,
|
||||
},
|
||||
];
|
||||
if (service) {
|
||||
filters.push({
|
||||
type: 'sql',
|
||||
condition: `${CH_COLUMNS.service} = '${service}'`,
|
||||
condition: `${expressions.service} IN ('${service}')`,
|
||||
});
|
||||
}
|
||||
return filters;
|
||||
}, [dbQuery, service]);
|
||||
}, [dbQuery, expressions, service]);
|
||||
|
||||
if (!dbQuery) {
|
||||
return null;
|
||||
|
|
@ -95,6 +92,7 @@ export default function ServiceDashboardDbQuerySidePanel({
|
|||
</Group>
|
||||
{source && (
|
||||
<DBTimeChart
|
||||
sourceId={sourceId}
|
||||
config={{
|
||||
...source,
|
||||
where: '',
|
||||
|
|
@ -102,7 +100,7 @@ export default function ServiceDashboardDbQuerySidePanel({
|
|||
select: [
|
||||
{
|
||||
aggFn: 'sum' as const,
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
alias: 'Total Query Time',
|
||||
aggCondition: '',
|
||||
},
|
||||
|
|
@ -124,6 +122,7 @@ export default function ServiceDashboardDbQuerySidePanel({
|
|||
</Group>
|
||||
{source && (
|
||||
<DBTimeChart
|
||||
sourceId={sourceId}
|
||||
config={{
|
||||
...source,
|
||||
where: '',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { MS_NUMBER_FORMAT } from '@/ChartUtils';
|
|||
import { TSource } from '@/commonTypes';
|
||||
import { ChartBox } from '@/components/ChartBox';
|
||||
import DBListBarChart from '@/components/DBListBarChart';
|
||||
import { CH_COLUMNS, durationInMsExpr } from '@/ServicesDashboardPage';
|
||||
import { getExpressions } from '@/serviceDashboard';
|
||||
|
||||
const MAX_NUM_GROUPS = 200;
|
||||
|
||||
|
|
@ -19,18 +19,15 @@ export default function ServiceDashboardEndpointPerformanceChart({
|
|||
service?: string;
|
||||
endpoint?: string;
|
||||
}) {
|
||||
const traceIdExpression = source?.traceIdExpression || CH_COLUMNS.traceId;
|
||||
const spanName = source?.spanNameExpression || CH_COLUMNS.spanName;
|
||||
const serviceName = source?.serviceNameExpression || CH_COLUMNS.service;
|
||||
const duration = durationInMsExpr(source);
|
||||
const expressions = getExpressions(source);
|
||||
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentSpanWhereCondition = [
|
||||
service && `${serviceName} = '${service}'`,
|
||||
endpoint && `${spanName} = '${endpoint}'`,
|
||||
service && `${expressions.service} = '${service}'`,
|
||||
endpoint && `${expressions.spanName} = '${endpoint}'`,
|
||||
// Ideally should use `timeFilterExpr`, but it returns chSql while filter.condition is string
|
||||
`${source.timestampValueExpression} >=
|
||||
fromUnixTimestamp64Milli(${dateRange[0].getTime()}) AND
|
||||
|
|
@ -40,7 +37,7 @@ export default function ServiceDashboardEndpointPerformanceChart({
|
|||
.filter(Boolean)
|
||||
.join(' AND ');
|
||||
|
||||
const selectTraceIdsSql = `SELECT distinct ${traceIdExpression}
|
||||
const selectTraceIdsSql = `SELECT distinct ${expressions.traceId}
|
||||
FROM ${source.from.databaseName}.${source.from.tableName}
|
||||
WHERE ${parentSpanWhereCondition}
|
||||
`;
|
||||
|
|
@ -64,12 +61,12 @@ export default function ServiceDashboardEndpointPerformanceChart({
|
|||
{
|
||||
alias: 'group',
|
||||
valueExpression: `concat(
|
||||
${spanName}, ' ',
|
||||
${expressions.spanName}, ' ',
|
||||
if(
|
||||
has(['HTTP DELETE', 'DELETE', 'HTTP GET', 'GET', 'HTTP HEAD', 'HEAD', 'HTTP OPTIONS', 'OPTIONS', 'HTTP PATCH', 'PATCH', 'HTTP POST', 'POST', 'HTTP PUT', 'PUT'], ${spanName}),
|
||||
has(['HTTP DELETE', 'DELETE', 'HTTP GET', 'GET', 'HTTP HEAD', 'HEAD', 'HTTP OPTIONS', 'OPTIONS', 'HTTP PATCH', 'PATCH', 'HTTP POST', 'POST', 'HTTP PUT', 'PUT'], ${expressions.spanName}),
|
||||
COALESCE(
|
||||
NULLIF(${CH_COLUMNS.serverAddress}, ''),
|
||||
NULLIF(${CH_COLUMNS.httpHost}, '')
|
||||
NULLIF(${expressions.serverAddress}, ''),
|
||||
NULLIF(${expressions.httpHost}, '')
|
||||
),
|
||||
''
|
||||
))`,
|
||||
|
|
@ -78,7 +75,7 @@ export default function ServiceDashboardEndpointPerformanceChart({
|
|||
alias: 'Total Time Spent',
|
||||
aggFn: 'sum',
|
||||
aggCondition: '',
|
||||
valueExpression: duration,
|
||||
valueExpression: expressions.durationInMillis,
|
||||
},
|
||||
{
|
||||
alias: 'Number of Calls',
|
||||
|
|
@ -88,25 +85,25 @@ export default function ServiceDashboardEndpointPerformanceChart({
|
|||
alias: 'Average Duration',
|
||||
aggFn: 'avg',
|
||||
aggCondition: '',
|
||||
valueExpression: duration,
|
||||
valueExpression: expressions.durationInMillis,
|
||||
},
|
||||
{
|
||||
alias: 'Min Duration',
|
||||
aggFn: 'min',
|
||||
aggCondition: '',
|
||||
valueExpression: duration,
|
||||
valueExpression: expressions.durationInMillis,
|
||||
},
|
||||
{
|
||||
alias: 'Max Duration',
|
||||
aggFn: 'max',
|
||||
aggCondition: '',
|
||||
valueExpression: duration,
|
||||
valueExpression: expressions.durationInMillis,
|
||||
},
|
||||
{
|
||||
alias: 'Number of Requests',
|
||||
aggFn: 'count_distinct',
|
||||
aggCondition: '',
|
||||
valueExpression: traceIdExpression,
|
||||
valueExpression: expressions.traceId,
|
||||
},
|
||||
{
|
||||
alias: 'Calls per Request',
|
||||
|
|
@ -121,17 +118,17 @@ export default function ServiceDashboardEndpointPerformanceChart({
|
|||
? [
|
||||
{
|
||||
type: 'sql' as const,
|
||||
condition: `${serviceName} = '${service}'`,
|
||||
condition: `${expressions.service} = '${service}'`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: 'sql',
|
||||
condition: `${traceIdExpression} IN (${selectTraceIdsSql})`,
|
||||
condition: `${expressions.traceId} IN (${selectTraceIdsSql})`,
|
||||
},
|
||||
{
|
||||
type: 'sql',
|
||||
condition: `${duration} >= 0 AND ${spanName} != '${endpoint}'`,
|
||||
condition: `${expressions.duration} >= 0 AND ${expressions.spanName} != '${endpoint}'`,
|
||||
},
|
||||
],
|
||||
numberFormat: MS_NUMBER_FORMAT,
|
||||
|
|
|
|||
|
|
@ -13,12 +13,8 @@ import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils';
|
|||
import ServiceDashboardEndpointPerformanceChart from '@/components/ServiceDashboardEndpointPerformanceChart';
|
||||
import SlowestEventsTile from '@/components/ServiceDashboardSlowestEventsTile';
|
||||
import { Filter } from '@/renderChartConfig';
|
||||
import {
|
||||
CH_COLUMNS,
|
||||
CH_IS_ERROR,
|
||||
CH_IS_SERVER_KIND,
|
||||
EndpointLatencyChart,
|
||||
} from '@/ServicesDashboardPage';
|
||||
import { getExpressions } from '@/serviceDashboard';
|
||||
import { EndpointLatencyChart } from '@/ServicesDashboardPage';
|
||||
import { useSource } from '@/source';
|
||||
import { useZIndex, ZIndexContext } from '@/zIndex';
|
||||
|
||||
|
|
@ -35,6 +31,7 @@ export default function ServiceDashboardEndpointSidePanel({
|
|||
searchedTimeRange: [Date, Date];
|
||||
}) {
|
||||
const { data: source } = useSource({ id: sourceId });
|
||||
const expressions = getExpressions(source);
|
||||
|
||||
const [endpoint, setEndpoint] = useQueryState('endpoint', parseAsString);
|
||||
const onClose = useCallback(() => {
|
||||
|
|
@ -48,17 +45,17 @@ export default function ServiceDashboardEndpointSidePanel({
|
|||
const filters: Filter[] = [
|
||||
{
|
||||
type: 'sql',
|
||||
condition: `${CH_COLUMNS.spanName} = '${endpoint}' AND ${CH_IS_SERVER_KIND}`,
|
||||
condition: `${expressions.spanName} = '${endpoint}' AND ${expressions.isSpanKindServer}`,
|
||||
},
|
||||
];
|
||||
if (service) {
|
||||
filters.push({
|
||||
type: 'sql',
|
||||
condition: `${CH_COLUMNS.service} = '${service}'`,
|
||||
condition: `${expressions.service} = '${service}'`,
|
||||
});
|
||||
}
|
||||
return filters;
|
||||
}, [endpoint, service]);
|
||||
}, [endpoint, service, expressions]);
|
||||
|
||||
if (!endpoint || !source) {
|
||||
return null;
|
||||
|
|
@ -106,7 +103,7 @@ export default function ServiceDashboardEndpointSidePanel({
|
|||
whereLanguage: 'sql',
|
||||
select: [
|
||||
{
|
||||
valueExpression: `countIf(${CH_IS_ERROR}) / count()`,
|
||||
valueExpression: `countIf(${expressions.isError}) / count()`,
|
||||
alias: 'Error Rate %',
|
||||
},
|
||||
],
|
||||
|
|
@ -115,7 +112,7 @@ export default function ServiceDashboardEndpointSidePanel({
|
|||
...endpointFilters,
|
||||
{
|
||||
type: 'sql',
|
||||
condition: `${CH_COLUMNS.httpScheme} = 'http'`,
|
||||
condition: `${expressions.httpScheme} = 'http'`,
|
||||
},
|
||||
],
|
||||
dateRange: searchedTimeRange,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { ChartBox } from '@/components/ChartBox';
|
|||
import { DBSqlRowTable } from '@/components/DBRowTable';
|
||||
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
|
||||
import { Filter } from '@/renderChartConfig';
|
||||
import { CH_COLUMNS, durationInMsExpr } from '@/ServicesDashboardPage';
|
||||
import { getExpressions } from '@/serviceDashboard';
|
||||
|
||||
import { SQLPreview } from './ChartSQLPreview';
|
||||
|
||||
|
|
@ -27,6 +27,8 @@ export default function SlowestEventsTile({
|
|||
enabled?: boolean;
|
||||
extraFilters?: Filter[];
|
||||
}) {
|
||||
const expressions = getExpressions(source);
|
||||
|
||||
const { data, isLoading, isError, error } = useQueriedChartConfig(
|
||||
{
|
||||
...source,
|
||||
|
|
@ -37,7 +39,7 @@ export default function SlowestEventsTile({
|
|||
alias: 'p95',
|
||||
aggFn: 'quantile',
|
||||
aggCondition: '',
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
level: 0.95,
|
||||
},
|
||||
],
|
||||
|
|
@ -112,21 +114,21 @@ export default function SlowestEventsTile({
|
|||
alias: 'Timestamp',
|
||||
},
|
||||
{
|
||||
valueExpression: CH_COLUMNS.level,
|
||||
alias: 'Level',
|
||||
valueExpression: expressions.severityText,
|
||||
alias: 'Severity',
|
||||
},
|
||||
{
|
||||
valueExpression: CH_COLUMNS.spanName,
|
||||
valueExpression: expressions.spanName,
|
||||
alias: 'Span Name',
|
||||
},
|
||||
{
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
alias: 'Duration (ms)',
|
||||
},
|
||||
],
|
||||
orderBy: [
|
||||
{
|
||||
valueExpression: durationInMsExpr(source),
|
||||
valueExpression: expressions.durationInMillis,
|
||||
ordering: 'DESC',
|
||||
},
|
||||
],
|
||||
|
|
@ -136,7 +138,7 @@ export default function SlowestEventsTile({
|
|||
...extraFilters,
|
||||
{
|
||||
type: 'sql',
|
||||
condition: `${durationInMsExpr(source)} > ${roundedP95}`,
|
||||
condition: `${expressions.durationInMillis} > ${roundedP95}`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -357,6 +357,19 @@ export function TraceTableModelForm({
|
|||
disableKeywordAutocomplete
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow
|
||||
label={'Default Select'}
|
||||
helpText="Default columns selected in search results (this can be customized per search later)"
|
||||
>
|
||||
<SQLInlineEditorControlled
|
||||
database={databaseName}
|
||||
table={tableName}
|
||||
control={control}
|
||||
name="defaultTableSelectExpression"
|
||||
placeholder="Timestamp, ServiceName, StatusCode, Duration, SpanName"
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
</FormRow>
|
||||
<Divider />
|
||||
<FormRow label={'Duration Expression'}>
|
||||
<SQLInlineEditorControlled
|
||||
|
|
@ -434,6 +447,16 @@ export function TraceTableModelForm({
|
|||
placeholder="SpanName"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label={'Span Kind Expression'}>
|
||||
<SQLInlineEditorControlled
|
||||
connectionId={connectionId}
|
||||
database={databaseName}
|
||||
table={tableName}
|
||||
control={control}
|
||||
name="spanKindExpression"
|
||||
placeholder="SpanKind"
|
||||
/>
|
||||
</FormRow>
|
||||
<Divider />
|
||||
<FormRow
|
||||
label={'Correlated Log Source'}
|
||||
|
|
@ -471,6 +494,26 @@ export function TraceTableModelForm({
|
|||
placeholder="ServiceName"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label={'Resource Attributes Expression'}>
|
||||
<SQLInlineEditorControlled
|
||||
database={databaseName}
|
||||
table={tableName}
|
||||
control={control}
|
||||
name="resourceAttributesExpression"
|
||||
placeholder="ResourceAttributes"
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label={'Event Attributes Expression'}>
|
||||
<SQLInlineEditorControlled
|
||||
database={databaseName}
|
||||
table={tableName}
|
||||
control={control}
|
||||
name="eventAttributesExpression"
|
||||
placeholder="SpanAttributes"
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
</FormRow>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,220 +0,0 @@
|
|||
// From: https://github.com/albertodeago/curl-generator/blob/master/src/main.ts
|
||||
// Forked to allow multiple headers with the same name
|
||||
|
||||
type StringMap = { [key: string]: string };
|
||||
|
||||
/**
|
||||
* Additional options for curl command.
|
||||
*
|
||||
* --compressed -> Request compressed response
|
||||
* --compressed-ssh -> Enable SSH compression
|
||||
* --fail -> Fail silently (no output at all) on HTTP errors
|
||||
* --fail-early -> Fail on first transfer error, do not continue
|
||||
* --head -> Show document info only
|
||||
* --include -> Include protocol response headers in the output
|
||||
* --insecure -> Allow insecure server connections when using SSL
|
||||
* --ipv4 -> Resolve names to IPv4 addresses
|
||||
* --ipv6 -> Resolve names to IPv6 addresses
|
||||
* --list-only -> List only mode
|
||||
* --location -> Follow redirects
|
||||
* --location-trusted -> Like --location, and send auth to other hosts
|
||||
* --no-keepalive -> Disable TCP keepalive on the connection
|
||||
* --show-error -> Show error even when -s is used
|
||||
* --silent -> Silent mode
|
||||
* --ssl -> Try SSL/TLS
|
||||
* --sslv2 -> Use SSLv2
|
||||
* --sslv3 -> Use SSLv3
|
||||
* --verbose -> Make the operation more talkative
|
||||
*/
|
||||
type CurlAdditionalOptions = {
|
||||
compressed: boolean;
|
||||
compressedSsh: boolean;
|
||||
fail: boolean;
|
||||
failEarly: boolean;
|
||||
head: boolean;
|
||||
include: boolean;
|
||||
insecure: boolean;
|
||||
ipv4: boolean;
|
||||
ipv6: boolean;
|
||||
listOnly: boolean;
|
||||
location: boolean;
|
||||
locationTrusted: boolean;
|
||||
noKeepalive: boolean;
|
||||
output: string;
|
||||
showError: boolean;
|
||||
silent: boolean;
|
||||
ssl: boolean;
|
||||
sslv2: boolean;
|
||||
sslv3: boolean;
|
||||
verbose: boolean;
|
||||
};
|
||||
|
||||
type CurlRequest = {
|
||||
method?:
|
||||
| 'GET'
|
||||
| 'get'
|
||||
| 'POST'
|
||||
| 'post'
|
||||
| 'PUT'
|
||||
| 'put'
|
||||
| 'PATCH'
|
||||
| 'patch'
|
||||
| 'DELETE'
|
||||
| 'delete';
|
||||
headers?: [string, string][];
|
||||
body?: Object | string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
// slash for connecting previous breakup line to current line for running cURL directly in Command Prompt
|
||||
const slash = ' \\';
|
||||
const newLine = '\n';
|
||||
|
||||
/**
|
||||
* @param {string} [method]
|
||||
* @returns {string}
|
||||
*/
|
||||
const getCurlMethod = function (method?: string): string {
|
||||
let result = '';
|
||||
if (method) {
|
||||
const types: StringMap = {
|
||||
GET: '-X GET',
|
||||
POST: '-X POST',
|
||||
PUT: '-X PUT',
|
||||
PATCH: '-X PATCH',
|
||||
DELETE: '-X DELETE',
|
||||
};
|
||||
result = ` ${types[method.toUpperCase()]}`;
|
||||
}
|
||||
return slash + newLine + result;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {StringMap} headers
|
||||
* @returns {string}
|
||||
*/
|
||||
const getCurlHeaders = function (headers?: [string, string][]): string {
|
||||
let result = '';
|
||||
if (headers) {
|
||||
headers.map(([name, val]) => {
|
||||
result += `${slash}${newLine}-H "${name}: ${val.replace(
|
||||
/(\\|")/g,
|
||||
'\\$1',
|
||||
)}"`;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} body
|
||||
* @returns {string}
|
||||
*/
|
||||
const getCurlBody = function (body?: Object): string {
|
||||
let result = '';
|
||||
if (body) {
|
||||
result += `${slash}${newLine}-d "${JSON.stringify(body).replace(
|
||||
/(\\|")/g,
|
||||
'\\$1',
|
||||
)}"`;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// From chrome dev tools
|
||||
// https://github.com/ChromeDevTools/devtools-frontend/blob/d12637511c19e5a3d060656eeb54e76e410715ca/front_end/panels/network/NetworkLogView.ts#L2193
|
||||
// TODO: Support windows
|
||||
function escapeStringPosix(str: string): string {
|
||||
function escapeCharacter(x: string): string {
|
||||
const code = x.charCodeAt(0);
|
||||
let hexString = code.toString(16);
|
||||
// Zero pad to four digits to comply with ANSI-C Quoting:
|
||||
// http://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html
|
||||
while (hexString.length < 4) {
|
||||
hexString = '0' + hexString;
|
||||
}
|
||||
|
||||
return '\\u' + hexString;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex, no-useless-escape
|
||||
if (/[\0-\x1F\x7F-\x9F!]|\'/.test(str)) {
|
||||
// Use ANSI-C quoting syntax.
|
||||
return (
|
||||
"$'" +
|
||||
str
|
||||
.replace(/\\/g, '\\\\')
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
.replace(/\'/g, "\\'")
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\0-\x1F\x7F-\x9F!]/g, escapeCharacter) +
|
||||
"'"
|
||||
);
|
||||
}
|
||||
// Use single quote syntax.
|
||||
return "'" + str + "'";
|
||||
}
|
||||
|
||||
const getCurlBodyString = function (body?: string): string {
|
||||
let result = '';
|
||||
if (body) {
|
||||
result += `${slash}${newLine}--data-raw ${escapeStringPosix(body)}`;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given the curl additional options, turn them into curl syntax
|
||||
* @param {CurlAdditionalOptions} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
const getCurlOptions = function (options?: CurlAdditionalOptions): string {
|
||||
let result = '';
|
||||
if (options) {
|
||||
(Object.keys(options) as Array<keyof CurlAdditionalOptions>).forEach(
|
||||
(key: keyof CurlAdditionalOptions) => {
|
||||
const kebabKey = key.replace(
|
||||
/[A-Z]/g,
|
||||
letter => `-${letter.toLowerCase()}`,
|
||||
);
|
||||
|
||||
if (!options[key]) {
|
||||
throw new Error(`Invalid Curl option ${key}`);
|
||||
} else if (typeof options[key] === 'boolean' && options[key]) {
|
||||
// boolean option, we just add --opt
|
||||
result += `--${kebabKey} `;
|
||||
} else if (typeof options[key] === 'string') {
|
||||
// string option, we have to add --opt=value
|
||||
result += `--${kebabKey} ${options[key]} `;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return result ? `${slash}${newLine}${result}` : result;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {CurlRequest} params
|
||||
* @param {CurlAdditionalOptions} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
const CurlGenerator = function (
|
||||
params: CurlRequest,
|
||||
options?: CurlAdditionalOptions,
|
||||
): string {
|
||||
let curlSnippet = 'curl ';
|
||||
curlSnippet += params.url;
|
||||
curlSnippet += getCurlMethod(params.method);
|
||||
curlSnippet += getCurlHeaders(params.headers);
|
||||
curlSnippet +=
|
||||
typeof params.body === 'string'
|
||||
? getCurlBodyString(params.body)
|
||||
: getCurlBody(params.body);
|
||||
curlSnippet += getCurlOptions(options);
|
||||
return curlSnippet.trim();
|
||||
};
|
||||
|
||||
export { CurlGenerator };
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
import { SERVER_URL } from './config';
|
||||
import { Chart, Dashboard } from './types';
|
||||
|
||||
function getResourceName(name: string) {
|
||||
return name.toLowerCase().replace(/\W/g, '_');
|
||||
}
|
||||
|
||||
function getDashboardResourceName(name: string) {
|
||||
return `dashboard_${getResourceName(name)}`;
|
||||
}
|
||||
|
||||
function getChartResourceName(chart: Chart, dashboard: Dashboard | undefined) {
|
||||
const defaultName = `chart_${getResourceName(chart.name)}`;
|
||||
|
||||
if (dashboard == null) {
|
||||
return defaultName;
|
||||
}
|
||||
|
||||
if (dashboard.charts.filter(c => c.name === chart.name).length > 1) {
|
||||
return `${defaultName}_${chart.id}`;
|
||||
}
|
||||
|
||||
return defaultName;
|
||||
}
|
||||
|
||||
function getChartAlertResourceName(
|
||||
chart: Chart,
|
||||
dashboard: Dashboard | undefined,
|
||||
) {
|
||||
return `alert_${getChartResourceName(chart, dashboard).replace(
|
||||
'chart_',
|
||||
'',
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function dashboardToTerraformImport(dashboard: Dashboard | undefined) {
|
||||
if (dashboard == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (
|
||||
`terraform import restapi_object.${getDashboardResourceName(
|
||||
dashboard.name,
|
||||
)} /api/v1/dashboards/${dashboard._id}\n` +
|
||||
dashboard.alerts
|
||||
?.map(alert => {
|
||||
const chart = dashboard.charts.find(
|
||||
chart => chart.id === alert.chartId,
|
||||
);
|
||||
if (chart == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `terraform import restapi_object.${getChartAlertResourceName(
|
||||
chart,
|
||||
dashboard,
|
||||
)} /api/v1/alerts/${alert._id}`;
|
||||
})
|
||||
.filter(s => s !== '')
|
||||
?.join('\n')
|
||||
).trim();
|
||||
}
|
||||
|
||||
export function dashboardToTerraform(
|
||||
dashboard: Dashboard | undefined,
|
||||
apiKey: string,
|
||||
) {
|
||||
if (dashboard == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `terraform {
|
||||
required_providers {
|
||||
restapi = {
|
||||
source = "Mastercard/restapi"
|
||||
version = "1.18.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "restapi" {
|
||||
uri = "${SERVER_URL}"
|
||||
write_returns_object = true
|
||||
debug = true
|
||||
id_attribute = "data/id"
|
||||
|
||||
headers = {
|
||||
"Authorization" = "Bearer ${apiKey}", # Your Personal API Key
|
||||
"Content-Type" = "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
${dashboard.charts
|
||||
.map(
|
||||
chart => ` ${getChartResourceName(chart, dashboard)}_id = "${chart.id}"`,
|
||||
)
|
||||
.join('\n')}
|
||||
}
|
||||
|
||||
resource "restapi_object" "dashboard_${getResourceName(dashboard.name)}" {
|
||||
path = "/api/v1/dashboards"
|
||||
data = jsonencode({
|
||||
"name": "${dashboard.name}",
|
||||
"query": "${dashboard.query}",${
|
||||
dashboard.tags != null
|
||||
? `
|
||||
"tags": [${dashboard?.tags?.map(tag => `"${tag}"`).join(', ')}],
|
||||
`
|
||||
: ''
|
||||
}
|
||||
"charts": [${dashboard.charts
|
||||
.map((chart, i) => {
|
||||
return (
|
||||
// Fix identation for following JSON objects
|
||||
(i > 0 ? ' ' : '') +
|
||||
JSON.stringify(
|
||||
{
|
||||
id: 'CHART_ID',
|
||||
name: chart.name,
|
||||
x: chart.x,
|
||||
y: chart.y,
|
||||
w: chart.w,
|
||||
h: chart.h,
|
||||
asRatio: chart.seriesReturnType === 'ratio',
|
||||
series: chart.series.map(s => ({
|
||||
type: s.type,
|
||||
...(s.type === 'time' ||
|
||||
s.type === 'table' ||
|
||||
s.type === 'number'
|
||||
? {
|
||||
dataSource: s.table === 'logs' ? 'events' : 'metrics',
|
||||
}
|
||||
: {}),
|
||||
...('numberFormat' in s
|
||||
? { numberFormat: s.numberFormat }
|
||||
: {}),
|
||||
...('groupBy' in s ? { groupBy: s.groupBy } : {}),
|
||||
...('sortOrder' in s ? { sortOrder: s.sortOrder } : {}),
|
||||
...('where' in s ? { where: s.where } : {}),
|
||||
...('field' in s ? { field: s.field } : {}),
|
||||
...('content' in s ? { content: s.content } : {}),
|
||||
...('aggFn' in s ? { aggFn: s.aggFn } : {}),
|
||||
...('metricDataType' in s
|
||||
? { metricDataType: s.metricDataType }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
)
|
||||
.replace(/\n/g, '\n ')
|
||||
// We need to replace it with a bare reference so it's not quoted
|
||||
.replace(
|
||||
'"CHART_ID"',
|
||||
`local.${getChartResourceName(chart, dashboard)}_id`,
|
||||
)
|
||||
);
|
||||
})
|
||||
.join(',\n')}]
|
||||
})
|
||||
}
|
||||
|
||||
${
|
||||
dashboard.alerts
|
||||
?.map(alert => {
|
||||
const chart = dashboard.charts.find(chart => chart.id === alert.chartId);
|
||||
if (chart == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dashboardId = `restapi_object.dashboard_${getResourceName(
|
||||
dashboard.name,
|
||||
)}.id`;
|
||||
|
||||
const chartId = `local.${getChartResourceName(chart, dashboard)}_id`;
|
||||
|
||||
const alertJson = JSON.stringify(
|
||||
{
|
||||
interval: alert.interval,
|
||||
threshold: alert.threshold,
|
||||
threshold_type: alert.type === 'presence' ? 'above' : 'below',
|
||||
channel: {
|
||||
type:
|
||||
alert.channel.type === 'webhook' ||
|
||||
alert.channel.type === 'slack_webhook'
|
||||
? 'webhook'
|
||||
: '',
|
||||
...('webhookId' in alert.channel
|
||||
? { webhookId: alert.channel.webhookId }
|
||||
: {}),
|
||||
},
|
||||
source: 'chart',
|
||||
dashboardId: 'TO_POPULATE_DASHBOARD_ID',
|
||||
chartId: 'TO_POPULATE_CHART_ID',
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
)
|
||||
.replace(/\n/g, '\n ')
|
||||
// Populate with bare variables, not quoted strings
|
||||
.replace('"TO_POPULATE_DASHBOARD_ID"', dashboardId)
|
||||
.replace('"TO_POPULATE_CHART_ID"', chartId);
|
||||
|
||||
return `resource "restapi_object" "${getChartAlertResourceName(
|
||||
chart,
|
||||
dashboard,
|
||||
)}" {
|
||||
path = "/api/v1/alerts"
|
||||
data = jsonencode(${alertJson})
|
||||
}`;
|
||||
})
|
||||
.filter(s => s != null)
|
||||
?.join('\n\n') || ''
|
||||
}`.trim();
|
||||
}
|
||||
|
|
@ -1,347 +0,0 @@
|
|||
import lucene from '@hyperdx/lucene';
|
||||
|
||||
function encodeSpecialTokens(query: string): string {
|
||||
return query
|
||||
.replace(/\\\\/g, 'HDX_BACKSLASH_LITERAL')
|
||||
.replace('http://', 'http_COLON_//')
|
||||
.replace('https://', 'https_COLON_//')
|
||||
.replace(/localhost:(\d{1,5})/, 'localhost_COLON_$1')
|
||||
.replace(/\\:/g, 'HDX_COLON');
|
||||
}
|
||||
function decodeSpecialTokens(query: string): string {
|
||||
return query
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/HDX_BACKSLASH_LITERAL/g, '\\')
|
||||
.replace('http_COLON_//', 'http://')
|
||||
.replace('https_COLON_//', 'https://')
|
||||
.replace(/localhost_COLON_(\d{1,5})/, 'localhost:$1')
|
||||
.replace(/HDX_COLON/g, ':');
|
||||
}
|
||||
|
||||
export function parse(query: string): lucene.AST {
|
||||
return lucene.parse(encodeSpecialTokens(query));
|
||||
}
|
||||
|
||||
const IMPLICIT_FIELD = '<implicit>';
|
||||
|
||||
interface Serializer {
|
||||
operator(op: lucene.Operator): string;
|
||||
eq(field: string, term: string, isNegatedField: boolean): Promise<string>;
|
||||
isNotNull(field: string, isNegatedField: boolean): Promise<string>;
|
||||
gte(field: string, term: string): Promise<string>;
|
||||
lte(field: string, term: string): Promise<string>;
|
||||
lt(field: string, term: string): Promise<string>;
|
||||
gt(field: string, term: string): Promise<string>;
|
||||
// ilike(field: string, term: string, isNegatedField: boolean): Promise<string>;
|
||||
fieldSearch(
|
||||
field: string,
|
||||
term: string,
|
||||
isNegatedField: boolean,
|
||||
prefixWildcard: boolean,
|
||||
suffixWildcard: boolean,
|
||||
): Promise<string>;
|
||||
range(
|
||||
field: string,
|
||||
start: string,
|
||||
end: string,
|
||||
isNegatedField: boolean,
|
||||
): Promise<string>;
|
||||
}
|
||||
|
||||
class EnglishSerializer implements Serializer {
|
||||
private translateField(field: string) {
|
||||
if (field === IMPLICIT_FIELD) {
|
||||
return 'event';
|
||||
}
|
||||
|
||||
return `'${field}'`;
|
||||
}
|
||||
|
||||
operator(op: lucene.Operator) {
|
||||
switch (op) {
|
||||
case 'NOT':
|
||||
case 'AND NOT':
|
||||
return 'AND NOT';
|
||||
case 'OR NOT':
|
||||
return 'OR NOT';
|
||||
// @ts-ignore TODO: Types need to be fixed upstream
|
||||
case '&&':
|
||||
case '<implicit>':
|
||||
case 'AND':
|
||||
return 'AND';
|
||||
// @ts-ignore TODO: Types need to be fixed upstream
|
||||
case '||':
|
||||
case 'OR':
|
||||
return 'OR';
|
||||
default:
|
||||
throw new Error(`Unexpected operator. ${op}`);
|
||||
}
|
||||
}
|
||||
|
||||
async eq(field: string, term: string, isNegatedField: boolean) {
|
||||
return `${this.translateField(field)} ${
|
||||
isNegatedField ? 'is not' : 'is'
|
||||
} ${term}`;
|
||||
}
|
||||
|
||||
async isNotNull(field: string, isNegatedField: boolean) {
|
||||
return `${this.translateField(field)} ${
|
||||
isNegatedField ? 'is null' : 'is not null'
|
||||
}`;
|
||||
}
|
||||
|
||||
async gte(field: string, term: string) {
|
||||
return `${this.translateField(field)} is greater than or equal to ${term}`;
|
||||
}
|
||||
|
||||
async lte(field: string, term: string) {
|
||||
return `${this.translateField(field)} is less than or equal to ${term}`;
|
||||
}
|
||||
|
||||
async lt(field: string, term: string) {
|
||||
return `${this.translateField(field)} is less than ${term}`;
|
||||
}
|
||||
|
||||
async gt(field: string, term: string) {
|
||||
return `${this.translateField(field)} is greater than ${term}`;
|
||||
}
|
||||
|
||||
// async fieldSearch(field: string, term: string, isNegatedField: boolean) {
|
||||
// return `${this.translateField(field)} ${
|
||||
// isNegatedField ? 'does not contain' : 'contains'
|
||||
// } ${term}`;
|
||||
// }
|
||||
|
||||
async fieldSearch(
|
||||
field: string,
|
||||
term: string,
|
||||
isNegatedField: boolean,
|
||||
prefixWildcard: boolean,
|
||||
suffixWildcard: boolean,
|
||||
) {
|
||||
if (field === IMPLICIT_FIELD) {
|
||||
return `${this.translateField(field)} ${
|
||||
prefixWildcard && suffixWildcard
|
||||
? isNegatedField
|
||||
? 'does not contain'
|
||||
: 'contains'
|
||||
: prefixWildcard
|
||||
? isNegatedField
|
||||
? 'does not end with'
|
||||
: 'ends with'
|
||||
: suffixWildcard
|
||||
? isNegatedField
|
||||
? 'does not start with'
|
||||
: 'starts with'
|
||||
: isNegatedField
|
||||
? 'does not have whole word'
|
||||
: 'has whole word'
|
||||
} ${term}`;
|
||||
} else {
|
||||
return `${this.translateField(field)} ${
|
||||
isNegatedField ? 'does not contain' : 'contains'
|
||||
} ${term}`;
|
||||
}
|
||||
}
|
||||
|
||||
async range(
|
||||
field: string,
|
||||
start: string,
|
||||
end: string,
|
||||
isNegatedField: boolean,
|
||||
) {
|
||||
return `${field} ${
|
||||
isNegatedField ? 'is not' : 'is'
|
||||
} between ${start} and ${end}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function nodeTerm(
|
||||
node: lucene.Node,
|
||||
serializer: Serializer,
|
||||
): Promise<string> {
|
||||
const field = node.field[0] === '-' ? node.field.slice(1) : node.field;
|
||||
let isNegatedField = node.field[0] === '-';
|
||||
const isImplicitField = node.field === '<implicit>';
|
||||
|
||||
// TODO: Deal with property with ambiguous/multiple types
|
||||
// let propertyType = propertyTypeMap.get(field);
|
||||
|
||||
// const column: string = field;
|
||||
// if (customColumnMap[field] != null) {
|
||||
// column = customColumnMap[field];
|
||||
// propertyType = 'string';
|
||||
// } else {
|
||||
// if (propertyType != null) {
|
||||
// column = buildSearchColumnName(propertyType, field);
|
||||
// }
|
||||
// }
|
||||
|
||||
// NodeTerm
|
||||
if ((node as lucene.NodeTerm).term != null) {
|
||||
const nodeTerm = node as lucene.NodeTerm;
|
||||
let term = decodeSpecialTokens(nodeTerm.term);
|
||||
// We should only negate the search for negated bare terms (ex. '-5')
|
||||
// This meeans the field is implicit and the prefix is -
|
||||
if (isImplicitField && nodeTerm.prefix === '-') {
|
||||
isNegatedField = true;
|
||||
}
|
||||
// Otherwise, if we have a negated term for a field (ex. 'level:-5')
|
||||
// we should not negate the search, and search for -5
|
||||
if (!isImplicitField && nodeTerm.prefix === '-') {
|
||||
term = nodeTerm.prefix + decodeSpecialTokens(nodeTerm.term);
|
||||
}
|
||||
|
||||
// TODO: Decide if this is good behavior
|
||||
// If the term is quoted, we should search for the exact term in a property (ex. foo:"bar")
|
||||
// Implicit field searches should still use substring matching (ex. "foo bar")
|
||||
if (nodeTerm.quoted && !isImplicitField) {
|
||||
return serializer.eq(field, term, isNegatedField);
|
||||
// return SqlString.format(`${column}${isNegatedField ? '!' : ''}=?`, [
|
||||
// term,
|
||||
// ]);
|
||||
}
|
||||
|
||||
if (!nodeTerm.quoted && term === '*') {
|
||||
return serializer.isNotNull(field, isNegatedField);
|
||||
// return `${column} IS ${isNegatedField ? '' : 'NOT '}NULL`;
|
||||
}
|
||||
|
||||
if (!nodeTerm.quoted && term.substring(0, 2) === '>=') {
|
||||
if (isNegatedField) {
|
||||
return serializer.lt(field, term.slice(2));
|
||||
}
|
||||
return serializer.gte(field, term.slice(2));
|
||||
}
|
||||
if (!nodeTerm.quoted && term.substring(0, 2) === '<=') {
|
||||
if (isNegatedField) {
|
||||
return serializer.gt(field, term.slice(2));
|
||||
}
|
||||
return serializer.lte(field, term.slice(2));
|
||||
// const fn = isNegatedField ? '>' : '<=';
|
||||
// return `${column} ${fn} ${term.slice(2)}`;
|
||||
}
|
||||
if (!nodeTerm.quoted && term[0] === '>') {
|
||||
if (isNegatedField) {
|
||||
return serializer.lte(field, term.slice(1));
|
||||
}
|
||||
return serializer.gt(field, term.slice(1));
|
||||
// const fn = isNegatedField ? '<=' : '>';
|
||||
// return `${column} ${fn} ${term.slice(1)}`;
|
||||
}
|
||||
if (!nodeTerm.quoted && term[0] === '<') {
|
||||
if (isNegatedField) {
|
||||
return serializer.gte(field, term.slice(1));
|
||||
}
|
||||
return serializer.lt(field, term.slice(1));
|
||||
// const fn = isNegatedField ? '>=' : '<';
|
||||
// return `${column} ${fn} ${term.slice(1)}`;
|
||||
}
|
||||
|
||||
let prefixWildcard = false;
|
||||
let suffixWildcard = false;
|
||||
if (!nodeTerm.quoted && term[0] === '*') {
|
||||
prefixWildcard = true;
|
||||
term = term.slice(1);
|
||||
}
|
||||
if (!nodeTerm.quoted && term[term.length - 1] === '*') {
|
||||
suffixWildcard = true;
|
||||
term = term.slice(0, -1);
|
||||
}
|
||||
|
||||
return serializer.fieldSearch(
|
||||
field,
|
||||
term,
|
||||
isNegatedField,
|
||||
prefixWildcard,
|
||||
suffixWildcard,
|
||||
);
|
||||
// Bool/Numbers need to be matched with equality operator
|
||||
// if (
|
||||
// !isImplicitField &&
|
||||
// (propertyType === 'number' || propertyType === 'bool')
|
||||
// ) {
|
||||
// return serializer.eq(field, term, isNegatedField);
|
||||
// // return `${column} ${isNegatedField ? '!' : ''}= ${term}`;
|
||||
// }
|
||||
|
||||
// TODO: Handle regex, similarity, boost, prefix
|
||||
// return serializer.ilike(field, term, isNegatedField);
|
||||
// return `(${column} ${isNegatedField ? 'NOT ' : ''}ILIKE '%${term}%')`;
|
||||
}
|
||||
// NodeRangedTerm
|
||||
if ((node as lucene.NodeRangedTerm).inclusive != null) {
|
||||
const rangedTerm = node as lucene.NodeRangedTerm;
|
||||
return serializer.range(
|
||||
field,
|
||||
rangedTerm.term_min,
|
||||
rangedTerm.term_max,
|
||||
isNegatedField,
|
||||
);
|
||||
// return `(${column} ${isNegatedField ? 'NOT ' : ''}BETWEEN ${
|
||||
// rangedTerm.term_min
|
||||
// } AND ${rangedTerm.term_max})`;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected Node type. ${node}`);
|
||||
}
|
||||
|
||||
async function serialize(
|
||||
ast: lucene.AST | lucene.Node,
|
||||
serializer: Serializer,
|
||||
// propertyTypeMap: Map<string, 'bool' | 'string' | 'number'>,
|
||||
): Promise<string> {
|
||||
// Node Scenarios:
|
||||
// 1. NodeTerm: Single term ex. "foo:bar"
|
||||
// 2. NodeRangedTerm: Two terms ex. "foo:[bar TO qux]"
|
||||
if ((ast as lucene.NodeTerm).term != null) {
|
||||
return await nodeTerm(ast as lucene.NodeTerm, serializer);
|
||||
}
|
||||
if ((ast as lucene.NodeRangedTerm).inclusive != null) {
|
||||
return await nodeTerm(ast as lucene.NodeTerm, serializer);
|
||||
}
|
||||
|
||||
// AST Scenarios:
|
||||
// 1. BinaryAST: Two terms ex. "foo:bar AND baz:qux"
|
||||
// 2. LeftOnlyAST: Single term ex. "foo:bar"
|
||||
if ((ast as lucene.BinaryAST).right != null) {
|
||||
const binaryAST = ast as lucene.BinaryAST;
|
||||
const operator = serializer.operator(binaryAST.operator);
|
||||
const parenthesized = binaryAST.parenthesized;
|
||||
return `${parenthesized ? '(' : ''}${await serialize(
|
||||
binaryAST.left,
|
||||
serializer,
|
||||
)} ${operator} ${await serialize(binaryAST.right, serializer)}${
|
||||
parenthesized ? ')' : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
if ((ast as lucene.LeftOnlyAST).left != null) {
|
||||
const leftOnlyAST = ast as lucene.LeftOnlyAST;
|
||||
const parenthesized = leftOnlyAST.parenthesized;
|
||||
// start is used when ex. "NOT foo:bar"
|
||||
return `${parenthesized ? '(' : ''}${
|
||||
leftOnlyAST.start != undefined ? `${leftOnlyAST.start} ` : ''
|
||||
}${await serialize(leftOnlyAST.left, serializer)}${
|
||||
parenthesized ? ')' : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
// Blank AST, means no text was parsed
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function genEnglishExplanation(query: string): Promise<string> {
|
||||
try {
|
||||
const parsedQ = parse(query);
|
||||
|
||||
if (parsedQ) {
|
||||
const serializer = new EnglishSerializer();
|
||||
return await serialize(parsedQ, serializer);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Parse failure', query, e);
|
||||
}
|
||||
|
||||
return `Message containing ${query}`;
|
||||
}
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { UseQueryOptions } from 'react-query';
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||
|
||||
import { apiConfigs } from './api';
|
||||
import { usePrevious } from './utils';
|
||||
|
||||
let team: string | null = null;
|
||||
try {
|
||||
team = localStorage?.getItem('hdx_team');
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
class RetriableError extends Error {}
|
||||
class FatalError extends Error {}
|
||||
class TimeoutError extends Error {}
|
||||
const EventStreamContentType = 'text/event-stream';
|
||||
|
||||
function useSearchEventStream(
|
||||
{
|
||||
apiUrlPath,
|
||||
q,
|
||||
startDate,
|
||||
endDate,
|
||||
extraFields,
|
||||
order,
|
||||
limit = 100,
|
||||
onEvent,
|
||||
onEnd,
|
||||
resultsKey,
|
||||
}: {
|
||||
apiUrlPath: string;
|
||||
q: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
extraFields: string[];
|
||||
order: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
onEvent?: (event: any) => void;
|
||||
onEnd?: (error?: any) => void;
|
||||
resultsKey?: string;
|
||||
},
|
||||
options?: UseQueryOptions<any, Error> & {
|
||||
shouldAbortPendingRequest?: boolean;
|
||||
},
|
||||
) {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const keepPreviousData = options?.keepPreviousData ?? false;
|
||||
const shouldAbortPendingRequest = options?.shouldAbortPendingRequest ?? true;
|
||||
|
||||
const [results, setResults] = useState<{ key: string; data: any[] }>({
|
||||
key: '',
|
||||
data: [],
|
||||
});
|
||||
// Set isFetching to true by default
|
||||
// unless we're not enabled
|
||||
const [isFetching, setIsFetching] = useState<boolean>(enabled);
|
||||
const [hasNextPage, setHasNextPage] = useState<boolean>(true);
|
||||
|
||||
const lastAbortController = useRef<AbortController | null>(null);
|
||||
const [fetchStatus, setFetchStatus] = useState<'fetching' | 'idle'>('idle');
|
||||
const lastFetchStatusRef = useRef<'fetching' | 'idle' | undefined>();
|
||||
|
||||
const fetchResults = useCallback(
|
||||
async ({
|
||||
pageParam = 0,
|
||||
limit: limitOverride,
|
||||
}: {
|
||||
pageParam: number;
|
||||
limit?: number;
|
||||
}) => {
|
||||
const resBuffer: any[] = [];
|
||||
let linesFetched = 0;
|
||||
|
||||
const startTime = startDate.getTime().toString();
|
||||
const endTime = endDate.getTime().toString();
|
||||
|
||||
const searchParams = new URLSearchParams([
|
||||
['endTime', endTime],
|
||||
['q', q],
|
||||
['startTime', startTime],
|
||||
['order', order],
|
||||
['offset', pageParam.toString()],
|
||||
['limit', (limitOverride ?? limit).toString()],
|
||||
...(team != null ? [['team', team]] : []),
|
||||
...extraFields.map(field => ['extraFields[]', field]),
|
||||
]);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
lastAbortController.current = ctrl;
|
||||
|
||||
setIsFetching(true);
|
||||
setFetchStatus('fetching');
|
||||
lastFetchStatusRef.current = 'fetching';
|
||||
|
||||
const fetchPromise = fetchEventSource(
|
||||
`${apiUrlPath}?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: ctrl.signal,
|
||||
credentials: 'include',
|
||||
async onopen(response) {
|
||||
if (
|
||||
response.ok &&
|
||||
response.headers.get('content-type') === EventStreamContentType
|
||||
) {
|
||||
return; // everything's good
|
||||
} else if (
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
response.status !== 429
|
||||
) {
|
||||
// client-side errors are usually non-retriable:
|
||||
// TODO: handle these???
|
||||
throw new FatalError();
|
||||
} else {
|
||||
throw new RetriableError();
|
||||
}
|
||||
},
|
||||
onmessage(event) {
|
||||
if (event.event === '') {
|
||||
const parsedRows = event.data
|
||||
.split('\n')
|
||||
.map((row: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(row);
|
||||
linesFetched++;
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((v: any) => v !== null);
|
||||
|
||||
if (onEvent != null) {
|
||||
parsedRows.forEach(onEvent);
|
||||
} else if (keepPreviousData) {
|
||||
resBuffer.push(...parsedRows);
|
||||
} else {
|
||||
setResults(prevResults => ({
|
||||
key: resultsKey ?? prevResults.key ?? 'DEFAULT_KEY',
|
||||
data: [...prevResults.data, ...parsedRows],
|
||||
}));
|
||||
}
|
||||
} else if (event.event === 'end') {
|
||||
onEnd?.();
|
||||
|
||||
if (keepPreviousData) {
|
||||
setResults({
|
||||
key: resultsKey ?? 'DEFAULT_KEY',
|
||||
data: resBuffer,
|
||||
});
|
||||
}
|
||||
|
||||
if (linesFetched === 0 || linesFetched < limit) {
|
||||
setHasNextPage(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
ctrl.abort();
|
||||
|
||||
setIsFetching(false);
|
||||
setFetchStatus('idle');
|
||||
lastFetchStatusRef.current = 'idle';
|
||||
// if the server closes the connection unexpectedly, retry:
|
||||
// throw new RetriableError();
|
||||
},
|
||||
fetch: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit | undefined,
|
||||
): Promise<Response> => {
|
||||
if (typeof input === 'string') {
|
||||
// Hack to dynamically resolve prefixUrl on every request
|
||||
return fetch(`${apiConfigs.prefixUrl}${input}`, init);
|
||||
} else {
|
||||
// We should never hit this
|
||||
console.error(
|
||||
'useSearchEventStream: Non-string fetch input is not supported',
|
||||
);
|
||||
return fetch(input, init);
|
||||
}
|
||||
},
|
||||
// onerror(err) {
|
||||
// if (err instanceof FatalError) {
|
||||
// throw err; // rethrow to stop the operation
|
||||
// } else {
|
||||
// // do nothing to automatically retry. You can also
|
||||
// // return a specific retry interval here.
|
||||
// }
|
||||
// },
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
fetchPromise,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new TimeoutError('Timeout'));
|
||||
}, 90 * 1000);
|
||||
}),
|
||||
]);
|
||||
} catch (e) {
|
||||
if (e instanceof TimeoutError) {
|
||||
setIsFetching(false);
|
||||
setFetchStatus('idle');
|
||||
lastFetchStatusRef.current = 'idle';
|
||||
ctrl.abort();
|
||||
console.warn('Closing event source due to timeout');
|
||||
onEnd?.(new TimeoutError());
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
apiUrlPath,
|
||||
q,
|
||||
startDate,
|
||||
endDate,
|
||||
extraFields,
|
||||
order,
|
||||
limit,
|
||||
keepPreviousData,
|
||||
setResults,
|
||||
onEvent,
|
||||
onEnd,
|
||||
resultsKey,
|
||||
],
|
||||
);
|
||||
|
||||
const queryKey = [
|
||||
apiUrlPath,
|
||||
q,
|
||||
startDate,
|
||||
endDate,
|
||||
extraFields,
|
||||
order,
|
||||
limit,
|
||||
].join('||');
|
||||
const prevQueryKey = usePrevious(queryKey);
|
||||
|
||||
useEffect(() => {
|
||||
// Only attempt fetching on new query keys
|
||||
if (prevQueryKey != queryKey && enabled) {
|
||||
if (
|
||||
lastFetchStatusRef.current !== 'fetching' ||
|
||||
shouldAbortPendingRequest
|
||||
) {
|
||||
// Abort previous pending request
|
||||
if (
|
||||
shouldAbortPendingRequest &&
|
||||
lastFetchStatusRef.current === 'fetching'
|
||||
) {
|
||||
lastAbortController.current?.abort();
|
||||
}
|
||||
|
||||
// Clean up previous results if we shouldn't keep them
|
||||
if (!keepPreviousData) {
|
||||
setResults({ key: '', data: [] });
|
||||
}
|
||||
|
||||
setHasNextPage(true);
|
||||
fetchResults({ pageParam: 0 });
|
||||
}
|
||||
}
|
||||
}, [
|
||||
prevQueryKey,
|
||||
queryKey,
|
||||
shouldAbortPendingRequest,
|
||||
fetchResults,
|
||||
keepPreviousData,
|
||||
enabled,
|
||||
]);
|
||||
|
||||
const fetchNextPage = useCallback(
|
||||
(params?: { limit?: number; cb?: VoidFunction }) => {
|
||||
// Make sure we don't try to fetch again when we're already fetching
|
||||
// Make sure lastFetchStatusRef is not null, as that means we haven't done an initial fetch yet
|
||||
if (
|
||||
hasNextPage &&
|
||||
lastFetchStatusRef.current === 'idle' &&
|
||||
results.data.length > 0 // make sure we at least fetched initially
|
||||
) {
|
||||
fetchResults({
|
||||
pageParam: results.data.length,
|
||||
limit: params?.limit,
|
||||
}).then(() => {
|
||||
params?.cb?.();
|
||||
});
|
||||
}
|
||||
},
|
||||
[fetchResults, results.data.length, hasNextPage],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
lastAbortController.current?.abort();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
results: results.data,
|
||||
resultsKey: results.key,
|
||||
fetchNextPage,
|
||||
abort,
|
||||
};
|
||||
}
|
||||
|
||||
export { useSearchEventStream };
|
||||
64
packages/app/src/serviceDashboard.ts
Normal file
64
packages/app/src/serviceDashboard.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { TSource } from '@/commonTypes';
|
||||
|
||||
function getDefaults() {
|
||||
const spanAttributeField = 'SpanAttributes';
|
||||
|
||||
return {
|
||||
duration: 'Duration',
|
||||
durationPrecision: 9,
|
||||
traceId: 'TraceId',
|
||||
service: 'ServiceName',
|
||||
spanName: 'SpanName',
|
||||
spanKind: 'SpanKind',
|
||||
severityText: 'StatusCode',
|
||||
k8sResourceName: `${spanAttributeField}['k8s.resource.name']`,
|
||||
k8sPodName: `${spanAttributeField}['k8s.pod.name']`,
|
||||
httpScheme: `${spanAttributeField}['http.scheme']`,
|
||||
serverAddress: `${spanAttributeField}['server.address']`,
|
||||
httpHost: `${spanAttributeField}['http.host']`,
|
||||
dbStatement: `coalesce(nullif(${spanAttributeField}['db.query.text'], ''), nullif(${spanAttributeField}['db.statement'], ''))`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getExpressions(source?: TSource) {
|
||||
const defaults = getDefaults();
|
||||
|
||||
const fieldExpressions = {
|
||||
// General
|
||||
duration: source?.durationExpression || defaults.duration,
|
||||
durationPrecision: source?.durationPrecision || defaults.durationPrecision,
|
||||
traceId: source?.traceIdExpression || defaults.traceId,
|
||||
service: source?.serviceNameExpression || defaults.service,
|
||||
spanName: source?.spanNameExpression || defaults.spanName,
|
||||
spanKind: source?.spanKindExpression || defaults.spanKind,
|
||||
severityText: source?.severityTextExpression || defaults.severityText,
|
||||
|
||||
// HTTP
|
||||
httpScheme: defaults.httpScheme,
|
||||
httpHost: defaults.httpHost,
|
||||
serverAddress: defaults.serverAddress,
|
||||
|
||||
// Kubernetes
|
||||
k8sResourceName: defaults.k8sResourceName,
|
||||
k8sPodName: defaults.k8sPodName,
|
||||
|
||||
// Database
|
||||
dbStatement: defaults.dbStatement,
|
||||
};
|
||||
|
||||
const filterExpressions = {
|
||||
isError: `lower(${fieldExpressions.severityText}) = 'error'`,
|
||||
isSpanKindServer: `${fieldExpressions.spanKind} IN ('Server', 'SPAN_KIND_SERVER')`,
|
||||
isDbSpan: `${fieldExpressions.dbStatement} <> ''`,
|
||||
};
|
||||
|
||||
const auxExpressions = {
|
||||
durationInMillis: `${fieldExpressions.duration}/1e${fieldExpressions.durationPrecision - 3}`, // precision is per second
|
||||
};
|
||||
|
||||
return {
|
||||
...fieldExpressions,
|
||||
...filterExpressions,
|
||||
...auxExpressions,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import api from './api';
|
||||
|
||||
export function useSessionEvents({
|
||||
config: { where, dateRange },
|
||||
}: {
|
||||
config: {
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
};
|
||||
}) {
|
||||
const {
|
||||
status,
|
||||
data: searchResultsPages,
|
||||
error,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isFetchingPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
} = api.useLogBatch(
|
||||
{
|
||||
q: where,
|
||||
startDate: dateRange?.[0] ?? new Date(),
|
||||
endDate: dateRange?.[1] ?? new Date(),
|
||||
extraFields: [
|
||||
'end_timestamp',
|
||||
'trace_id',
|
||||
'span_id',
|
||||
'parent_span_id',
|
||||
'http.status_code',
|
||||
'http.method',
|
||||
'http.url',
|
||||
'error.message',
|
||||
'location.href',
|
||||
'span_name',
|
||||
'component',
|
||||
'otel.library.name',
|
||||
],
|
||||
order: null,
|
||||
limit: 4000,
|
||||
},
|
||||
{
|
||||
// mikeshi: Eliminates a memory leak in the DOM Player (not sure why)
|
||||
cacheTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
getNextPageParam: (lastPage: any, allPages) => {
|
||||
if (lastPage.rows === 0) return undefined;
|
||||
return allPages.flatMap(page => page.data).length;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const events = useMemo(() => {
|
||||
return searchResultsPages?.pages
|
||||
.flatMap(page => page.data)
|
||||
.map(result => {
|
||||
return {
|
||||
...result,
|
||||
startOffset: new Date(result.timestamp).getTime(),
|
||||
endOffset: new Date(result.end_timestamp).getTime(),
|
||||
// startOffset: isoToNsOffset(result.timestamp),
|
||||
// endOffset: isoToNsOffset(result.end_timestamp),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => parseInt(a.sort_key) - parseInt(b.sort_key));
|
||||
}, [searchResultsPages]);
|
||||
|
||||
return {
|
||||
events,
|
||||
isFetching,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import omit from 'lodash/omit';
|
||||
import objectHash from 'object-hash';
|
||||
import store from 'store2';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
|
|
@ -101,6 +103,12 @@ export function useCreateSource() {
|
|||
const mut = useMutation({
|
||||
mutationFn: async ({ source }: { source: Omit<TSource, 'id'> }) => {
|
||||
if (IS_LOCAL_MODE) {
|
||||
const existingSource = getLocalSources().find(
|
||||
stored => objectHash(omit(stored, 'id')) === objectHash(source),
|
||||
);
|
||||
if (existingSource) {
|
||||
return existingSource;
|
||||
}
|
||||
const newSource = {
|
||||
...source,
|
||||
id: `l${hashCode(Math.random().toString())}`,
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
// TODO: Instead of prop drilling additional columns, we can consider using React.Context or Jotai
|
||||
export const useDisplayedColumns = (initialColumns: string[] = []) => {
|
||||
const [displayedColumns, setDisplayedColumns] =
|
||||
useState<string[]>(initialColumns);
|
||||
|
||||
const toggleColumn = (column: string) => {
|
||||
if (displayedColumns.includes(column)) {
|
||||
setDisplayedColumns(displayedColumns.filter(c => c !== column));
|
||||
} else {
|
||||
setDisplayedColumns([...displayedColumns, column]);
|
||||
}
|
||||
};
|
||||
|
||||
return { displayedColumns, setDisplayedColumns, toggleColumn };
|
||||
};
|
||||
|
|
@ -559,3 +559,11 @@ export const formatDate = (
|
|||
? formatInTimeZone(date, 'Etc/UTC', formatStr)
|
||||
: fnsFormat(date, formatStr);
|
||||
};
|
||||
|
||||
export const mergePath = (path: string[]) => {
|
||||
const [key, ...rest] = path;
|
||||
if (rest.length === 0) {
|
||||
return key;
|
||||
}
|
||||
return `${key}['${rest.join("']['")}']`;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue