feat: move more codes

This commit is contained in:
Warren 2024-11-21 21:44:33 -08:00
parent 123a0a0f50
commit aa165fcc46
99 changed files with 29078 additions and 37998 deletions

5
.env
View file

@ -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

View file

@ -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:

View file

@ -1,4 +1,4 @@
name: Push Downstream
name: Push Downstream V1
on:
push:
branches: [main]

11
.gitignore vendored
View file

@ -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

Binary file not shown.

View file

@ -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
View file

@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.5.1.cjs

View file

@ -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 &

View file

@ -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:

View file

@ -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}

View file

@ -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

View file

@ -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>

View file

@ -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"]

View file

@ -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")
'''
# --------------------------------------------------------------------------------

View file

@ -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

View file

@ -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

View file

@ -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 \

View file

@ -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>

View file

@ -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 \

View file

@ -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

View 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]

View file

@ -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]

View file

@ -1,7 +1,7 @@
{
"name": "hyperdx",
"private": true,
"version": "1.9.0",
"version": "2-beta",
"license": "MIT",
"workspaces": [
"packages/*"

View file

@ -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

View file

@ -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;

View file

@ -1,6 +1,6 @@
{
"name": "@hyperdx/app",
"version": "1.9.0",
"version": "2-beta",
"private": true,
"license": "MIT",
"engines": {

View file

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

View file

@ -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;

View file

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

View file

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

View file

@ -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>
</>
);
};

View file

@ -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&apos;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">&middot;</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;

View file

@ -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

View file

@ -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;

View file

@ -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)}
/>
)}
/>
);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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>
</>
);
}

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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]
);
};

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -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}
/>
</>
);
}

View file

@ -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}
/>
</>
);
}

View file

@ -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}
/>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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}
/>
</>
);
}

View file

@ -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);

View file

@ -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}
/>
);
}

View file

@ -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>
);
};

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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;

View file

@ -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>
)}
</>
);
}

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>
);
};

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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>
);
}

View file

@ -627,6 +627,7 @@ export default function EditTimeChartForm({
style={{ minHeight: 400 }}
>
<DBTimeChart
sourceId={sourceId}
config={queriedConfig}
onTimeRangeSelect={onTimeRangeSelect}
/>

View file

@ -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>

View file

@ -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;

View file

@ -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 ?? []) {

View file

@ -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"

View file

@ -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>
);
}

View file

@ -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: '',

View file

@ -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,

View file

@ -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,

View file

@ -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}`,
},
],
}}

View file

@ -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>
);
}

View file

@ -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 };

View file

@ -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();
}

View file

@ -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}`;
}

View file

@ -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 };

View 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,
};
}

View file

@ -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,
};
}

View file

@ -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())}`,

View file

@ -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 };
};

View file

@ -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("']['")}']`;
};

45583
yarn.lock

File diff suppressed because it is too large Load diff