mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd37b8a05e | ||
|
|
23f5ffc840 | ||
|
|
875dbccad3 | ||
|
|
fb8fa899b0 | ||
|
|
4bad603db2 | ||
|
|
720399c8b8 | ||
|
|
37e4f28e57 | ||
|
|
0502eb5ebe | ||
|
|
191569eb3d | ||
|
|
2770f9a515 | ||
|
|
788eeae500 | ||
|
|
a8497c497c | ||
|
|
b7a4214fa4 | ||
|
|
5e9034dd76 | ||
|
|
26bcc69fa2 | ||
|
|
577d8d2fba | ||
|
|
2b9aaa9929 | ||
|
|
cf166da6de | ||
|
|
92a48f1e17 | ||
|
|
f0d0d81a9b | ||
|
|
a2dc9bf1c8 | ||
|
|
263d745d0a | ||
|
|
d108cd484e | ||
|
|
148813786a | ||
|
|
8101f4a459 | ||
|
|
46a698b4be | ||
|
|
8d07271aa1 | ||
|
|
f5ef80e544 | ||
|
|
292a37397d | ||
|
|
abb3942c44 | ||
|
|
10d09ac4af | ||
|
|
64c31e9e7a | ||
|
|
d64194c18e | ||
|
|
2d13a52287 | ||
|
|
a54234609f | ||
|
|
214c9e013c | ||
|
|
b0c9de4d82 | ||
|
|
e98b424168 | ||
|
|
7521fe218d | ||
|
|
1f3d85d7a1 | ||
|
|
058c52f79d | ||
|
|
8af6e48d70 | ||
|
|
7569a50c56 | ||
|
|
20f314512d | ||
|
|
cdbbcdfe27 | ||
|
|
4e2ca87752 | ||
|
|
54a79a8100 | ||
|
|
eb4010dfa5 | ||
|
|
407d4e3687 | ||
|
|
6f7907102b | ||
|
|
5f398bdb31 | ||
|
|
69c6b332c1 | ||
|
|
e15a934b3f | ||
|
|
3a62729c03 | ||
|
|
23da31b50c | ||
|
|
4e33cd7c1b | ||
|
|
d97f17b1cf | ||
|
|
4bdf9943e4 | ||
|
|
a4c5be8665 | ||
|
|
ea71c8bd75 | ||
|
|
043707d0cb | ||
|
|
991916b2de | ||
|
|
5d3885c8a5 | ||
|
|
da44cd34f8 | ||
|
|
ffa2d3f008 | ||
|
|
d8aea2627b | ||
|
|
9756f5a117 | ||
|
|
c8cd97437e | ||
|
|
249241dfd4 | ||
|
|
16e2c2cb39 | ||
|
|
ecbca3208f | ||
|
|
505c143ddf | ||
|
|
c4aab31056 | ||
|
|
cdb63031d8 | ||
|
|
464455eff3 | ||
|
|
c7cfd7aa67 | ||
|
|
832b87e6d5 | ||
|
|
678e9614bf | ||
|
|
ac6c53ad85 | ||
|
|
22bf4de6fd | ||
|
|
dacf71a75d | ||
|
|
213c755f97 | ||
|
|
ac39602ef6 | ||
|
|
848fd2c30b | ||
|
|
63dd84339e | ||
|
|
e3ca08c69f | ||
|
|
3276ab3641 | ||
|
|
675cfa4682 | ||
|
|
f28306ce68 | ||
|
|
9b9ac62c77 | ||
|
|
574cac7d64 | ||
|
|
414f7cebc7 | ||
|
|
e30f2587e8 | ||
|
|
d7a339b9aa | ||
|
|
fe5329a1aa | ||
|
|
043ddebc6c | ||
|
|
67b9d245ec | ||
|
|
856e1f4715 | ||
|
|
72da710326 | ||
|
|
9fc6871a1f | ||
|
|
7add10642f | ||
|
|
34b6c198cb | ||
|
|
3dda45d2cc | ||
|
|
2fd7ede52f | ||
|
|
599e8dda1d | ||
|
|
21062dab44 | ||
|
|
3477593e11 |
46
.github/workflows/build.yml
vendored
46
.github/workflows/build.yml
vendored
@@ -33,29 +33,6 @@ jobs:
|
||||
max_attempts: 3
|
||||
command: sudo docker build --no-cache -f ./Home/Dockerfile .
|
||||
|
||||
docker-build-worker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preinstall
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: npm run prerun
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build --no-cache -f ./Worker/Dockerfile .
|
||||
|
||||
|
||||
docker-build-app:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -129,29 +106,6 @@ jobs:
|
||||
max_attempts: 3
|
||||
command: sudo docker build --no-cache -f ./Probe/Dockerfile .
|
||||
|
||||
docker-build-telemetry:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preinstall
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: npm run prerun
|
||||
|
||||
# build image probe api
|
||||
- name: build docker image
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build --no-cache -f ./Telemetry/Dockerfile .
|
||||
|
||||
docker-build-test-server:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
35
.github/workflows/compile.yml
vendored
35
.github/workflows/compile.yml
vendored
@@ -77,23 +77,6 @@ jobs:
|
||||
max_attempts: 3
|
||||
command: cd Home && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-worker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- name: Compile Worker
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Worker && npm install && npm run compile && npm run dep-check
|
||||
|
||||
|
||||
compile-nginx:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -201,24 +184,6 @@ jobs:
|
||||
max_attempts: 3
|
||||
command: cd Probe && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-telemetry:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- name: Compile Telemetry
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Telemetry && npm install && npm run compile && npm run dep-check
|
||||
|
||||
|
||||
compile-status-page:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
188
.github/workflows/release.yml
vendored
188
.github/workflows/release.yml
vendored
@@ -569,88 +569,6 @@ jobs:
|
||||
--image test \
|
||||
--tags "${SANITIZED_VERSION},enterprise-${SANITIZED_VERSION}"
|
||||
|
||||
telemetry-docker-image-build:
|
||||
needs: [generate-build-number, read-version]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
bash ./Scripts/GHA/build_docker_images.sh \
|
||||
--image telemetry \
|
||||
--version "${{needs.read-version.outputs.major_minor}}" \
|
||||
--dockerfile ./Telemetry/Dockerfile \
|
||||
--context . \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}"
|
||||
|
||||
telemetry-docker-image-merge:
|
||||
needs: [telemetry-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image telemetry \
|
||||
--tags "${SANITIZED_VERSION},enterprise-${SANITIZED_VERSION}"
|
||||
|
||||
probe-docker-image-build:
|
||||
needs: [generate-build-number, read-version]
|
||||
strategy:
|
||||
@@ -921,88 +839,6 @@ jobs:
|
||||
- name: Publish NPM Packages
|
||||
run: bash ./Scripts/NPM/PublishAllPackages.sh
|
||||
|
||||
worker-docker-image-build:
|
||||
needs: [generate-build-number, read-version]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
bash ./Scripts/GHA/build_docker_images.sh \
|
||||
--image worker \
|
||||
--version "${{needs.read-version.outputs.major_minor}}" \
|
||||
--dockerfile ./Worker/Dockerfile \
|
||||
--context . \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}"
|
||||
|
||||
worker-docker-image-merge:
|
||||
needs: [worker-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image worker \
|
||||
--tags "${SANITIZED_VERSION},enterprise-${SANITIZED_VERSION}"
|
||||
|
||||
# ─── Non-Docker jobs (downstream dependencies updated) ───────────────
|
||||
|
||||
publish-terraform-provider:
|
||||
@@ -1019,8 +855,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history for changelog generation
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -1035,7 +869,7 @@ jobs:
|
||||
cache: true
|
||||
|
||||
- name: Install GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
uses: goreleaser/goreleaser-action@v6.1.0
|
||||
with:
|
||||
install-only: true
|
||||
|
||||
@@ -1061,7 +895,7 @@ jobs:
|
||||
gpg --export-secret-keys >~/.gnupg/secring.gpg
|
||||
echo "GPG key exported successfully"
|
||||
|
||||
- name: Generate Terraform provider
|
||||
- name: Generate and publish Terraform provider
|
||||
run: npm run publish-terraform-provider -- --version "${{ steps.version.outputs.version }}" --github-token "${{ secrets.SIMLARSEN_GITHUB_PAT }}" --github-repo-deploy-key "${{ secrets.TERRAFORM_PROVIDER_GITHUB_REPO_DEPLOY_KEY }}"
|
||||
|
||||
|
||||
@@ -1076,11 +910,9 @@ jobs:
|
||||
- home-docker-image-merge
|
||||
- test-server-docker-image-merge
|
||||
- test-docker-image-merge
|
||||
- telemetry-docker-image-merge
|
||||
- probe-docker-image-merge
|
||||
- app-docker-image-merge
|
||||
- ai-agent-docker-image-merge
|
||||
- worker-docker-image-merge
|
||||
- test-e2e-release-saas
|
||||
- test-e2e-release-self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1093,11 +925,9 @@ jobs:
|
||||
"home",
|
||||
"test-server",
|
||||
"test",
|
||||
"telemetry",
|
||||
"probe",
|
||||
"app",
|
||||
"ai-agent",
|
||||
"worker"
|
||||
"ai-agent"
|
||||
]
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
@@ -1143,7 +973,7 @@ jobs:
|
||||
|
||||
test-e2e-release-saas:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [telemetry-docker-image-merge, ai-agent-docker-image-merge, app-docker-image-merge, home-docker-image-merge, worker-docker-image-merge, probe-docker-image-merge, test-docker-image-merge, test-server-docker-image-merge, publish-npm-packages, e2e-docker-image-merge, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-merge]
|
||||
needs: [ai-agent-docker-image-merge, app-docker-image-merge, home-docker-image-merge, probe-docker-image-merge, test-docker-image-merge, test-server-docker-image-merge, publish-npm-packages, e2e-docker-image-merge, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-merge]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
@@ -1274,7 +1104,7 @@ jobs:
|
||||
test-e2e-release-self-hosted:
|
||||
runs-on: ubuntu-latest
|
||||
# After all the jobs runs
|
||||
needs: [telemetry-docker-image-merge, ai-agent-docker-image-merge, app-docker-image-merge, home-docker-image-merge, worker-docker-image-merge, probe-docker-image-merge, test-docker-image-merge, test-server-docker-image-merge, publish-npm-packages, e2e-docker-image-merge, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-merge]
|
||||
needs: [ai-agent-docker-image-merge, app-docker-image-merge, home-docker-image-merge, probe-docker-image-merge, test-docker-image-merge, test-server-docker-image-merge, publish-npm-packages, e2e-docker-image-merge, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-merge]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
@@ -1636,6 +1466,8 @@ jobs:
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/"$PROFILE_UUID".mobileprovision
|
||||
|
||||
echo "PROFILE_UUID=$PROFILE_UUID" >> $GITHUB_ENV
|
||||
|
||||
- name: Build archive
|
||||
env:
|
||||
IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }}
|
||||
@@ -1654,6 +1486,7 @@ jobs:
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
CODE_SIGN_IDENTITY="iPhone Distribution" \
|
||||
DEVELOPMENT_TEAM="$IOS_TEAM_ID" \
|
||||
PROVISIONING_PROFILE="${{ env.PROFILE_UUID }}" \
|
||||
MARKETING_VERSION=${{ needs.read-version.outputs.major_minor }} \
|
||||
CURRENT_PROJECT_VERSION=${{ needs.generate-build-number.outputs.build_number }}
|
||||
|
||||
@@ -1872,8 +1705,9 @@ jobs:
|
||||
MARKETING_VERSION=${{ needs.read-version.outputs.major_minor }} \
|
||||
CURRENT_PROJECT_VERSION=${{ needs.generate-build-number.outputs.build_number }} \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
OTHER_CODE_SIGN_FLAGS="--keychain ${{ env.KEYCHAIN_PATH }}" \
|
||||
-allowProvisioningUpdates
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="${{ env.PROFILE_UUID }}" \
|
||||
OTHER_CODE_SIGN_FLAGS="--keychain ${{ env.KEYCHAIN_PATH }}"
|
||||
|
||||
- name: Export IPA
|
||||
run: |
|
||||
|
||||
170
.github/workflows/test-release.yaml
vendored
170
.github/workflows/test-release.yaml
vendored
@@ -514,90 +514,6 @@ jobs:
|
||||
--image test \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
telemetry-docker-image-build:
|
||||
needs: [read-version, generate-build-number]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
bash ./Scripts/GHA/build_docker_images.sh \
|
||||
--image telemetry \
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./Telemetry/Dockerfile \
|
||||
--context . \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
telemetry-docker-image-merge:
|
||||
needs: [telemetry-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}-test"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image telemetry \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
probe-docker-image-build:
|
||||
needs: [read-version, generate-build-number]
|
||||
strategy:
|
||||
@@ -850,90 +766,6 @@ jobs:
|
||||
--image ai-agent \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
worker-docker-image-build:
|
||||
needs: [read-version, generate-build-number]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
bash ./Scripts/GHA/build_docker_images.sh \
|
||||
--image worker \
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./Worker/Dockerfile \
|
||||
--context . \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
worker-docker-image-merge:
|
||||
needs: [worker-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}-test"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image worker \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
# ─── Non-Docker jobs (unchanged) ─────────────────────────────────────
|
||||
|
||||
publish-terraform-provider:
|
||||
@@ -951,7 +783,7 @@ jobs:
|
||||
|
||||
test-helm-chart:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [infrastructure-agent-deploy, publish-terraform-provider, telemetry-docker-image-merge, worker-docker-image-merge, home-docker-image-merge, test-server-docker-image-merge, test-docker-image-merge, probe-docker-image-merge, app-docker-image-merge, ai-agent-docker-image-merge, nginx-docker-image-merge, e2e-docker-image-merge]
|
||||
needs: [infrastructure-agent-deploy, publish-terraform-provider, home-docker-image-merge, test-server-docker-image-merge, test-docker-image-merge, probe-docker-image-merge, app-docker-image-merge, ai-agent-docker-image-merge, nginx-docker-image-merge, e2e-docker-image-merge]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
|
||||
22
.github/workflows/test.telemetry.yaml
vendored
22
.github/workflows/test.telemetry.yaml
vendored
@@ -1,22 +0,0 @@
|
||||
name: Telemetry Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'hotfix-*' # excludes hotfix branches
|
||||
- 'release'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd Telemetry && npm install && npm run test
|
||||
|
||||
11
.github/workflows/test.yaml
vendored
11
.github/workflows/test.yaml
vendored
@@ -29,14 +29,3 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Home && npm install && npm run test
|
||||
|
||||
test-worker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Worker && npm install && npm run test
|
||||
@@ -1 +1 @@
|
||||
This is a local development server hosted at HOST env variable (please read config.env file). When you make any changes to the codebase the server hot-reloads. Please make sure you wait for it to restart to test. This project is hosted on docker compose for local development. If you need access to the database during development, credentials are in config.env file.
|
||||
This is a local development server hosted at HOST env variable (please read config.env file). This project is hosted on docker compose for local development. When you make any changes to the codebase the container hot-reloads. Please make sure you wait for it to restart to test. If you need access to the database during development, credentials are in config.env file.
|
||||
@@ -11,6 +11,7 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import App from "Common/Server/Utils/StartServer";
|
||||
import Telemetry from "Common/Server/Utils/Telemetry";
|
||||
import Profiling from "Common/Server/Utils/Profiling";
|
||||
import Express, { ExpressApplication } from "Common/Server/Utils/Express";
|
||||
import "ejs";
|
||||
|
||||
@@ -23,6 +24,11 @@ const init: PromiseVoidFunction = async (): Promise<void> => {
|
||||
serviceName: APP_NAME,
|
||||
});
|
||||
|
||||
// Initialize profiling (opt-in via ENABLE_PROFILING env var)
|
||||
Profiling.init({
|
||||
serviceName: APP_NAME,
|
||||
});
|
||||
|
||||
logger.info("AI Agent Service - Starting...");
|
||||
|
||||
// init the app
|
||||
|
||||
27
AIAgent/package-lock.json
generated
27
AIAgent/package-lock.json
generated
@@ -48,6 +48,7 @@
|
||||
"@opentelemetry/sdk-node": "^0.207.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@pyroscope/nodejs": "^0.4.11",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
@@ -80,7 +81,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"isolated-vm": "^6.1.2",
|
||||
"json2csv": "^5.0.7",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -1488,9 +1489,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -2225,9 +2226,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3753,9 +3754,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4440,9 +4441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
35
App/API/Metrics.ts
Normal file
35
App/API/Metrics.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
ExpressRouter,
|
||||
NextFunction,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import AppQueueService from "../Services/Queue/AppQueueService";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
/**
|
||||
* JSON metrics endpoint for KEDA autoscaling
|
||||
* Returns combined queue size (worker + workflow + telemetry) as JSON for KEDA metrics-api scaler
|
||||
*/
|
||||
router.get(
|
||||
"/metrics/queue-size",
|
||||
async (
|
||||
_req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const queueSize: number = await AppQueueService.getQueueSize();
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.status(200).json({
|
||||
queueSize: queueSize,
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
15
App/FeatureSet/Accounts/package-lock.json
generated
15
App/FeatureSet/Accounts/package-lock.json
generated
@@ -52,6 +52,7 @@
|
||||
"@opentelemetry/sdk-node": "^0.207.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@pyroscope/nodejs": "^0.4.11",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
@@ -84,7 +85,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"isolated-vm": "^6.1.2",
|
||||
"json2csv": "^5.0.7",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -802,9 +803,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -930,9 +931,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
|
||||
15
App/FeatureSet/AdminDashboard/package-lock.json
generated
15
App/FeatureSet/AdminDashboard/package-lock.json
generated
@@ -51,6 +51,7 @@
|
||||
"@opentelemetry/sdk-node": "^0.207.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@pyroscope/nodejs": "^0.4.11",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
@@ -83,7 +84,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"isolated-vm": "^6.1.2",
|
||||
"json2csv": "^5.0.7",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -786,9 +787,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -914,9 +915,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
|
||||
15
App/FeatureSet/Dashboard/package-lock.json
generated
15
App/FeatureSet/Dashboard/package-lock.json
generated
@@ -55,6 +55,7 @@
|
||||
"@opentelemetry/sdk-node": "^0.207.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@pyroscope/nodejs": "^0.4.11",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
@@ -87,7 +88,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"isolated-vm": "^6.1.2",
|
||||
"json2csv": "^5.0.7",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -1083,9 +1084,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1380,9 +1381,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import TelemetryException from "Common/Models/DatabaseModels/TelemetryException";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import ListResult from "Common/Types/BaseDatabase/ListResult";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import TelemetryServiceElement from "../TelemetryService/TelemetryServiceElement";
|
||||
import TelemetryExceptionElement from "./ExceptionElement";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import AppLink from "../AppLink/AppLink";
|
||||
|
||||
interface ServiceExceptionSummary {
|
||||
service: Service;
|
||||
unresolvedCount: number;
|
||||
totalOccurrences: number;
|
||||
}
|
||||
|
||||
const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
const [unresolvedCount, setUnresolvedCount] = useState<number>(0);
|
||||
const [resolvedCount, setResolvedCount] = useState<number>(0);
|
||||
const [archivedCount, setArchivedCount] = useState<number>(0);
|
||||
const [topExceptions, setTopExceptions] = useState<Array<TelemetryException>>(
|
||||
[],
|
||||
);
|
||||
const [recentExceptions, setRecentExceptions] = useState<
|
||||
Array<TelemetryException>
|
||||
>([]);
|
||||
const [serviceSummaries, setServiceSummaries] = useState<
|
||||
Array<ServiceExceptionSummary>
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loadDashboard: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const projectId: ObjectID = ProjectUtil.getCurrentProjectId()!;
|
||||
|
||||
const [
|
||||
unresolvedResult,
|
||||
resolvedResult,
|
||||
archivedResult,
|
||||
topExceptionsResult,
|
||||
recentExceptionsResult,
|
||||
servicesResult,
|
||||
] = await Promise.all([
|
||||
ModelAPI.count({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
isResolved: false,
|
||||
isArchived: false,
|
||||
},
|
||||
}),
|
||||
ModelAPI.count({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
isResolved: true,
|
||||
isArchived: false,
|
||||
},
|
||||
}),
|
||||
ModelAPI.count({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
isArchived: true,
|
||||
},
|
||||
}),
|
||||
ModelAPI.getList({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
isResolved: false,
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
message: true,
|
||||
exceptionType: true,
|
||||
fingerprint: true,
|
||||
isResolved: true,
|
||||
isArchived: true,
|
||||
occuranceCount: true,
|
||||
lastSeenAt: true,
|
||||
firstSeenAt: true,
|
||||
environment: true,
|
||||
service: {
|
||||
name: true,
|
||||
serviceColor: true,
|
||||
} as any,
|
||||
},
|
||||
limit: 10,
|
||||
skip: 0,
|
||||
sort: {
|
||||
occuranceCount: SortOrder.Descending,
|
||||
},
|
||||
}),
|
||||
ModelAPI.getList({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
isResolved: false,
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
message: true,
|
||||
exceptionType: true,
|
||||
fingerprint: true,
|
||||
isResolved: true,
|
||||
isArchived: true,
|
||||
occuranceCount: true,
|
||||
lastSeenAt: true,
|
||||
firstSeenAt: true,
|
||||
environment: true,
|
||||
service: {
|
||||
name: true,
|
||||
serviceColor: true,
|
||||
} as any,
|
||||
},
|
||||
limit: 8,
|
||||
skip: 0,
|
||||
sort: {
|
||||
lastSeenAt: SortOrder.Descending,
|
||||
},
|
||||
}),
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
setUnresolvedCount(unresolvedResult);
|
||||
setResolvedCount(resolvedResult);
|
||||
setArchivedCount(archivedResult);
|
||||
setTopExceptions(topExceptionsResult.data || []);
|
||||
setRecentExceptions(recentExceptionsResult.data || []);
|
||||
|
||||
const loadedServices: Array<Service> = servicesResult.data || [];
|
||||
|
||||
// Load unresolved exception counts per service
|
||||
const serviceExceptionCounts: Array<ServiceExceptionSummary> = [];
|
||||
|
||||
for (const service of loadedServices) {
|
||||
const serviceExceptions: ListResult<TelemetryException> =
|
||||
await ModelAPI.getList({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
serviceId: service.id!,
|
||||
isResolved: false,
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
occuranceCount: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
occuranceCount: SortOrder.Descending,
|
||||
},
|
||||
});
|
||||
|
||||
const exceptions: Array<TelemetryException> =
|
||||
serviceExceptions.data || [];
|
||||
|
||||
if (exceptions.length > 0) {
|
||||
let totalOccurrences: number = 0;
|
||||
for (const ex of exceptions) {
|
||||
totalOccurrences += ex.occuranceCount || 0;
|
||||
}
|
||||
serviceExceptionCounts.push({
|
||||
service,
|
||||
unresolvedCount: exceptions.length,
|
||||
totalOccurrences,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
serviceExceptionCounts.sort(
|
||||
(a: ServiceExceptionSummary, b: ServiceExceptionSummary) => {
|
||||
return b.unresolvedCount - a.unresolvedCount;
|
||||
},
|
||||
);
|
||||
|
||||
setServiceSummaries(serviceExceptionCounts);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadDashboard();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCount: number = unresolvedCount + resolvedCount + archivedCount;
|
||||
|
||||
if (totalCount === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-green-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="h-8 w-8 text-green-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No exceptions caught yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start reporting exceptions, you{"'"}ll see bug
|
||||
frequency, affected services, and resolution status here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolutionRate: number =
|
||||
totalCount > 0
|
||||
? Math.round(((resolvedCount + archivedCount) / totalCount) * 100)
|
||||
: 0;
|
||||
|
||||
// Count how many of the top exceptions were first seen in last 24h
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
const oneDayAgo: Date = OneUptimeDate.addRemoveHours(now, -24);
|
||||
const newTodayCount: number = topExceptions.filter(
|
||||
(e: TelemetryException) => {
|
||||
return e.firstSeenAt && new Date(e.firstSeenAt) > oneDayAgo;
|
||||
},
|
||||
).length;
|
||||
|
||||
const maxServiceBugs: number =
|
||||
serviceSummaries.length > 0 ? serviceSummaries[0]!.unresolvedCount : 1;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Unresolved Alert Banner */}
|
||||
{unresolvedCount > 0 && (
|
||||
<AppLink
|
||||
className="block mb-6"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_UNRESOLVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={`rounded-xl p-4 flex items-center justify-between ${unresolvedCount > 20 ? "bg-red-50 border border-red-200" : unresolvedCount > 5 ? "bg-amber-50 border border-amber-200" : "bg-blue-50 border border-blue-200"}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${unresolvedCount > 20 ? "bg-red-100" : unresolvedCount > 5 ? "bg-amber-100" : "bg-blue-100"}`}
|
||||
>
|
||||
<svg
|
||||
className={`h-5 w-5 ${unresolvedCount > 20 ? "text-red-600" : unresolvedCount > 5 ? "text-amber-600" : "text-blue-600"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0112 12.75zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 01-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 002.248-2.354M12 12.75a2.25 2.25 0 01-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 00-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 01.4-2.253M12 8.25a2.25 2.25 0 00-2.248 2.146M12 8.25a2.25 2.25 0 012.248 2.146M8.683 5a6.032 6.032 0 01-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0115.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 00-.575-1.752M4.921 12s-.148-.277-.277-.5M19.08 12s.147-.277.277-.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
className={`text-sm font-semibold ${unresolvedCount > 20 ? "text-red-800" : unresolvedCount > 5 ? "text-amber-800" : "text-blue-800"}`}
|
||||
>
|
||||
{unresolvedCount} unresolved{" "}
|
||||
{unresolvedCount === 1 ? "bug" : "bugs"} need attention
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs mt-0.5 ${unresolvedCount > 20 ? "text-red-600" : unresolvedCount > 5 ? "text-amber-600" : "text-blue-600"}`}
|
||||
>
|
||||
{newTodayCount > 0
|
||||
? `${newTodayCount} new in the last 24 hours`
|
||||
: "Click to view and triage"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`h-5 w-5 ${unresolvedCount > 20 ? "text-red-400" : unresolvedCount > 5 ? "text-amber-400" : "text-blue-400"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</AppLink>
|
||||
)}
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<AppLink
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_UNRESOLVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-red-200 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Unresolved</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-red-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4 w-4 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-red-600 mt-2">
|
||||
{unresolvedCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">needs attention</p>
|
||||
</div>
|
||||
</AppLink>
|
||||
|
||||
<AppLink
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_RESOLVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-green-200 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Resolved</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-green-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4 w-4 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-green-600 mt-2">
|
||||
{resolvedCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">fixed</p>
|
||||
</div>
|
||||
</AppLink>
|
||||
|
||||
<AppLink
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_ARCHIVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-gray-300 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Archived</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-600 mt-2">
|
||||
{archivedCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">dismissed</p>
|
||||
</div>
|
||||
</AppLink>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Resolution Rate</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-indigo-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4 w-4 text-indigo-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{resolutionRate}%
|
||||
</p>
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden mt-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-400"
|
||||
style={{ width: `${resolutionRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Most Frequent Exceptions - takes 2 columns */}
|
||||
{topExceptions.length > 0 && (
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Most Frequent Bugs
|
||||
</h3>
|
||||
</div>
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_UNRESOLVED] as Route,
|
||||
)}
|
||||
>
|
||||
View all
|
||||
</AppLink>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{topExceptions.map(
|
||||
(exception: TelemetryException, index: number) => {
|
||||
const maxOccurrences: number =
|
||||
topExceptions[0]?.occuranceCount || 1;
|
||||
const barWidth: number =
|
||||
((exception.occuranceCount || 0) / maxOccurrences) * 100;
|
||||
|
||||
const isNewToday: boolean = Boolean(
|
||||
exception.firstSeenAt &&
|
||||
new Date(exception.firstSeenAt) > oneDayAgo,
|
||||
);
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
key={exception.id?.toString() || index.toString()}
|
||||
className="block px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
to={
|
||||
exception.fingerprint
|
||||
? new Route(
|
||||
RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.EXCEPTIONS_VIEW_ROOT
|
||||
] as Route,
|
||||
)
|
||||
.toString()
|
||||
.replace(/\/?$/, `/${exception.fingerprint}`),
|
||||
)
|
||||
: RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.EXCEPTIONS_UNRESOLVED
|
||||
] as Route,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1.5">
|
||||
<div className="min-w-0 flex-1 mr-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TelemetryExceptionElement
|
||||
message={
|
||||
exception.message ||
|
||||
exception.exceptionType ||
|
||||
"Unknown exception"
|
||||
}
|
||||
isResolved={exception.isResolved || false}
|
||||
isArchived={exception.isArchived || false}
|
||||
className="text-sm"
|
||||
/>
|
||||
{isNewToday && (
|
||||
<span className="flex-shrink-0 text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded font-medium">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{exception.service && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{exception.service.name?.toString()}
|
||||
</span>
|
||||
)}
|
||||
{exception.exceptionType && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded font-mono">
|
||||
{exception.exceptionType}
|
||||
</span>
|
||||
)}
|
||||
{exception.environment && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">
|
||||
{exception.environment}
|
||||
</span>
|
||||
)}
|
||||
{exception.lastSeenAt && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{OneUptimeDate.fromNow(
|
||||
new Date(exception.lastSeenAt),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-sm font-bold text-gray-900">
|
||||
{(exception.occuranceCount || 0).toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">hits</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-red-400"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right sidebar: Affected Services + Recently Seen */}
|
||||
<div className="space-y-6">
|
||||
{/* Affected Services */}
|
||||
{serviceSummaries.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Affected Services
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{serviceSummaries.map((summary: ServiceExceptionSummary) => {
|
||||
const barWidth: number =
|
||||
(summary.unresolvedCount / maxServiceBugs) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={summary.service.id?.toString()}
|
||||
className="px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<TelemetryServiceElement
|
||||
telemetryService={summary.service}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-bold text-red-600">
|
||||
{summary.unresolvedCount}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
bugs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-red-400"
|
||||
style={{
|
||||
width: `${Math.max(barWidth, 3)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{summary.totalOccurrences.toLocaleString()} hits
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recently Active */}
|
||||
{recentExceptions.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Recently Active
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{recentExceptions
|
||||
.slice(0, 5)
|
||||
.map((exception: TelemetryException, index: number) => {
|
||||
return (
|
||||
<AppLink
|
||||
key={exception.id?.toString() || index.toString()}
|
||||
className="block px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
to={
|
||||
exception.fingerprint
|
||||
? new Route(
|
||||
RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.EXCEPTIONS_VIEW_ROOT
|
||||
] as Route,
|
||||
)
|
||||
.toString()
|
||||
.replace(
|
||||
/\/?$/,
|
||||
`/${exception.fingerprint}`,
|
||||
),
|
||||
)
|
||||
: RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.EXCEPTIONS_UNRESOLVED
|
||||
] as Route,
|
||||
)
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-900 truncate font-medium">
|
||||
{exception.message ||
|
||||
exception.exceptionType ||
|
||||
"Unknown"}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{exception.service && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{exception.service.name?.toString()}
|
||||
</span>
|
||||
)}
|
||||
{exception.lastSeenAt && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{OneUptimeDate.fromNow(
|
||||
new Date(exception.lastSeenAt),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExceptionsDashboard;
|
||||
@@ -257,7 +257,14 @@ const MonitorStepElement: FunctionComponent<ComponentProps> = (
|
||||
if (props.monitorType === MonitorType.CustomJavaScriptCode) {
|
||||
codeEditorPlaceholder = `
|
||||
// You can use axios, http modules here.
|
||||
await axios.get('https://example.com');
|
||||
const response = await axios.get('https://example.com');
|
||||
|
||||
// To capture custom metrics, use oneuptime.captureMetric(name, value, attributes)
|
||||
// These metrics can be charted on dashboards via the Metric Explorer.
|
||||
oneuptime.captureMetric('api.response.time', response.data.latency);
|
||||
oneuptime.captureMetric('api.queue.depth', response.data.queueDepth, {
|
||||
region: 'us-east-1'
|
||||
});
|
||||
|
||||
// when you want to return a value, use return statement with data as a prop.
|
||||
|
||||
@@ -275,6 +282,7 @@ return {
|
||||
// - page: Playwright Page object to interact with the browser
|
||||
// - browserType: Browser type in the current run context - Chromium, Firefox, Webkit
|
||||
// - screenSizeType: Screen size type in the current run context - Mobile, Tablet, Desktop
|
||||
// - oneuptime.captureMetric: Capture custom metrics for dashboards
|
||||
|
||||
await page.goto('https://playwright.dev/');
|
||||
|
||||
@@ -286,6 +294,11 @@ const screenshots = {};
|
||||
|
||||
screenshots['screenshot-name'] = await page.screenshot(); // you can save multiple screenshots and have them with different names.
|
||||
|
||||
// To capture custom metrics, use oneuptime.captureMetric(name, value, attributes)
|
||||
// These metrics can be charted on dashboards via the Metric Explorer.
|
||||
const startTime = Date.now();
|
||||
await page.waitForSelector('h1');
|
||||
oneuptime.captureMetric('page.load.time', Date.now() - startTime);
|
||||
|
||||
// To log data, use console.log
|
||||
console.log('Hello World');
|
||||
|
||||
@@ -284,9 +284,6 @@ const DashboardProjectPicker: FunctionComponent<ComponentProps> = (
|
||||
if (project && props.onProjectSelected) {
|
||||
props.onProjectSelected(project);
|
||||
}
|
||||
if (project && props.onProjectSelected) {
|
||||
props.onProjectSelected(project);
|
||||
}
|
||||
setShowModal(false);
|
||||
props.onProjectModalClose();
|
||||
}}
|
||||
|
||||
@@ -3,8 +3,10 @@ import OneUptimeDate from "Common/Types/Date";
|
||||
import XAxisType from "Common/UI/Components/Charts/Types/XAxis/XAxisType";
|
||||
import ChartGroup, {
|
||||
Chart,
|
||||
ChartMetricInfo,
|
||||
ChartType,
|
||||
} from "Common/UI/Components/Charts/ChartGroup/ChartGroup";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import { XAxisAggregateType } from "Common/UI/Components/Charts/Types/XAxis/XAxis";
|
||||
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
|
||||
@@ -201,6 +203,35 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
});
|
||||
}
|
||||
|
||||
// Build metric info for the info icon modal
|
||||
const metricAttributes: Dictionary<string> = {};
|
||||
const filterAttributes:
|
||||
| Dictionary<string | boolean | number>
|
||||
| undefined = queryConfig.metricQueryData.filterData.attributes as
|
||||
| Dictionary<string | boolean | number>
|
||||
| undefined;
|
||||
|
||||
if (filterAttributes) {
|
||||
for (const key of Object.keys(filterAttributes)) {
|
||||
metricAttributes[key] = String(filterAttributes[key]);
|
||||
}
|
||||
}
|
||||
|
||||
const metricInfo: ChartMetricInfo = {
|
||||
metricName:
|
||||
queryConfig.metricQueryData.filterData.metricName?.toString() || "",
|
||||
aggregationType:
|
||||
queryConfig.metricQueryData.filterData.aggegationType?.toString() ||
|
||||
"",
|
||||
attributes:
|
||||
Object.keys(metricAttributes).length > 0
|
||||
? metricAttributes
|
||||
: undefined,
|
||||
groupByAttribute:
|
||||
queryConfig.metricQueryData.filterData.groupByAttribute?.toString(),
|
||||
unit,
|
||||
};
|
||||
|
||||
const chart: Chart = {
|
||||
id: index.toString(),
|
||||
type: chartType,
|
||||
@@ -209,6 +240,7 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
queryConfig.metricQueryData.filterData.metricName?.toString() ||
|
||||
"",
|
||||
description: queryConfig.metricAliasData?.description || "",
|
||||
metricInfo,
|
||||
props: {
|
||||
data: chartSeries,
|
||||
xAxis: {
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import ServiceElement from "../Service/ServiceElement";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import AppLink from "../AppLink/AppLink";
|
||||
|
||||
interface ServiceMetricSummary {
|
||||
service: Service;
|
||||
metricCount: number;
|
||||
metricNames: Array<string>;
|
||||
metricUnits: Array<string>;
|
||||
metricDescriptions: Array<string>;
|
||||
hasSystemMetrics: boolean;
|
||||
hasAppMetrics: boolean;
|
||||
}
|
||||
|
||||
interface MetricCategory {
|
||||
name: string;
|
||||
count: number;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
const MetricsDashboard: FunctionComponent = (): ReactElement => {
|
||||
const [serviceSummaries, setServiceSummaries] = useState<
|
||||
Array<ServiceMetricSummary>
|
||||
>([]);
|
||||
const [totalMetricCount, setTotalMetricCount] = useState<number>(0);
|
||||
const [metricCategories, setMetricCategories] = useState<
|
||||
Array<MetricCategory>
|
||||
>([]);
|
||||
const [servicesWithNoMetrics, setServicesWithNoMetrics] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const categorizeMetric: (name: string) => string = (name: string): string => {
|
||||
const lower: string = name.toLowerCase();
|
||||
if (
|
||||
lower.includes("cpu") ||
|
||||
lower.includes("memory") ||
|
||||
lower.includes("disk") ||
|
||||
lower.includes("network") ||
|
||||
lower.includes("system") ||
|
||||
lower.includes("process") ||
|
||||
lower.includes("runtime") ||
|
||||
lower.includes("gc")
|
||||
) {
|
||||
return "System";
|
||||
}
|
||||
if (
|
||||
lower.includes("http") ||
|
||||
lower.includes("request") ||
|
||||
lower.includes("response") ||
|
||||
lower.includes("latency") ||
|
||||
lower.includes("duration") ||
|
||||
lower.includes("rpc")
|
||||
) {
|
||||
return "Request";
|
||||
}
|
||||
if (
|
||||
lower.includes("db") ||
|
||||
lower.includes("database") ||
|
||||
lower.includes("query") ||
|
||||
lower.includes("connection") ||
|
||||
lower.includes("pool")
|
||||
) {
|
||||
return "Database";
|
||||
}
|
||||
if (
|
||||
lower.includes("queue") ||
|
||||
lower.includes("message") ||
|
||||
lower.includes("kafka") ||
|
||||
lower.includes("rabbit") ||
|
||||
lower.includes("publish") ||
|
||||
lower.includes("consume")
|
||||
) {
|
||||
return "Messaging";
|
||||
}
|
||||
return "Custom";
|
||||
};
|
||||
|
||||
const loadDashboard: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
// Load services and metrics in parallel
|
||||
const [servicesResult, metricsResult] = await Promise.all([
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
ModelAPI.getList({
|
||||
modelType: MetricType,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
unit: true,
|
||||
description: true,
|
||||
services: {
|
||||
_id: true,
|
||||
name: true,
|
||||
serviceColor: true,
|
||||
} as any,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const services: Array<Service> = servicesResult.data || [];
|
||||
const metrics: Array<MetricType> = metricsResult.data || [];
|
||||
setTotalMetricCount(metrics.length);
|
||||
|
||||
// Build category counts
|
||||
const categoryMap: Map<string, number> = new Map();
|
||||
for (const metric of metrics) {
|
||||
const cat: string = categorizeMetric(metric.name || "");
|
||||
categoryMap.set(cat, (categoryMap.get(cat) || 0) + 1);
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, { color: string; bgColor: string }> =
|
||||
{
|
||||
System: { color: "text-blue-700", bgColor: "bg-blue-50" },
|
||||
Request: { color: "text-purple-700", bgColor: "bg-purple-50" },
|
||||
Database: { color: "text-amber-700", bgColor: "bg-amber-50" },
|
||||
Messaging: { color: "text-green-700", bgColor: "bg-green-50" },
|
||||
Custom: { color: "text-gray-700", bgColor: "bg-gray-50" },
|
||||
};
|
||||
|
||||
const categories: Array<MetricCategory> = Array.from(
|
||||
categoryMap.entries(),
|
||||
)
|
||||
.map(([name, count]: [string, number]) => {
|
||||
return {
|
||||
name,
|
||||
count,
|
||||
color: categoryColors[name]?.color || "text-gray-700",
|
||||
bgColor: categoryColors[name]?.bgColor || "bg-gray-50",
|
||||
};
|
||||
})
|
||||
.sort((a: MetricCategory, b: MetricCategory) => {
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
setMetricCategories(categories);
|
||||
|
||||
// Build per-service summaries
|
||||
const summaryMap: Map<string, ServiceMetricSummary> = new Map();
|
||||
|
||||
for (const service of services) {
|
||||
const serviceId: string = service.id?.toString() || "";
|
||||
summaryMap.set(serviceId, {
|
||||
service,
|
||||
metricCount: 0,
|
||||
metricNames: [],
|
||||
metricUnits: [],
|
||||
metricDescriptions: [],
|
||||
hasSystemMetrics: false,
|
||||
hasAppMetrics: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const metric of metrics) {
|
||||
const metricServices: Array<Service> = metric.services || [];
|
||||
const cat: string = categorizeMetric(metric.name || "");
|
||||
|
||||
for (const metricService of metricServices) {
|
||||
const serviceId: string =
|
||||
metricService._id?.toString() || metricService.id?.toString() || "";
|
||||
let summary: ServiceMetricSummary | undefined =
|
||||
summaryMap.get(serviceId);
|
||||
|
||||
if (!summary) {
|
||||
summary = {
|
||||
service: metricService,
|
||||
metricCount: 0,
|
||||
metricNames: [],
|
||||
metricUnits: [],
|
||||
metricDescriptions: [],
|
||||
hasSystemMetrics: false,
|
||||
hasAppMetrics: false,
|
||||
};
|
||||
summaryMap.set(serviceId, summary);
|
||||
}
|
||||
|
||||
summary.metricCount += 1;
|
||||
|
||||
if (cat === "System") {
|
||||
summary.hasSystemMetrics = true;
|
||||
} else {
|
||||
summary.hasAppMetrics = true;
|
||||
}
|
||||
|
||||
const metricName: string = metric.name || "";
|
||||
if (metricName && summary.metricNames.length < 6) {
|
||||
summary.metricNames.push(metricName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summariesWithData: Array<ServiceMetricSummary> = Array.from(
|
||||
summaryMap.values(),
|
||||
).filter((s: ServiceMetricSummary) => {
|
||||
return s.metricCount > 0;
|
||||
});
|
||||
|
||||
const noMetricsCount: number = services.length - summariesWithData.length;
|
||||
setServicesWithNoMetrics(noMetricsCount);
|
||||
|
||||
// Sort by metric count descending
|
||||
summariesWithData.sort(
|
||||
(a: ServiceMetricSummary, b: ServiceMetricSummary) => {
|
||||
return b.metricCount - a.metricCount;
|
||||
},
|
||||
);
|
||||
|
||||
setServiceSummaries(summariesWithData);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err as Error));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadDashboard();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (serviceSummaries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="h-8 w-8 text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No metrics data yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start sending metrics via OpenTelemetry, you{"'"}ll
|
||||
see coverage, categories, and per-service breakdowns here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxMetrics: number = Math.max(
|
||||
...serviceSummaries.map((s: ServiceMetricSummary) => {
|
||||
return s.metricCount;
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Hero Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Total Metrics</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-indigo-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-indigo-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{totalMetricCount}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">unique metric types</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
Services Reporting
|
||||
</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-green-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{serviceSummaries.length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">actively sending data</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Avg per Service</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{serviceSummaries.length > 0
|
||||
? Math.round(totalMetricCount / serviceSummaries.length)
|
||||
: 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">metrics per service</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">No Metrics</p>
|
||||
<div
|
||||
className={`h-9 w-9 rounded-lg flex items-center justify-center ${servicesWithNoMetrics > 0 ? "bg-amber-50" : "bg-gray-50"}`}
|
||||
>
|
||||
<svg
|
||||
className={`h-4.5 w-4.5 ${servicesWithNoMetrics > 0 ? "text-amber-600" : "text-gray-400"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-2 ${servicesWithNoMetrics > 0 ? "text-amber-600" : "text-gray-900"}`}
|
||||
>
|
||||
{servicesWithNoMetrics}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{servicesWithNoMetrics > 0
|
||||
? "services not instrumented"
|
||||
: "all services covered"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric Categories */}
|
||||
{metricCategories.length > 0 && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Metric Categories
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{metricCategories.map((cat: MetricCategory) => {
|
||||
const pct: number =
|
||||
totalMetricCount > 0
|
||||
? Math.round((cat.count / totalMetricCount) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={cat.name}
|
||||
className={`flex items-center gap-2.5 px-3.5 py-2 rounded-lg ${cat.bgColor}`}
|
||||
>
|
||||
<span className={`text-sm font-semibold ${cat.color}`}>
|
||||
{cat.count}
|
||||
</span>
|
||||
<span className={`text-sm ${cat.color}`}>{cat.name}</span>
|
||||
<span className={`text-xs ${cat.color} opacity-60`}>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Category distribution bar */}
|
||||
<div className="flex h-2 rounded-full overflow-hidden mt-3">
|
||||
{metricCategories.map((cat: MetricCategory) => {
|
||||
const pct: number =
|
||||
totalMetricCount > 0 ? (cat.count / totalMetricCount) * 100 : 0;
|
||||
const barColorMap: Record<string, string> = {
|
||||
System: "bg-blue-400",
|
||||
Request: "bg-purple-400",
|
||||
Database: "bg-amber-400",
|
||||
Messaging: "bg-green-400",
|
||||
Custom: "bg-gray-300",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={cat.name}
|
||||
className={`${barColorMap[cat.name] || "bg-gray-300"}`}
|
||||
style={{ width: `${Math.max(pct, 1)}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Cards */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Services Reporting Metrics
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Coverage and instrumentation per service
|
||||
</p>
|
||||
</div>
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.METRICS_LIST] as Route,
|
||||
)}
|
||||
>
|
||||
View all metrics
|
||||
</AppLink>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{serviceSummaries.map((summary: ServiceMetricSummary) => {
|
||||
const coverage: number =
|
||||
maxMetrics > 0
|
||||
? Math.round((summary.metricCount / maxMetrics) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
key={
|
||||
summary.service.id?.toString() ||
|
||||
summary.service._id?.toString()
|
||||
}
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_METRICS] as Route,
|
||||
{
|
||||
modelId: new ObjectID(
|
||||
(summary.service._id as string) ||
|
||||
summary.service.id?.toString() ||
|
||||
"",
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-indigo-200 hover:shadow-md transition-all duration-200">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<ServiceElement service={summary.service} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
{summary.hasSystemMetrics && (
|
||||
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full font-medium">
|
||||
System
|
||||
</span>
|
||||
)}
|
||||
{summary.hasAppMetrics && (
|
||||
<span className="text-xs bg-purple-50 text-purple-700 px-2 py-0.5 rounded-full font-medium">
|
||||
App
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric count with relative bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-end justify-between mb-1.5">
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
{summary.metricCount}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 mb-1">
|
||||
metrics
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-400 transition-all duration-500"
|
||||
style={{ width: `${Math.max(coverage, 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric name tags */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{summary.metricNames.map((name: string) => {
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-600 border border-gray-100"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{summary.metricCount > summary.metricNames.length && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-400">
|
||||
+{summary.metricCount - summary.metricNames.length} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricsDashboard;
|
||||
@@ -43,9 +43,9 @@ const MetricsTable: FunctionComponent<ComponentProps> = (
|
||||
sortBy="name"
|
||||
sortOrder={SortOrder.Ascending}
|
||||
cardProps={{
|
||||
title: "Metrics",
|
||||
title: "All Metrics",
|
||||
description:
|
||||
"Metrics are the individual data points that make up a service. They are the building blocks of a service and represent the work done by a single service.",
|
||||
"All metrics collected from your services. Click on a metric to explore its data in the chart viewer.",
|
||||
}}
|
||||
onViewPage={async (item: MetricType) => {
|
||||
const route: Route = RouteUtil.populateRouteParams(
|
||||
|
||||
@@ -12,7 +12,6 @@ import URL from "Common/Types/API/URL";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import AggregatedModel from "Common/Types/BaseDatabase/AggregatedModel";
|
||||
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
@@ -36,7 +35,7 @@ export default class MetricUtil {
|
||||
time: metricViewData.startAndEndDate!,
|
||||
name: queryConfig.metricQueryData.filterData.metricName!,
|
||||
attributes: queryConfig.metricQueryData.filterData
|
||||
.attributes as Dictionary<string | number | boolean>,
|
||||
.attributes as any,
|
||||
},
|
||||
aggregationType:
|
||||
(queryConfig.metricQueryData.filterData
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import AlertMetricType from "Common/Types/Alerts/AlertMetricType";
|
||||
import AlertMetricTypeUtil from "Common/Utils/Alerts/AlertMetricType";
|
||||
import MetricView from "../Metrics/MetricView";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import MetricQueryConfigData, {
|
||||
MetricChartType,
|
||||
} from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import RangeStartAndEndDateTime, {
|
||||
RangeStartAndEndDateTimeUtil,
|
||||
} from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import TimeRange from "Common/Types/Time/TimeRange";
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
|
||||
export interface ComponentProps {
|
||||
monitorId: ObjectID;
|
||||
}
|
||||
|
||||
const MonitorAlertMetrics: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const alertMetricTypes: Array<AlertMetricType> =
|
||||
AlertMetricTypeUtil.getAllAlertMetricTypes();
|
||||
|
||||
const [timeRange, setTimeRange] = useState<RangeStartAndEndDateTime>({
|
||||
range: TimeRange.PAST_ONE_DAY,
|
||||
});
|
||||
|
||||
type GetQueryConfigsFunction = () => Array<MetricQueryConfigData>;
|
||||
|
||||
const getQueryConfigs: GetQueryConfigsFunction =
|
||||
(): Array<MetricQueryConfigData> => {
|
||||
const queries: Array<MetricQueryConfigData> = [];
|
||||
|
||||
for (const metricType of alertMetricTypes) {
|
||||
queries.push({
|
||||
metricAliasData: {
|
||||
metricVariable: metricType,
|
||||
title: AlertMetricTypeUtil.getTitleByAlertMetricType(metricType),
|
||||
description:
|
||||
AlertMetricTypeUtil.getDescriptionByAlertMetricType(metricType),
|
||||
legend: AlertMetricTypeUtil.getLegendByAlertMetricType(metricType),
|
||||
legendUnit:
|
||||
AlertMetricTypeUtil.getLegendUnitByAlertMetricType(metricType),
|
||||
},
|
||||
metricQueryData: {
|
||||
filterData: {
|
||||
metricName: metricType,
|
||||
attributes: {
|
||||
monitorId: props.monitorId.toString(),
|
||||
projectId: ProjectUtil.getCurrentProjectId()?.toString() || "",
|
||||
},
|
||||
aggegationType:
|
||||
AlertMetricTypeUtil.getAggregationTypeByAlertMetricType(
|
||||
metricType,
|
||||
),
|
||||
},
|
||||
groupBy: undefined,
|
||||
},
|
||||
chartType: MetricChartType.BAR,
|
||||
});
|
||||
}
|
||||
|
||||
return queries;
|
||||
};
|
||||
|
||||
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate({
|
||||
range: TimeRange.PAST_ONE_DAY,
|
||||
}),
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
|
||||
const handleTimeRangeChange: (
|
||||
newTimeRange: RangeStartAndEndDateTime,
|
||||
) => void = useCallback((newTimeRange: RangeStartAndEndDateTime): void => {
|
||||
setTimeRange(newTimeRange);
|
||||
const dateRange: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(newTimeRange);
|
||||
setMetricViewData((prev: MetricViewData) => {
|
||||
return {
|
||||
...prev,
|
||||
startAndEndDate: dateRange,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Alert Metrics"
|
||||
description="Alert metrics for this monitor - count, time to acknowledge, time to resolve, and duration."
|
||||
rightElement={
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MetricView
|
||||
data={metricViewData}
|
||||
hideQueryElements={true}
|
||||
hideStartAndEndDate={true}
|
||||
hideCardInCharts={true}
|
||||
onChange={(data: MetricViewData) => {
|
||||
setMetricViewData({
|
||||
...data,
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitorAlertMetrics;
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import MetricView from "../Metrics/MetricView";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import RangeStartAndEndDateTime, {
|
||||
RangeStartAndEndDateTimeUtil,
|
||||
} from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import TimeRange from "Common/Types/Time/TimeRange";
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import EmptyState from "Common/UI/Components/EmptyState/EmptyState";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Metric from "Common/Models/AnalyticsModels/Metric";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import Search from "Common/Types/BaseDatabase/Search";
|
||||
|
||||
export interface ComponentProps {
|
||||
monitorId: ObjectID;
|
||||
}
|
||||
|
||||
const MonitorCustomMetrics: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [customMetricNames, setCustomMetricNames] = useState<Array<string>>([]);
|
||||
|
||||
const fetchCustomMetricNames: PromiseVoidFunction =
|
||||
async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
/*
|
||||
* Query ClickHouse for recent metrics belonging to this monitor
|
||||
* with names starting with "custom.monitor."
|
||||
* monitorId is stored as serviceId in the Metric table.
|
||||
*/
|
||||
const listResult: ListResult<Metric> =
|
||||
await AnalyticsModelAPI.getList<Metric>({
|
||||
modelType: Metric,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
serviceId: props.monitorId,
|
||||
name: new Search("custom.monitor.") as any,
|
||||
time: new InBetween(
|
||||
OneUptimeDate.addRemoveDays(
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
-30,
|
||||
),
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
) as any,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
limit: 1000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
});
|
||||
|
||||
// Extract distinct metric names
|
||||
const nameSet: Set<string> = new Set<string>();
|
||||
for (const metric of listResult.data) {
|
||||
const name: string = (metric as any).name || "";
|
||||
if (name.length > 0) {
|
||||
nameSet.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
const names: Array<string> = Array.from(nameSet).sort();
|
||||
setCustomMetricNames(names);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomMetricNames().catch((err: Error) => {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [timeRange, setTimeRange] = useState<RangeStartAndEndDateTime>({
|
||||
range: TimeRange.PAST_ONE_HOUR,
|
||||
});
|
||||
|
||||
const getQueryConfigs: () => Array<MetricQueryConfigData> =
|
||||
(): Array<MetricQueryConfigData> => {
|
||||
return customMetricNames.map(
|
||||
(metricName: string): MetricQueryConfigData => {
|
||||
const displayName: string = metricName.replace("custom.monitor.", "");
|
||||
|
||||
return {
|
||||
metricAliasData: {
|
||||
metricVariable: metricName,
|
||||
title: displayName,
|
||||
description: `Custom metric: ${displayName}`,
|
||||
legend: displayName,
|
||||
legendUnit: "",
|
||||
},
|
||||
metricQueryData: {
|
||||
filterData: {
|
||||
metricName: metricName,
|
||||
attributes: {
|
||||
monitorId: props.monitorId.toString(),
|
||||
projectId:
|
||||
ProjectUtil.getCurrentProjectId()?.toString() || "",
|
||||
},
|
||||
aggegationType: AggregationType.Avg,
|
||||
},
|
||||
groupBy: {
|
||||
attributes: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate({
|
||||
range: TimeRange.PAST_ONE_HOUR,
|
||||
}),
|
||||
queryConfigs: [],
|
||||
formulaConfigs: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (customMetricNames.length > 0) {
|
||||
setMetricViewData({
|
||||
startAndEndDate:
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(timeRange),
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
}
|
||||
}, [customMetricNames]);
|
||||
|
||||
const handleTimeRangeChange: (
|
||||
newTimeRange: RangeStartAndEndDateTime,
|
||||
) => void = useCallback((newTimeRange: RangeStartAndEndDateTime): void => {
|
||||
setTimeRange(newTimeRange);
|
||||
const dateRange: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(newTimeRange);
|
||||
setMetricViewData((prev: MetricViewData) => {
|
||||
return {
|
||||
...prev,
|
||||
startAndEndDate: dateRange,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (customMetricNames.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
id="no-custom-metrics"
|
||||
icon={IconProp.ChartBar}
|
||||
title="No Custom Metrics"
|
||||
description="No custom metrics have been captured yet. Use oneuptime.captureMetric() in your monitor script to capture custom metrics."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Custom Metrics"
|
||||
description="Custom metrics captured from your monitor script using oneuptime.captureMetric()."
|
||||
rightElement={
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MetricView
|
||||
data={metricViewData}
|
||||
hideQueryElements={true}
|
||||
hideStartAndEndDate={true}
|
||||
hideCardInCharts={true}
|
||||
onChange={(data: MetricViewData) => {
|
||||
setMetricViewData({
|
||||
...data,
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitorCustomMetrics;
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Search from "Common/Types/BaseDatabase/Search";
|
||||
import IncidentMetricType from "Common/Types/Incident/IncidentMetricType";
|
||||
import IncidentMetricTypeUtil from "Common/Utils/Incident/IncidentMetricType";
|
||||
import MetricView from "../Metrics/MetricView";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import MetricQueryConfigData, {
|
||||
MetricChartType,
|
||||
} from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import RangeStartAndEndDateTime, {
|
||||
RangeStartAndEndDateTimeUtil,
|
||||
} from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import TimeRange from "Common/Types/Time/TimeRange";
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
|
||||
export interface ComponentProps {
|
||||
monitorId: ObjectID;
|
||||
}
|
||||
|
||||
const MonitorIncidentMetrics: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const incidentMetricTypes: Array<IncidentMetricType> =
|
||||
IncidentMetricTypeUtil.getAllIncidentMetricTypes();
|
||||
|
||||
const [timeRange, setTimeRange] = useState<RangeStartAndEndDateTime>({
|
||||
range: TimeRange.PAST_ONE_DAY,
|
||||
});
|
||||
|
||||
type GetQueryConfigsFunction = () => Array<MetricQueryConfigData>;
|
||||
|
||||
const getQueryConfigs: GetQueryConfigsFunction =
|
||||
(): Array<MetricQueryConfigData> => {
|
||||
const queries: Array<MetricQueryConfigData> = [];
|
||||
|
||||
for (const metricType of incidentMetricTypes) {
|
||||
queries.push({
|
||||
metricAliasData: {
|
||||
metricVariable: metricType,
|
||||
title:
|
||||
IncidentMetricTypeUtil.getTitleByIncidentMetricType(metricType),
|
||||
description:
|
||||
IncidentMetricTypeUtil.getDescriptionByIncidentMetricType(
|
||||
metricType,
|
||||
),
|
||||
legend:
|
||||
IncidentMetricTypeUtil.getLegendByIncidentMetricType(metricType),
|
||||
legendUnit:
|
||||
IncidentMetricTypeUtil.getLegendUnitByIncidentMetricType(
|
||||
metricType,
|
||||
),
|
||||
},
|
||||
metricQueryData: {
|
||||
filterData: {
|
||||
metricName: metricType,
|
||||
attributes: {
|
||||
monitorIds: new Search(props.monitorId.toString()),
|
||||
projectId: ProjectUtil.getCurrentProjectId()?.toString() || "",
|
||||
},
|
||||
aggegationType:
|
||||
IncidentMetricTypeUtil.getAggregationTypeByIncidentMetricType(
|
||||
metricType,
|
||||
),
|
||||
},
|
||||
groupBy: undefined,
|
||||
},
|
||||
chartType: MetricChartType.BAR,
|
||||
});
|
||||
}
|
||||
|
||||
return queries;
|
||||
};
|
||||
|
||||
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate({
|
||||
range: TimeRange.PAST_ONE_DAY,
|
||||
}),
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
|
||||
const handleTimeRangeChange: (
|
||||
newTimeRange: RangeStartAndEndDateTime,
|
||||
) => void = useCallback((newTimeRange: RangeStartAndEndDateTime): void => {
|
||||
setTimeRange(newTimeRange);
|
||||
const dateRange: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(newTimeRange);
|
||||
setMetricViewData((prev: MetricViewData) => {
|
||||
return {
|
||||
...prev,
|
||||
startAndEndDate: dateRange,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Incident Metrics"
|
||||
description="Incident metrics for this monitor - count, time to acknowledge, time to resolve, and duration."
|
||||
rightElement={
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MetricView
|
||||
data={metricViewData}
|
||||
hideQueryElements={true}
|
||||
hideStartAndEndDate={true}
|
||||
hideCardInCharts={true}
|
||||
onChange={(data: MetricViewData) => {
|
||||
setMetricViewData({
|
||||
...data,
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitorIncidentMetrics;
|
||||
@@ -108,7 +108,7 @@ const DashboardNavbar: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
{
|
||||
title: "Metrics",
|
||||
description: "Monitor system metrics.",
|
||||
description: "Monitor and visualize system metrics across your services.",
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.METRICS] as Route),
|
||||
activeRoute: RouteMap[PageMap.METRICS],
|
||||
icon: IconProp.Heartbeat,
|
||||
@@ -117,7 +117,7 @@ const DashboardNavbar: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
{
|
||||
title: "Traces",
|
||||
description: "Distributed tracing analysis.",
|
||||
description: "Track requests across your services.",
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.TRACES] as Route),
|
||||
activeRoute: RouteMap[PageMap.TRACES],
|
||||
icon: IconProp.Waterfall,
|
||||
@@ -125,8 +125,8 @@ const DashboardNavbar: FunctionComponent<ComponentProps> = (
|
||||
category: "Observability",
|
||||
},
|
||||
{
|
||||
title: "Profiles",
|
||||
description: "CPU and memory profiling.",
|
||||
title: "Performance Profiles",
|
||||
description: "Find slow functions and memory hotspots.",
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.PROFILES] as Route),
|
||||
activeRoute: RouteMap[PageMap.PROFILES],
|
||||
icon: IconProp.Fire,
|
||||
@@ -135,7 +135,7 @@ const DashboardNavbar: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
{
|
||||
title: "Exceptions",
|
||||
description: "Catch and fix bugs early.",
|
||||
description: "Track and resolve bugs across your services.",
|
||||
route: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS] as Route,
|
||||
),
|
||||
|
||||
@@ -184,7 +184,8 @@ const DiffFlamegraph: FunctionComponent<DiffFlamegraphProps> = (
|
||||
) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No profile data found in the selected time ranges.
|
||||
No performance data found in the selected time ranges. Try adjusting the
|
||||
time periods.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -323,14 +324,14 @@ const DiffFlamegraph: FunctionComponent<DiffFlamegraphProps> = (
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center space-x-4 text-xs text-gray-600">
|
||||
<span className="font-medium">Legend:</span>
|
||||
<span className="font-medium">What the colors mean:</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className="inline-block w-3 h-3 rounded bg-red-500" />
|
||||
<span>Regression (slower)</span>
|
||||
<span>Got slower</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className="inline-block w-3 h-3 rounded bg-green-500" />
|
||||
<span>Improvement (faster)</span>
|
||||
<span>Got faster</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className="inline-block w-3 h-3 rounded bg-gray-400" />
|
||||
@@ -364,9 +365,9 @@ const DiffFlamegraph: FunctionComponent<DiffFlamegraphProps> = (
|
||||
<div className="text-gray-300">{tooltip.fileName}</div>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
Baseline: {tooltip.baselineValue.toLocaleString()}
|
||||
Before: {tooltip.baselineValue.toLocaleString()}
|
||||
</div>
|
||||
<div>Comparison: {tooltip.comparisonValue.toLocaleString()}</div>
|
||||
<div>After: {tooltip.comparisonValue.toLocaleString()}</div>
|
||||
<div
|
||||
className={
|
||||
tooltip.delta > 0
|
||||
@@ -376,7 +377,7 @@ const DiffFlamegraph: FunctionComponent<DiffFlamegraphProps> = (
|
||||
: ""
|
||||
}
|
||||
>
|
||||
Delta: {tooltip.delta > 0 ? "+" : ""}
|
||||
Change: {tooltip.delta > 0 ? "+" : ""}
|
||||
{tooltip.delta.toLocaleString()} (
|
||||
{tooltip.deltaPercent >= 0 ? "+" : ""}
|
||||
{tooltip.deltaPercent.toFixed(1)}%)
|
||||
|
||||
@@ -211,7 +211,8 @@ const ProfileFlamegraph: FunctionComponent<ProfileFlamegraphProps> = (
|
||||
if (samples.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No profile samples found for this profile.
|
||||
No performance data found for this profile. This can happen if the
|
||||
profile was recently captured and data is still being processed.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -325,19 +326,25 @@ const ProfileFlamegraph: FunctionComponent<ProfileFlamegraphProps> = (
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center space-x-4 text-xs text-gray-600">
|
||||
<span className="font-medium">Frame Types:</span>
|
||||
{["kernel", "native", "jvm", "cpython", "go", "v8js", "unknown"].map(
|
||||
(type: string) => {
|
||||
return (
|
||||
<span key={type} className="flex items-center space-x-1">
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded ${ProfileUtil.getFrameTypeColor(type)}`}
|
||||
/>
|
||||
<span>{type}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<span className="font-medium">Code Type:</span>
|
||||
{[
|
||||
{ key: "kernel", label: "System / Kernel" },
|
||||
{ key: "native", label: "Native Code" },
|
||||
{ key: "jvm", label: "Java / JVM" },
|
||||
{ key: "cpython", label: "Python" },
|
||||
{ key: "go", label: "Go" },
|
||||
{ key: "v8js", label: "JavaScript" },
|
||||
{ key: "unknown", label: "Other" },
|
||||
].map((item: { key: string; label: string }) => {
|
||||
return (
|
||||
<span key={item.key} className="flex items-center space-x-1">
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded ${ProfileUtil.getFrameTypeColor(item.key)}`}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -359,8 +366,10 @@ const ProfileFlamegraph: FunctionComponent<ProfileFlamegraphProps> = (
|
||||
{tooltip.fileName && (
|
||||
<div className="text-gray-300">{tooltip.fileName}</div>
|
||||
)}
|
||||
<div className="mt-1">Self: {tooltip.selfValue.toLocaleString()}</div>
|
||||
<div>Total: {tooltip.totalValue.toLocaleString()}</div>
|
||||
<div className="mt-1">
|
||||
Own Time: {tooltip.selfValue.toLocaleString()}
|
||||
</div>
|
||||
<div>Total Time: {tooltip.totalValue.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -204,7 +204,7 @@ const ProfileFunctionList: FunctionComponent<ProfileFunctionListProps> = (
|
||||
if (samples.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No profile samples found for this profile.
|
||||
No performance data found for this profile.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -228,7 +228,7 @@ const ProfileFunctionList: FunctionComponent<ProfileFunctionListProps> = (
|
||||
handleSort("fileName");
|
||||
}}
|
||||
>
|
||||
File{getSortIndicator("fileName")}
|
||||
Source File{getSortIndicator("fileName")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
@@ -236,7 +236,7 @@ const ProfileFunctionList: FunctionComponent<ProfileFunctionListProps> = (
|
||||
handleSort("selfValue");
|
||||
}}
|
||||
>
|
||||
Self Value{getSortIndicator("selfValue")}
|
||||
Own Time{getSortIndicator("selfValue")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
@@ -244,7 +244,7 @@ const ProfileFunctionList: FunctionComponent<ProfileFunctionListProps> = (
|
||||
handleSort("totalValue");
|
||||
}}
|
||||
>
|
||||
Total Value{getSortIndicator("totalValue")}
|
||||
Total Time{getSortIndicator("totalValue")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
@@ -252,7 +252,7 @@ const ProfileFunctionList: FunctionComponent<ProfileFunctionListProps> = (
|
||||
handleSort("sampleCount");
|
||||
}}
|
||||
>
|
||||
Samples{getSortIndicator("sampleCount")}
|
||||
Occurrences{getSortIndicator("sampleCount")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -28,6 +28,7 @@ import ListResult from "Common/Types/BaseDatabase/ListResult";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import ServiceElement from "../Service/ServiceElement";
|
||||
import ProfileUtil from "../../Utils/ProfileUtil";
|
||||
|
||||
export interface ComponentProps {
|
||||
modelId?: ObjectID | undefined;
|
||||
@@ -196,23 +197,28 @@ const ProfileTable: FunctionComponent<ComponentProps> = (
|
||||
isDeleteable={false}
|
||||
isEditable={false}
|
||||
isCreateable={false}
|
||||
singularName="Profile"
|
||||
pluralName="Profiles"
|
||||
name="Profiles"
|
||||
singularName="Performance Profile"
|
||||
pluralName="Performance Profiles"
|
||||
name="Performance Profiles"
|
||||
isViewable={true}
|
||||
cardProps={
|
||||
props.isMinimalTable
|
||||
? undefined
|
||||
: {
|
||||
title: "Profiles",
|
||||
title: "Performance Profiles",
|
||||
description:
|
||||
"Continuous profiling data from your services. Profiles help you understand CPU, memory, and allocation hotspots in your applications.",
|
||||
"See where your application spends the most time and memory. Use profiles to find slow functions and optimize performance.",
|
||||
}
|
||||
}
|
||||
query={query}
|
||||
selectMoreFields={{
|
||||
profileId: true,
|
||||
}}
|
||||
showViewIdButton={true}
|
||||
noItemsMessage={
|
||||
props.noItemsMessage ? props.noItemsMessage : "No profiles found."
|
||||
props.noItemsMessage
|
||||
? props.noItemsMessage
|
||||
: "No performance profiles found. Once your services start sending profiling data, they will appear here."
|
||||
}
|
||||
showRefreshButton={true}
|
||||
sortBy="startTime"
|
||||
@@ -245,7 +251,7 @@ const ProfileTable: FunctionComponent<ComponentProps> = (
|
||||
profileType: true,
|
||||
},
|
||||
type: FieldType.Text,
|
||||
title: "Profile Type",
|
||||
title: "Type",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
@@ -259,7 +265,7 @@ const ProfileTable: FunctionComponent<ComponentProps> = (
|
||||
startTime: true,
|
||||
},
|
||||
type: FieldType.DateTime,
|
||||
title: "Start Time",
|
||||
title: "Captured At",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
@@ -273,20 +279,6 @@ const ProfileTable: FunctionComponent<ComponentProps> = (
|
||||
]}
|
||||
onAdvancedFiltersToggle={handleAdvancedFiltersToggle}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
profileId: true,
|
||||
},
|
||||
title: "Profile ID",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
profileType: true,
|
||||
},
|
||||
title: "Profile Type",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
serviceId: true,
|
||||
@@ -312,18 +304,40 @@ const ProfileTable: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
profileType: true,
|
||||
},
|
||||
title: "Type",
|
||||
type: FieldType.Element,
|
||||
getElement: (profile: Profile): ReactElement => {
|
||||
const profileType: string = profile.profileType || "unknown";
|
||||
const displayName: string =
|
||||
ProfileUtil.getProfileTypeDisplayName(profileType);
|
||||
const badgeColor: string =
|
||||
ProfileUtil.getProfileTypeBadgeColor(profileType);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${badgeColor}`}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
sampleCount: true,
|
||||
},
|
||||
title: "Samples",
|
||||
title: "Data Points",
|
||||
type: FieldType.Number,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
startTime: true,
|
||||
},
|
||||
title: "Start Time",
|
||||
title: "Captured At",
|
||||
type: FieldType.DateTime,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -168,7 +168,7 @@ const ProfileTimeline: FunctionComponent<ProfileTimelineProps> = (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
Profile Density ({profiles.length} profiles)
|
||||
Activity ({profiles.length} profiles captured)
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(props.startTime, true)} —{" "}
|
||||
|
||||
@@ -12,12 +12,12 @@ interface ProfileTypeOption {
|
||||
|
||||
const profileTypeOptions: Array<ProfileTypeOption> = [
|
||||
{ label: "All Types", value: undefined },
|
||||
{ label: "CPU", value: "cpu" },
|
||||
{ label: "Wall", value: "wall" },
|
||||
{ label: "Alloc Objects", value: "alloc_objects" },
|
||||
{ label: "Alloc Space", value: "alloc_space" },
|
||||
{ label: "Goroutine", value: "goroutine" },
|
||||
{ label: "Contention", value: "contention" },
|
||||
{ label: "CPU Usage", value: "cpu" },
|
||||
{ label: "Wall Clock Time", value: "wall" },
|
||||
{ label: "Memory Allocations (Count)", value: "alloc_objects" },
|
||||
{ label: "Memory Allocations (Size)", value: "alloc_space" },
|
||||
{ label: "Goroutines", value: "goroutine" },
|
||||
{ label: "Lock Contention", value: "contention" },
|
||||
];
|
||||
|
||||
const ProfileTypeSelector: FunctionComponent<ProfileTypeSelectorProps> = (
|
||||
@@ -25,7 +25,7 @@ const ProfileTypeSelector: FunctionComponent<ProfileTypeSelectorProps> = (
|
||||
): ReactElement => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-sm font-medium text-gray-700">Profile Type:</label>
|
||||
<label className="text-sm font-medium text-gray-700">Show:</label>
|
||||
<select
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={props.selectedProfileType || ""}
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/Utils/API";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import Profile from "Common/Models/AnalyticsModels/Profile";
|
||||
import AnalyticsModelAPI from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import ServiceElement from "../Service/ServiceElement";
|
||||
import ProfileUtil from "../../Utils/ProfileUtil";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import AppLink from "../AppLink/AppLink";
|
||||
|
||||
interface ServiceProfileSummary {
|
||||
service: Service;
|
||||
profileCount: number;
|
||||
latestProfileTime: Date | null;
|
||||
profileTypes: Array<string>;
|
||||
totalSamples: number;
|
||||
}
|
||||
|
||||
interface FunctionHotspot {
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
sampleCount: number;
|
||||
frameType: string;
|
||||
}
|
||||
|
||||
interface ProfileTypeStats {
|
||||
type: string;
|
||||
count: number;
|
||||
displayName: string;
|
||||
badgeColor: string;
|
||||
}
|
||||
|
||||
const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
const [serviceSummaries, setServiceSummaries] = useState<
|
||||
Array<ServiceProfileSummary>
|
||||
>([]);
|
||||
const [hotspots, setHotspots] = useState<Array<FunctionHotspot>>([]);
|
||||
const [profileTypeStats, setProfileTypeStats] = useState<
|
||||
Array<ProfileTypeStats>
|
||||
>([]);
|
||||
const [totalProfileCount, setTotalProfileCount] = useState<number>(0);
|
||||
const [totalSampleCount, setTotalSampleCount] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loadDashboard: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
const oneHourAgo: Date = OneUptimeDate.addRemoveHours(now, -1);
|
||||
|
||||
const [servicesResult, profilesResult] = await Promise.all([
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
AnalyticsModelAPI.getList({
|
||||
modelType: Profile,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
startTime: new InBetween(oneHourAgo, now),
|
||||
},
|
||||
select: {
|
||||
serviceId: true,
|
||||
profileType: true,
|
||||
startTime: true,
|
||||
sampleCount: true,
|
||||
},
|
||||
limit: 5000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
startTime: SortOrder.Descending,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const services: Array<Service> = servicesResult.data || [];
|
||||
const profiles: Array<Profile> = profilesResult.data || [];
|
||||
|
||||
setTotalProfileCount(profiles.length);
|
||||
|
||||
// Build per-service summaries
|
||||
const summaryMap: Map<string, ServiceProfileSummary> = new Map();
|
||||
const typeCountMap: Map<string, number> = new Map();
|
||||
let totalSamples: number = 0;
|
||||
|
||||
for (const service of services) {
|
||||
const serviceId: string = service.id?.toString() || "";
|
||||
summaryMap.set(serviceId, {
|
||||
service,
|
||||
profileCount: 0,
|
||||
latestProfileTime: null,
|
||||
profileTypes: [],
|
||||
totalSamples: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const profile of profiles) {
|
||||
const serviceId: string = profile.serviceId?.toString() || "";
|
||||
const summary: ServiceProfileSummary | undefined =
|
||||
summaryMap.get(serviceId);
|
||||
|
||||
if (!summary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.profileCount += 1;
|
||||
|
||||
const samples: number = (profile.sampleCount as number) || 0;
|
||||
summary.totalSamples += samples;
|
||||
totalSamples += samples;
|
||||
|
||||
const profileTime: Date | undefined = profile.startTime
|
||||
? new Date(profile.startTime)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
profileTime &&
|
||||
(!summary.latestProfileTime ||
|
||||
profileTime > summary.latestProfileTime)
|
||||
) {
|
||||
summary.latestProfileTime = profileTime;
|
||||
}
|
||||
|
||||
const profileType: string = profile.profileType || "";
|
||||
if (profileType && !summary.profileTypes.includes(profileType)) {
|
||||
summary.profileTypes.push(profileType);
|
||||
}
|
||||
|
||||
// Track global type stats
|
||||
typeCountMap.set(profileType, (typeCountMap.get(profileType) || 0) + 1);
|
||||
}
|
||||
|
||||
setTotalSampleCount(totalSamples);
|
||||
|
||||
// Build profile type stats
|
||||
const typeStats: Array<ProfileTypeStats> = Array.from(
|
||||
typeCountMap.entries(),
|
||||
)
|
||||
.map(([type, count]: [string, number]) => {
|
||||
return {
|
||||
type,
|
||||
count,
|
||||
displayName: ProfileUtil.getProfileTypeDisplayName(type),
|
||||
badgeColor: ProfileUtil.getProfileTypeBadgeColor(type),
|
||||
};
|
||||
})
|
||||
.sort((a: ProfileTypeStats, b: ProfileTypeStats) => {
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
setProfileTypeStats(typeStats);
|
||||
|
||||
// Only show services that have profiles
|
||||
const summariesWithData: Array<ServiceProfileSummary> = Array.from(
|
||||
summaryMap.values(),
|
||||
).filter((s: ServiceProfileSummary) => {
|
||||
return s.profileCount > 0;
|
||||
});
|
||||
|
||||
summariesWithData.sort(
|
||||
(a: ServiceProfileSummary, b: ServiceProfileSummary) => {
|
||||
return b.profileCount - a.profileCount;
|
||||
},
|
||||
);
|
||||
|
||||
setServiceSummaries(summariesWithData);
|
||||
|
||||
// Load top hotspots
|
||||
try {
|
||||
const hotspotsResponse: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
"/telemetry/profiles/function-list",
|
||||
),
|
||||
data: {
|
||||
startTime: oneHourAgo.toISOString(),
|
||||
endTime: now.toISOString(),
|
||||
limit: 10,
|
||||
sortBy: "selfValue",
|
||||
},
|
||||
headers: {
|
||||
...ModelAPI.getCommonHeaders(),
|
||||
},
|
||||
});
|
||||
|
||||
if (hotspotsResponse instanceof HTTPErrorResponse) {
|
||||
throw hotspotsResponse;
|
||||
}
|
||||
|
||||
const functions: Array<FunctionHotspot> = (hotspotsResponse.data[
|
||||
"functions"
|
||||
] || []) as unknown as Array<FunctionHotspot>;
|
||||
setHotspots(functions);
|
||||
} catch {
|
||||
setHotspots([]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadDashboard();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (serviceSummaries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="h-8 w-8 text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No profiling data yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start sending profiling data, you{"'"}ll see
|
||||
performance hotspots, resource usage patterns, and optimization
|
||||
opportunities.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxProfiles: number = Math.max(
|
||||
...serviceSummaries.map((s: ServiceProfileSummary) => {
|
||||
return s.profileCount;
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Hero Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Profiles</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-indigo-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-indigo-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{totalProfileCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">last hour</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Services</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-green-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{serviceSummaries.length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">being profiled</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Samples</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{totalSampleCount >= 1_000_000
|
||||
? `${(totalSampleCount / 1_000_000).toFixed(1)}M`
|
||||
: totalSampleCount >= 1_000
|
||||
? `${(totalSampleCount / 1_000).toFixed(1)}K`
|
||||
: totalSampleCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">total samples</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Hotspots</p>
|
||||
<div
|
||||
className={`h-9 w-9 rounded-lg flex items-center justify-center ${hotspots.length > 0 ? "bg-orange-50" : "bg-gray-50"}`}
|
||||
>
|
||||
<svg
|
||||
className={`h-4.5 w-4.5 ${hotspots.length > 0 ? "text-orange-600" : "text-gray-400"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 6.51 6.51 0 009 4.572c.163.07.322.148.476.232M12 18.75a6.743 6.743 0 002.14-1.234M12 18.75a6.72 6.72 0 01-2.14-1.234M12 18.75V21m-4.773-4.227l-1.591 1.591M5.636 5.636L4.045 4.045m0 15.91l1.591-1.591M18.364 5.636l1.591-1.591M21 12h-2.25M4.5 12H2.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{hotspots.length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">functions identified</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Type Distribution */}
|
||||
{profileTypeStats.length > 0 && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Profile Types Collected
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{profileTypeStats.map((stat: ProfileTypeStats) => {
|
||||
const pct: number =
|
||||
totalProfileCount > 0
|
||||
? Math.round((stat.count / totalProfileCount) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={stat.type}
|
||||
className={`flex items-center gap-2.5 px-3.5 py-2 rounded-lg ${stat.badgeColor}`}
|
||||
>
|
||||
<span className="text-sm font-semibold">{stat.count}</span>
|
||||
<span className="text-sm">{stat.displayName}</span>
|
||||
<span className="text-xs opacity-60">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Cards */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Services Being Profiled
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Performance data collected in the last hour
|
||||
</p>
|
||||
</div>
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.PROFILES_LIST] as Route,
|
||||
)}
|
||||
>
|
||||
View all profiles
|
||||
</AppLink>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{serviceSummaries.map((summary: ServiceProfileSummary) => {
|
||||
const coverage: number =
|
||||
maxProfiles > 0
|
||||
? Math.round((summary.profileCount / maxProfiles) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
key={summary.service.id?.toString()}
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_PROFILES] as Route,
|
||||
{
|
||||
modelId: new ObjectID(summary.service._id as string),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-indigo-200 hover:shadow-md transition-all duration-200">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<ServiceElement service={summary.service} />
|
||||
<span className="text-xs bg-green-50 text-green-700 px-2 py-0.5 rounded-full font-medium">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Profiles</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{summary.profileCount}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Samples</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{summary.totalSamples >= 1_000
|
||||
? `${(summary.totalSamples / 1_000).toFixed(1)}K`
|
||||
: summary.totalSamples.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile volume bar */}
|
||||
<div className="mb-3">
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-400 transition-all duration-500"
|
||||
style={{ width: `${Math.max(coverage, 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile type badges */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{summary.profileTypes.map((profileType: string) => {
|
||||
const badgeColor: string =
|
||||
ProfileUtil.getProfileTypeBadgeColor(profileType);
|
||||
return (
|
||||
<span
|
||||
key={profileType}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badgeColor}`}
|
||||
>
|
||||
{ProfileUtil.getProfileTypeDisplayName(profileType)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{summary.latestProfileTime && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
Last captured{" "}
|
||||
{OneUptimeDate.fromNow(summary.latestProfileTime)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Hotspots */}
|
||||
{hotspots.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Top Performance Hotspots
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Functions consuming the most resources across all services
|
||||
</p>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{hotspots.map((fn: FunctionHotspot, index: number) => {
|
||||
const maxSelf: number = hotspots[0]?.selfValue || 1;
|
||||
const barWidth: number = (fn.selfValue / maxSelf) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${fn.functionName}-${fn.fileName}-${index}`}
|
||||
className="px-5 py-3.5 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1.5">
|
||||
<div className="min-w-0 flex-1 mr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 font-mono w-5 flex-shrink-0">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<p className="font-mono text-sm text-gray-900 truncate">
|
||||
{fn.functionName}
|
||||
</p>
|
||||
{fn.frameType && (
|
||||
<span className="flex-shrink-0 text-xs px-1.5 py-0.5 rounded font-medium bg-gray-100 text-gray-600">
|
||||
{fn.frameType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{fn.fileName && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 ml-7 truncate">
|
||||
{fn.fileName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-5 flex-shrink-0">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold font-mono text-gray-900">
|
||||
{fn.selfValue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">own time</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-mono text-gray-700">
|
||||
{fn.totalValue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">total</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-mono text-gray-700">
|
||||
{fn.sampleCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">samples</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-7">
|
||||
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-orange-400"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilesDashboard;
|
||||
@@ -86,9 +86,11 @@ function replacePlaceholders(
|
||||
otlpUrl: string,
|
||||
otlpHost: string,
|
||||
token: string,
|
||||
pyroscopeUrl: string,
|
||||
): string {
|
||||
return code
|
||||
.replace(/<YOUR_ONEUPTIME_OTLP_URL>/g, otlpUrl)
|
||||
.replace(/<YOUR_ONEUPTIME_URL>/g, otlpUrl)
|
||||
.replace(/<YOUR_ONEUPTIME_PYROSCOPE_URL>/g, pyroscopeUrl)
|
||||
.replace(/<YOUR_ONEUPTIME_OTLP_HOST>/g, otlpHost)
|
||||
.replace(/<YOUR_ONEUPTIME_TOKEN>/g, token);
|
||||
}
|
||||
@@ -261,19 +263,19 @@ import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
|
||||
const sdk = new NodeSDK({
|
||||
serviceName: 'my-service',
|
||||
traceExporter: new OTLPTraceExporter({
|
||||
url: '<YOUR_ONEUPTIME_OTLP_URL>/v1/traces',
|
||||
url: '<YOUR_ONEUPTIME_URL>/v1/traces',
|
||||
headers: { 'x-oneuptime-token': '<YOUR_ONEUPTIME_TOKEN>' },
|
||||
}),
|
||||
metricReader: new PeriodicExportingMetricReader({
|
||||
exporter: new OTLPMetricExporter({
|
||||
url: '<YOUR_ONEUPTIME_OTLP_URL>/v1/metrics',
|
||||
url: '<YOUR_ONEUPTIME_URL>/v1/metrics',
|
||||
headers: { 'x-oneuptime-token': '<YOUR_ONEUPTIME_TOKEN>' },
|
||||
}),
|
||||
}),
|
||||
logRecordProcessors: [
|
||||
new BatchLogRecordProcessor(
|
||||
new OTLPLogExporter({
|
||||
url: '<YOUR_ONEUPTIME_OTLP_URL>/v1/logs',
|
||||
url: '<YOUR_ONEUPTIME_URL>/v1/logs',
|
||||
headers: { 'x-oneuptime-token': '<YOUR_ONEUPTIME_TOKEN>' },
|
||||
})
|
||||
),
|
||||
@@ -303,7 +305,7 @@ trace_provider = TracerProvider(resource=resource)
|
||||
trace_provider.add_span_processor(
|
||||
BatchSpanProcessor(
|
||||
OTLPSpanExporter(
|
||||
endpoint="<YOUR_ONEUPTIME_OTLP_URL>",
|
||||
endpoint="<YOUR_ONEUPTIME_URL>",
|
||||
headers={"x-oneuptime-token": "<YOUR_ONEUPTIME_TOKEN>"},
|
||||
)
|
||||
)
|
||||
@@ -313,7 +315,7 @@ trace.set_tracer_provider(trace_provider)
|
||||
# Metrics
|
||||
metric_reader = PeriodicExportingMetricReader(
|
||||
OTLPMetricExporter(
|
||||
endpoint="<YOUR_ONEUPTIME_OTLP_URL>",
|
||||
endpoint="<YOUR_ONEUPTIME_URL>",
|
||||
headers={"x-oneuptime-token": "<YOUR_ONEUPTIME_TOKEN>"},
|
||||
)
|
||||
)
|
||||
@@ -363,7 +365,7 @@ func initTracer() (*sdktrace.TracerProvider, error) {
|
||||
code: `# Run your Java application with the OpenTelemetry agent:
|
||||
java -javaagent:opentelemetry-javaagent.jar \\
|
||||
-Dotel.service.name=my-service \\
|
||||
-Dotel.exporter.otlp.endpoint=<YOUR_ONEUPTIME_OTLP_URL> \\
|
||||
-Dotel.exporter.otlp.endpoint=<YOUR_ONEUPTIME_URL> \\
|
||||
-Dotel.exporter.otlp.headers="x-oneuptime-token=<YOUR_ONEUPTIME_TOKEN>" \\
|
||||
-Dotel.exporter.otlp.protocol=http/protobuf \\
|
||||
-Dotel.metrics.exporter=otlp \\
|
||||
@@ -393,7 +395,7 @@ builder.Services.AddOpenTelemetry()
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter(options => {
|
||||
options.Endpoint = new Uri("<YOUR_ONEUPTIME_OTLP_URL>");
|
||||
options.Endpoint = new Uri("<YOUR_ONEUPTIME_URL>");
|
||||
options.Headers = "x-oneuptime-token=<YOUR_ONEUPTIME_TOKEN>";
|
||||
options.Protocol = OtlpExportProtocol.HttpProtobuf;
|
||||
})
|
||||
@@ -402,7 +404,7 @@ builder.Services.AddOpenTelemetry()
|
||||
.SetResourceBuilder(resourceBuilder)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddOtlpExporter(options => {
|
||||
options.Endpoint = new Uri("<YOUR_ONEUPTIME_OTLP_URL>");
|
||||
options.Endpoint = new Uri("<YOUR_ONEUPTIME_URL>");
|
||||
options.Headers = "x-oneuptime-token=<YOUR_ONEUPTIME_TOKEN>";
|
||||
options.Protocol = OtlpExportProtocol.HttpProtobuf;
|
||||
})
|
||||
@@ -412,7 +414,7 @@ builder.Services.AddOpenTelemetry()
|
||||
builder.Logging.AddOpenTelemetry(logging => {
|
||||
logging.SetResourceBuilder(resourceBuilder);
|
||||
logging.AddOtlpExporter(options => {
|
||||
options.Endpoint = new Uri("<YOUR_ONEUPTIME_OTLP_URL>");
|
||||
options.Endpoint = new Uri("<YOUR_ONEUPTIME_URL>");
|
||||
options.Headers = "x-oneuptime-token=<YOUR_ONEUPTIME_TOKEN>";
|
||||
options.Protocol = OtlpExportProtocol.HttpProtobuf;
|
||||
});
|
||||
@@ -440,7 +442,7 @@ fn init_tracer() -> sdktrace::TracerProvider {
|
||||
|
||||
let exporter = opentelemetry_otlp::new_exporter()
|
||||
.http()
|
||||
.with_endpoint("<YOUR_ONEUPTIME_OTLP_URL>")
|
||||
.with_endpoint("<YOUR_ONEUPTIME_URL>")
|
||||
.with_headers(headers);
|
||||
|
||||
opentelemetry_otlp::new_pipeline()
|
||||
@@ -471,7 +473,7 @@ use OpenTelemetry\\SemConv\\ResourceAttributes;
|
||||
use OpenTelemetry\\Contrib\\Otlp\\HttpTransportFactory;
|
||||
|
||||
$transport = (new HttpTransportFactory())->create(
|
||||
'<YOUR_ONEUPTIME_OTLP_URL>/v1/traces',
|
||||
'<YOUR_ONEUPTIME_URL>/v1/traces',
|
||||
'application/x-protobuf',
|
||||
['x-oneuptime-token' => '<YOUR_ONEUPTIME_TOKEN>']
|
||||
);
|
||||
@@ -503,7 +505,7 @@ OpenTelemetry::SDK.configure do |c|
|
||||
c.add_span_processor(
|
||||
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
||||
OpenTelemetry::Exporter::OTLP::Exporter.new(
|
||||
endpoint: '<YOUR_ONEUPTIME_OTLP_URL>/v1/traces',
|
||||
endpoint: '<YOUR_ONEUPTIME_URL>/v1/traces',
|
||||
headers: { 'x-oneuptime-token' => '<YOUR_ONEUPTIME_TOKEN>' }
|
||||
)
|
||||
)
|
||||
@@ -523,7 +525,7 @@ config :opentelemetry,
|
||||
|
||||
config :opentelemetry_exporter,
|
||||
otlp_protocol: :http_protobuf,
|
||||
otlp_endpoint: "<YOUR_ONEUPTIME_OTLP_URL>",
|
||||
otlp_endpoint: "<YOUR_ONEUPTIME_URL>",
|
||||
otlp_headers: [{"x-oneuptime-token", "<YOUR_ONEUPTIME_TOKEN>"}]
|
||||
|
||||
# In application.ex, add to children:
|
||||
@@ -545,7 +547,7 @@ namespace otlp = opentelemetry::exporter::otlp;
|
||||
|
||||
void initTracer() {
|
||||
otlp::OtlpHttpExporterOptions opts;
|
||||
opts.url = "<YOUR_ONEUPTIME_OTLP_URL>/v1/traces";
|
||||
opts.url = "<YOUR_ONEUPTIME_URL>/v1/traces";
|
||||
opts.http_headers = {{"x-oneuptime-token", "<YOUR_ONEUPTIME_TOKEN>"}};
|
||||
|
||||
auto exporter = otlp::OtlpHttpExporterFactory::Create(opts);
|
||||
@@ -573,7 +575,7 @@ import OtlpHttpSpanExporting
|
||||
|
||||
func initTracer() {
|
||||
let exporter = OtlpHttpSpanExporter(
|
||||
endpoint: URL(string: "<YOUR_ONEUPTIME_OTLP_URL>/v1/traces")!,
|
||||
endpoint: URL(string: "<YOUR_ONEUPTIME_URL>/v1/traces")!,
|
||||
config: OtlpConfiguration(
|
||||
headers: [("x-oneuptime-token", "<YOUR_ONEUPTIME_TOKEN>")]
|
||||
)
|
||||
@@ -614,7 +616,7 @@ const provider = new WebTracerProvider({
|
||||
provider.addSpanProcessor(
|
||||
new BatchSpanProcessor(
|
||||
new OTLPTraceExporter({
|
||||
url: '<YOUR_ONEUPTIME_OTLP_URL>/v1/traces',
|
||||
url: '<YOUR_ONEUPTIME_URL>/v1/traces',
|
||||
headers: { 'x-oneuptime-token': '<YOUR_ONEUPTIME_TOKEN>' },
|
||||
})
|
||||
)
|
||||
@@ -664,7 +666,7 @@ const provider = new WebTracerProvider({
|
||||
provider.addSpanProcessor(
|
||||
new BatchSpanProcessor(
|
||||
new OTLPTraceExporter({
|
||||
url: '<YOUR_ONEUPTIME_OTLP_URL>/v1/traces',
|
||||
url: '<YOUR_ONEUPTIME_URL>/v1/traces',
|
||||
headers: { 'x-oneuptime-token': '<YOUR_ONEUPTIME_TOKEN>' },
|
||||
})
|
||||
)
|
||||
@@ -698,7 +700,7 @@ registerInstrumentations({
|
||||
function getEnvVarSnippet(): string {
|
||||
return `# Alternatively, configure via environment variables (works with any language):
|
||||
export OTEL_SERVICE_NAME="my-service"
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT="<YOUR_ONEUPTIME_OTLP_URL>"
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT="<YOUR_ONEUPTIME_URL>"
|
||||
export OTEL_EXPORTER_OTLP_HEADERS="x-oneuptime-token=<YOUR_ONEUPTIME_TOKEN>"
|
||||
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"`;
|
||||
}
|
||||
@@ -785,7 +787,7 @@ function getProfileConfigSnippet(lang: Language): {
|
||||
code: `const Pyroscope = require('@pyroscope/nodejs');
|
||||
|
||||
Pyroscope.init({
|
||||
serverAddress: '<YOUR_ONEUPTIME_OTLP_URL>',
|
||||
serverAddress: '<YOUR_ONEUPTIME_PYROSCOPE_URL>',
|
||||
appName: 'my-service',
|
||||
tags: {
|
||||
region: process.env.REGION || 'default',
|
||||
@@ -802,7 +804,7 @@ Pyroscope.start();`,
|
||||
|
||||
pyroscope.configure(
|
||||
application_name="my-service",
|
||||
server_address="<YOUR_ONEUPTIME_OTLP_URL>",
|
||||
server_address="<YOUR_ONEUPTIME_PYROSCOPE_URL>",
|
||||
sample_rate=100,
|
||||
tags={
|
||||
"region": "us-east-1",
|
||||
@@ -829,7 +831,7 @@ func main() {
|
||||
|
||||
pyroscope.Start(pyroscope.Config{
|
||||
ApplicationName: "my-service",
|
||||
ServerAddress: "<YOUR_ONEUPTIME_OTLP_URL>",
|
||||
ServerAddress: "<YOUR_ONEUPTIME_PYROSCOPE_URL>",
|
||||
AuthToken: os.Getenv("ONEUPTIME_TOKEN"),
|
||||
Tags: map[string]string{"hostname": os.Getenv("HOSTNAME")},
|
||||
ProfileTypes: []pyroscope.ProfileType{
|
||||
@@ -863,7 +865,7 @@ PyroscopeAgent.start(
|
||||
.setApplicationName("my-service")
|
||||
.setProfilingEvent(EventType.ITIMER)
|
||||
.setFormat(Format.JFR)
|
||||
.setServerAddress("<YOUR_ONEUPTIME_OTLP_URL>")
|
||||
.setServerAddress("<YOUR_ONEUPTIME_PYROSCOPE_URL>")
|
||||
.setAuthToken("<YOUR_ONEUPTIME_TOKEN>")
|
||||
.build()
|
||||
);
|
||||
@@ -871,7 +873,7 @@ PyroscopeAgent.start(
|
||||
// Option 2: Attach as Java agent (no code changes)
|
||||
// java -javaagent:pyroscope.jar \\
|
||||
// -Dpyroscope.application.name=my-service \\
|
||||
// -Dpyroscope.server.address=<YOUR_ONEUPTIME_OTLP_URL> \\
|
||||
// -Dpyroscope.server.address=<YOUR_ONEUPTIME_PYROSCOPE_URL> \\
|
||||
// -Dpyroscope.auth.token=<YOUR_ONEUPTIME_TOKEN> \\
|
||||
// -jar my-app.jar`,
|
||||
language: "java",
|
||||
@@ -880,7 +882,7 @@ PyroscopeAgent.start(
|
||||
return {
|
||||
code: `# Set environment variables before running your .NET application:
|
||||
export PYROSCOPE_APPLICATION_NAME=my-service
|
||||
export PYROSCOPE_SERVER_ADDRESS=<YOUR_ONEUPTIME_OTLP_URL>
|
||||
export PYROSCOPE_SERVER_ADDRESS=<YOUR_ONEUPTIME_PYROSCOPE_URL>
|
||||
export PYROSCOPE_AUTH_TOKEN=<YOUR_ONEUPTIME_TOKEN>
|
||||
export PYROSCOPE_PROFILING_ENABLED=1
|
||||
export CORECLR_ENABLE_PROFILING=1
|
||||
@@ -899,7 +901,7 @@ require 'pyroscope'
|
||||
|
||||
Pyroscope.configure do |config|
|
||||
config.application_name = "my-service"
|
||||
config.server_address = "<YOUR_ONEUPTIME_OTLP_URL>"
|
||||
config.server_address = "<YOUR_ONEUPTIME_PYROSCOPE_URL>"
|
||||
config.auth_token = "<YOUR_ONEUPTIME_TOKEN>"
|
||||
config.tags = {
|
||||
"hostname" => ENV["HOSTNAME"],
|
||||
@@ -918,7 +920,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let backend_impl = pprof_backend(pprof_config);
|
||||
|
||||
let agent = PyroscopeAgent::builder(
|
||||
"<YOUR_ONEUPTIME_OTLP_URL>", "my-service"
|
||||
"<YOUR_ONEUPTIME_PYROSCOPE_URL>", "my-service"
|
||||
)
|
||||
.backend(backend_impl)
|
||||
.auth_token("<YOUR_ONEUPTIME_TOKEN>".to_string())
|
||||
@@ -973,7 +975,7 @@ pyroscope.ebpf "default" {
|
||||
|
||||
pyroscope.write "oneuptime" {
|
||||
endpoint {
|
||||
url = "<YOUR_ONEUPTIME_OTLP_URL>"
|
||||
url = "<YOUR_ONEUPTIME_PYROSCOPE_URL>"
|
||||
headers = {
|
||||
"x-oneuptime-token" = "<YOUR_ONEUPTIME_TOKEN>",
|
||||
}
|
||||
@@ -1110,7 +1112,10 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
const otlpHost: string = HOST ? HOST : "<YOUR_ONEUPTIME_OTLP_HOST>";
|
||||
const otlpUrl: string = HOST
|
||||
? `${httpProtocol}://${HOST}/otlp`
|
||||
: "<YOUR_ONEUPTIME_OTLP_URL>";
|
||||
: "<YOUR_ONEUPTIME_URL>";
|
||||
const pyroscopeUrl: string = HOST
|
||||
? `${httpProtocol}://${HOST}/pyroscope`
|
||||
: "<YOUR_ONEUPTIME_PYROSCOPE_URL>";
|
||||
|
||||
// Fetch ingestion keys on mount
|
||||
useEffect(() => {
|
||||
@@ -1558,6 +1563,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language={configSnippet.language}
|
||||
/>,
|
||||
@@ -1575,6 +1581,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="bash"
|
||||
/>,
|
||||
@@ -1607,6 +1614,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="yaml"
|
||||
/>,
|
||||
@@ -1622,6 +1630,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="yaml"
|
||||
/>,
|
||||
@@ -1661,6 +1670,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="yaml"
|
||||
/>,
|
||||
@@ -1676,6 +1686,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="yaml"
|
||||
/>,
|
||||
@@ -1715,8 +1726,9 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="hcl"
|
||||
language="nginx"
|
||||
/>,
|
||||
)}
|
||||
|
||||
@@ -1730,6 +1742,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="yaml"
|
||||
/>,
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/Utils/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
|
||||
import AnalyticsModelAPI from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import ServiceElement from "../Service/ServiceElement";
|
||||
import SpanStatusElement from "../Span/SpanStatusElement";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import AppLink from "../AppLink/AppLink";
|
||||
|
||||
interface ServiceTraceSummary {
|
||||
service: Service;
|
||||
totalTraces: number;
|
||||
errorTraces: number;
|
||||
latestTraceTime: Date | null;
|
||||
p50Nanos: number;
|
||||
p95Nanos: number;
|
||||
durations: Array<number>;
|
||||
}
|
||||
|
||||
interface RecentTrace {
|
||||
traceId: string;
|
||||
name: string;
|
||||
serviceId: string;
|
||||
startTime: Date;
|
||||
statusCode: SpanStatus;
|
||||
durationNano: number;
|
||||
}
|
||||
|
||||
const formatDuration: (nanos: number) => string = (nanos: number): string => {
|
||||
if (nanos >= 1_000_000_000) {
|
||||
return `${(nanos / 1_000_000_000).toFixed(2)}s`;
|
||||
}
|
||||
if (nanos >= 1_000_000) {
|
||||
return `${(nanos / 1_000_000).toFixed(1)}ms`;
|
||||
}
|
||||
if (nanos >= 1_000) {
|
||||
return `${(nanos / 1_000).toFixed(0)}us`;
|
||||
}
|
||||
return `${nanos}ns`;
|
||||
};
|
||||
|
||||
const getPercentile: (arr: Array<number>, p: number) => number = (
|
||||
arr: Array<number>,
|
||||
p: number,
|
||||
): number => {
|
||||
if (arr.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sorted: Array<number> = [...arr].sort((a: number, b: number) => {
|
||||
return a - b;
|
||||
});
|
||||
const idx: number = Math.ceil((p / 100) * sorted.length) - 1;
|
||||
return sorted[Math.max(0, idx)] || 0;
|
||||
};
|
||||
|
||||
const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
const [serviceSummaries, setServiceSummaries] = useState<
|
||||
Array<ServiceTraceSummary>
|
||||
>([]);
|
||||
const [recentErrorTraces, setRecentErrorTraces] = useState<
|
||||
Array<RecentTrace>
|
||||
>([]);
|
||||
const [recentSlowTraces, setRecentSlowTraces] = useState<Array<RecentTrace>>(
|
||||
[],
|
||||
);
|
||||
const [services, setServices] = useState<Array<Service>>([]);
|
||||
const [totalRequests, setTotalRequests] = useState<number>(0);
|
||||
const [totalErrors, setTotalErrors] = useState<number>(0);
|
||||
const [globalP50, setGlobalP50] = useState<number>(0);
|
||||
const [globalP95, setGlobalP95] = useState<number>(0);
|
||||
const [globalP99, setGlobalP99] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loadDashboard: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
const oneHourAgo: Date = OneUptimeDate.addRemoveHours(now, -1);
|
||||
|
||||
const [servicesResult, spansResult] = await Promise.all([
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
AnalyticsModelAPI.getList({
|
||||
modelType: Span,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
startTime: new InBetween(oneHourAgo, now),
|
||||
},
|
||||
select: {
|
||||
traceId: true,
|
||||
spanId: true,
|
||||
parentSpanId: true,
|
||||
serviceId: true,
|
||||
name: true,
|
||||
startTime: true,
|
||||
statusCode: true,
|
||||
durationUnixNano: true,
|
||||
},
|
||||
limit: 5000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
startTime: SortOrder.Descending,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const loadedServices: Array<Service> = servicesResult.data || [];
|
||||
setServices(loadedServices);
|
||||
|
||||
const allSpans: Array<Span> = spansResult.data || [];
|
||||
|
||||
// Build per-service summaries
|
||||
const summaryMap: Map<string, ServiceTraceSummary> = new Map();
|
||||
|
||||
for (const service of loadedServices) {
|
||||
const serviceId: string = service.id?.toString() || "";
|
||||
summaryMap.set(serviceId, {
|
||||
service,
|
||||
totalTraces: 0,
|
||||
errorTraces: 0,
|
||||
latestTraceTime: null,
|
||||
p50Nanos: 0,
|
||||
p95Nanos: 0,
|
||||
durations: [],
|
||||
});
|
||||
}
|
||||
|
||||
const serviceTraceIds: Map<string, Set<string>> = new Map();
|
||||
const serviceErrorTraceIds: Map<string, Set<string>> = new Map();
|
||||
const errorTraces: Array<RecentTrace> = [];
|
||||
const allTraces: Array<RecentTrace> = [];
|
||||
const seenTraceIds: Set<string> = new Set();
|
||||
const seenErrorTraceIds: Set<string> = new Set();
|
||||
const allDurations: Array<number> = [];
|
||||
|
||||
for (const span of allSpans) {
|
||||
const serviceId: string = span.serviceId?.toString() || "";
|
||||
const traceId: string = span.traceId?.toString() || "";
|
||||
const duration: number = (span.durationUnixNano as number) || 0;
|
||||
const summary: ServiceTraceSummary | undefined =
|
||||
summaryMap.get(serviceId);
|
||||
|
||||
if (duration > 0) {
|
||||
allDurations.push(duration);
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
if (!serviceTraceIds.has(serviceId)) {
|
||||
serviceTraceIds.set(serviceId, new Set());
|
||||
}
|
||||
if (!serviceErrorTraceIds.has(serviceId)) {
|
||||
serviceErrorTraceIds.set(serviceId, new Set());
|
||||
}
|
||||
|
||||
const traceSet: Set<string> = serviceTraceIds.get(serviceId)!;
|
||||
if (!traceSet.has(traceId)) {
|
||||
traceSet.add(traceId);
|
||||
summary.totalTraces += 1;
|
||||
}
|
||||
|
||||
if (duration > 0) {
|
||||
summary.durations.push(duration);
|
||||
}
|
||||
|
||||
if (span.statusCode === SpanStatus.Error) {
|
||||
const errorSet: Set<string> = serviceErrorTraceIds.get(serviceId)!;
|
||||
if (!errorSet.has(traceId)) {
|
||||
errorSet.add(traceId);
|
||||
summary.errorTraces += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const spanTime: Date | undefined = span.startTime
|
||||
? new Date(span.startTime)
|
||||
: undefined;
|
||||
if (
|
||||
spanTime &&
|
||||
(!summary.latestTraceTime || spanTime > summary.latestTraceTime)
|
||||
) {
|
||||
summary.latestTraceTime = spanTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (!seenTraceIds.has(traceId) && traceId) {
|
||||
seenTraceIds.add(traceId);
|
||||
allTraces.push({
|
||||
traceId,
|
||||
name: span.name?.toString() || "Unknown",
|
||||
serviceId,
|
||||
startTime: span.startTime ? new Date(span.startTime) : new Date(),
|
||||
statusCode: span.statusCode || SpanStatus.Unset,
|
||||
durationNano: duration,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
span.statusCode === SpanStatus.Error &&
|
||||
traceId &&
|
||||
!seenErrorTraceIds.has(traceId)
|
||||
) {
|
||||
seenErrorTraceIds.add(traceId);
|
||||
errorTraces.push({
|
||||
traceId,
|
||||
name: span.name?.toString() || "Unknown",
|
||||
serviceId,
|
||||
startTime: span.startTime ? new Date(span.startTime) : new Date(),
|
||||
statusCode: span.statusCode,
|
||||
durationNano: duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Compute global percentiles
|
||||
setGlobalP50(getPercentile(allDurations, 50));
|
||||
setGlobalP95(getPercentile(allDurations, 95));
|
||||
setGlobalP99(getPercentile(allDurations, 99));
|
||||
|
||||
// Compute per-service percentiles and filter
|
||||
const summariesWithData: Array<ServiceTraceSummary> = Array.from(
|
||||
summaryMap.values(),
|
||||
)
|
||||
.filter((s: ServiceTraceSummary) => {
|
||||
return s.totalTraces > 0;
|
||||
})
|
||||
.map((s: ServiceTraceSummary) => {
|
||||
return {
|
||||
...s,
|
||||
p50Nanos: getPercentile(s.durations, 50),
|
||||
p95Nanos: getPercentile(s.durations, 95),
|
||||
};
|
||||
});
|
||||
|
||||
// Sort: highest error rate first, then by total traces
|
||||
summariesWithData.sort(
|
||||
(a: ServiceTraceSummary, b: ServiceTraceSummary) => {
|
||||
const aErrorRate: number =
|
||||
a.totalTraces > 0 ? a.errorTraces / a.totalTraces : 0;
|
||||
const bErrorRate: number =
|
||||
b.totalTraces > 0 ? b.errorTraces / b.totalTraces : 0;
|
||||
if (bErrorRate !== aErrorRate) {
|
||||
return bErrorRate - aErrorRate;
|
||||
}
|
||||
return b.totalTraces - a.totalTraces;
|
||||
},
|
||||
);
|
||||
|
||||
let totalReqs: number = 0;
|
||||
let totalErrs: number = 0;
|
||||
for (const s of summariesWithData) {
|
||||
totalReqs += s.totalTraces;
|
||||
totalErrs += s.errorTraces;
|
||||
}
|
||||
setTotalRequests(totalReqs);
|
||||
setTotalErrors(totalErrs);
|
||||
|
||||
setServiceSummaries(summariesWithData);
|
||||
setRecentErrorTraces(errorTraces.slice(0, 8));
|
||||
|
||||
const slowTraces: Array<RecentTrace> = [...allTraces]
|
||||
.sort((a: RecentTrace, b: RecentTrace) => {
|
||||
return b.durationNano - a.durationNano;
|
||||
})
|
||||
.slice(0, 8);
|
||||
setRecentSlowTraces(slowTraces);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, []);
|
||||
|
||||
const getServiceName: (serviceId: string) => string = (
|
||||
serviceId: string,
|
||||
): string => {
|
||||
const service: Service | undefined = services.find((s: Service) => {
|
||||
return s.id?.toString() === serviceId;
|
||||
});
|
||||
return service?.name?.toString() || "Unknown";
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadDashboard();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (serviceSummaries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="h-8 w-8 text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No trace data yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start sending distributed tracing data, you{"'"}ll
|
||||
see request rates, error rates, latency percentiles, and more.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overallErrorRate: number =
|
||||
totalRequests > 0 ? (totalErrors / totalRequests) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Hero Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p className="text-sm font-medium text-gray-500">Requests</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
{totalRequests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">last hour</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-5 ${overallErrorRate > 5 ? "border-red-200 bg-red-50" : overallErrorRate > 1 ? "border-amber-200 bg-amber-50" : "border-gray-200 bg-white"}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-500">Error Rate</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-1 ${overallErrorRate > 5 ? "text-red-600" : overallErrorRate > 1 ? "text-amber-600" : "text-green-600"}`}
|
||||
>
|
||||
{overallErrorRate.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{totalErrors.toLocaleString()} errors
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p className="text-sm font-medium text-gray-500">P50 Latency</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
{formatDuration(globalP50)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">median</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-5 ${globalP95 > 1_000_000_000 ? "border-amber-200 bg-amber-50" : "border-gray-200 bg-white"}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-500">P95 Latency</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-1 ${globalP95 > 1_000_000_000 ? "text-amber-600" : "text-gray-900"}`}
|
||||
>
|
||||
{formatDuration(globalP95)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">95th percentile</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-5 ${globalP99 > 2_000_000_000 ? "border-red-200 bg-red-50" : "border-gray-200 bg-white"}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-500">P99 Latency</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-1 ${globalP99 > 2_000_000_000 ? "text-red-600" : "text-gray-900"}`}
|
||||
>
|
||||
{formatDuration(globalP99)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">99th percentile</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Health Table */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Service Health
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Sorted by error rate — services needing attention first
|
||||
</p>
|
||||
</div>
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACES_LIST] as Route,
|
||||
)}
|
||||
>
|
||||
View all spans
|
||||
</AppLink>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Service
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Requests
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Error Rate
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
P50
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
P95
|
||||
</th>
|
||||
<th className="text-center text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{serviceSummaries.map((summary: ServiceTraceSummary) => {
|
||||
const errorRate: number =
|
||||
summary.totalTraces > 0
|
||||
? (summary.errorTraces / summary.totalTraces) * 100
|
||||
: 0;
|
||||
|
||||
let healthColor: string = "bg-green-500";
|
||||
let healthLabel: string = "Healthy";
|
||||
let healthBg: string = "bg-green-50 text-green-700";
|
||||
if (errorRate > 10) {
|
||||
healthColor = "bg-red-500";
|
||||
healthLabel = "Critical";
|
||||
healthBg = "bg-red-50 text-red-700";
|
||||
} else if (errorRate > 5) {
|
||||
healthColor = "bg-amber-500";
|
||||
healthLabel = "Degraded";
|
||||
healthBg = "bg-amber-50 text-amber-700";
|
||||
} else if (errorRate > 1) {
|
||||
healthColor = "bg-yellow-400";
|
||||
healthLabel = "Warning";
|
||||
healthBg = "bg-yellow-50 text-yellow-700";
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={summary.service.id?.toString()}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="px-5 py-3.5">
|
||||
<ServiceElement service={summary.service} />
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{summary.totalTraces.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${errorRate > 10 ? "bg-red-500" : errorRate > 5 ? "bg-amber-400" : errorRate > 0 ? "bg-yellow-400" : "bg-green-400"}`}
|
||||
style={{
|
||||
width: `${Math.max(errorRate, errorRate > 0 ? 3 : 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${errorRate > 5 ? "text-red-600" : "text-gray-900"}`}
|
||||
>
|
||||
{errorRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<span className="text-sm font-mono text-gray-700">
|
||||
{formatDuration(summary.p50Nanos)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<span className="text-sm font-mono text-gray-700">
|
||||
{formatDuration(summary.p95Nanos)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full ${healthBg}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${healthColor}`}
|
||||
/>
|
||||
{healthLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<AppLink
|
||||
className="text-xs text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_TRACES] as Route,
|
||||
{
|
||||
modelId: new ObjectID(
|
||||
summary.service._id as string,
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
View
|
||||
</AppLink>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column: Errors + Slow Requests */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Errors */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Recent Errors
|
||||
</h3>
|
||||
{recentErrorTraces.length > 0 && (
|
||||
<span className="text-xs bg-red-50 text-red-700 px-2 py-0.5 rounded-full font-medium">
|
||||
{recentErrorTraces.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{recentErrorTraces.length === 0 ? (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-10 text-center">
|
||||
<div className="mx-auto w-10 h-10 rounded-full bg-green-50 flex items-center justify-center mb-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
No errors in the last hour
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Looking good!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{recentErrorTraces.map((trace: RecentTrace, index: number) => {
|
||||
return (
|
||||
<AppLink
|
||||
key={`${trace.traceId}-${index}`}
|
||||
className="block px-4 py-3 hover:bg-red-50/30 transition-colors"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACE_VIEW]!,
|
||||
{ modelId: trace.traceId },
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3 min-w-0">
|
||||
<SpanStatusElement
|
||||
spanStatusCode={trace.statusCode}
|
||||
title=""
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{trace.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{getServiceName(trace.serviceId)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-3">
|
||||
<p className="text-xs font-mono text-gray-600">
|
||||
{formatDuration(trace.durationNano)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{OneUptimeDate.fromNow(trace.startTime)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Slowest Requests */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Slowest Requests
|
||||
</h3>
|
||||
</div>
|
||||
{recentSlowTraces.length === 0 ? (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-10 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
No traces in the last hour
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{recentSlowTraces.map((trace: RecentTrace, index: number) => {
|
||||
const maxDuration: number =
|
||||
recentSlowTraces[0]?.durationNano || 1;
|
||||
const barWidth: number =
|
||||
(trace.durationNano / maxDuration) * 100;
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
key={`${trace.traceId}-slow-${index}`}
|
||||
className="block px-4 py-3 hover:bg-amber-50/30 transition-colors"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACE_VIEW]!,
|
||||
{ modelId: trace.traceId },
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center space-x-3 min-w-0">
|
||||
<SpanStatusElement
|
||||
spanStatusCode={trace.statusCode}
|
||||
title=""
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{trace.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{getServiceName(trace.serviceId)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-3">
|
||||
<p className="text-sm font-mono font-semibold text-gray-900">
|
||||
{formatDuration(trace.durationNano)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-8">
|
||||
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-400"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default TracesDashboard;
|
||||
@@ -15,7 +15,7 @@ const ExceptionsLayout: FunctionComponent<
|
||||
|
||||
if (path.endsWith("exceptions") || path.endsWith("exceptions/*")) {
|
||||
Navigation.navigate(
|
||||
RouteUtil.populateRouteParams(RouteMap[PageMap.EXCEPTIONS_UNRESOLVED]!),
|
||||
RouteUtil.populateRouteParams(RouteMap[PageMap.EXCEPTIONS_OVERVIEW]!),
|
||||
);
|
||||
|
||||
return <></>;
|
||||
|
||||
68
App/FeatureSet/Dashboard/src/Pages/Exceptions/Overview.tsx
Normal file
68
App/FeatureSet/Dashboard/src/Pages/Exceptions/Overview.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import TelemetryDocumentation from "../../Components/Telemetry/Documentation";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import ExceptionsDashboard from "../../Components/Exceptions/ExceptionsDashboard";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
|
||||
const ExceptionsOverviewPage: FunctionComponent<PageComponentProps> = (
|
||||
props: PageComponentProps,
|
||||
): ReactElement => {
|
||||
const disableTelemetryForThisProject: boolean =
|
||||
props.currentProject?.reseller?.enableTelemetryFeatures === false;
|
||||
|
||||
const [serviceCount, setServiceCount] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const fetchServiceCount: PromiseVoidFunction = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const count: number = await ModelAPI.count({
|
||||
modelType: Service,
|
||||
query: {},
|
||||
});
|
||||
setServiceCount(count);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchServiceCount().catch((err: Error) => {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (disableTelemetryForThisProject) {
|
||||
return (
|
||||
<ErrorMessage message="Looks like you have bought this plan from a reseller. It did not include telemetry features in your plan. Telemetry features are disabled for this project." />
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (serviceCount === 0) {
|
||||
return <TelemetryDocumentation telemetryType="exceptions" />;
|
||||
}
|
||||
|
||||
return <ExceptionsDashboard />;
|
||||
};
|
||||
|
||||
export default ExceptionsOverviewPage;
|
||||
@@ -15,6 +15,15 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => {
|
||||
{
|
||||
title: "Exceptions",
|
||||
items: [
|
||||
{
|
||||
link: {
|
||||
title: "Overview",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_OVERVIEW] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.Home,
|
||||
},
|
||||
{
|
||||
link: {
|
||||
title: "Unresolved",
|
||||
@@ -52,11 +61,11 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => {
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Documentation",
|
||||
title: "Help",
|
||||
items: [
|
||||
{
|
||||
link: {
|
||||
title: "Documentation",
|
||||
title: "Setup Guide",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_DOCUMENTATION] as Route,
|
||||
),
|
||||
|
||||
@@ -14,7 +14,7 @@ const ExceptionViewLayout: FunctionComponent<
|
||||
|
||||
if (path.endsWith("exceptions")) {
|
||||
Navigation.navigate(
|
||||
RouteUtil.populateRouteParams(RouteMap[PageMap.EXCEPTIONS_UNRESOLVED]!),
|
||||
RouteUtil.populateRouteParams(RouteMap[PageMap.EXCEPTIONS_OVERVIEW]!),
|
||||
);
|
||||
|
||||
return <></>;
|
||||
@@ -22,7 +22,7 @@ const ExceptionViewLayout: FunctionComponent<
|
||||
|
||||
return (
|
||||
<Page
|
||||
title="Exception Explorer"
|
||||
title="Exception Details"
|
||||
breadcrumbLinks={getExceptionsBreadcrumbs(path)}
|
||||
>
|
||||
<Outlet />
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import MetricsTable from "../../Components/Metrics/MetricsTable";
|
||||
import MetricsDashboard from "../../Components/Metrics/MetricsDashboard";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
@@ -62,7 +62,7 @@ const MetricsPage: FunctionComponent<PageComponentProps> = (
|
||||
return <TelemetryDocumentation telemetryType="metrics" />;
|
||||
}
|
||||
|
||||
return <MetricsTable />;
|
||||
return <MetricsDashboard />;
|
||||
};
|
||||
|
||||
export default MetricsPage;
|
||||
|
||||
8
App/FeatureSet/Dashboard/src/Pages/Metrics/List.tsx
Normal file
8
App/FeatureSet/Dashboard/src/Pages/Metrics/List.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import MetricsTable from "../../Components/Metrics/MetricsTable";
|
||||
|
||||
const MetricsListPage: FunctionComponent = (): ReactElement => {
|
||||
return <MetricsTable />;
|
||||
};
|
||||
|
||||
export default MetricsListPage;
|
||||
@@ -14,21 +14,30 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => {
|
||||
items: [
|
||||
{
|
||||
link: {
|
||||
title: "All Metrics",
|
||||
title: "Overview",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.METRICS] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.Home,
|
||||
},
|
||||
{
|
||||
link: {
|
||||
title: "All Metrics",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.METRICS_LIST] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.ChartBar,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Documentation",
|
||||
title: "Help",
|
||||
items: [
|
||||
{
|
||||
link: {
|
||||
title: "Documentation",
|
||||
title: "Setup Guide",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.METRICS_DOCUMENTATION] as Route,
|
||||
),
|
||||
|
||||
@@ -11,10 +11,7 @@ const MetricsViewLayout: FunctionComponent<
|
||||
> = (): ReactElement => {
|
||||
const path: string = Navigation.getRoutePath(RouteUtil.getRoutes());
|
||||
return (
|
||||
<Page
|
||||
title="Metrics Explorer"
|
||||
breadcrumbLinks={getMetricsBreadcrumbs(path)}
|
||||
>
|
||||
<Page title="Metric Explorer" breadcrumbLinks={getMetricsBreadcrumbs(path)}>
|
||||
<Outlet />
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -65,6 +65,10 @@ import MonitorFeedElement from "../../../Components/Monitor/MonitorFeed";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import MonitorEvaluationSummary from "Common/Types/Monitor/MonitorEvaluationSummary";
|
||||
import Incident from "Common/Models/DatabaseModels/Incident";
|
||||
import UptimeBarTooltipIncident from "Common/Types/Monitor/UptimeBarTooltipIncident";
|
||||
import UptimeBarDayModal from "Common/UI/Components/MonitorGraphs/UptimeBarDayModal";
|
||||
import Color from "Common/Types/Color";
|
||||
|
||||
const MonitorView: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID();
|
||||
@@ -110,6 +114,15 @@ const MonitorView: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
MonitorEvaluationSummary | undefined
|
||||
>(undefined);
|
||||
|
||||
const [timelineIncidents, setTimelineIncidents] = useState<
|
||||
Array<UptimeBarTooltipIncident>
|
||||
>([]);
|
||||
|
||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||||
const [selectedDayIncidents, setSelectedDayIncidents] = useState<
|
||||
Array<UptimeBarTooltipIncident>
|
||||
>([]);
|
||||
|
||||
const getUptimePercent: () => ReactElement = (): ReactElement => {
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
@@ -297,6 +310,66 @@ const MonitorView: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
);
|
||||
setStatusTimelines(monitorStatus.data);
|
||||
|
||||
// Fetch incidents for this monitor in the timeline date range
|
||||
const incidentResult: ListResult<Incident> = await ModelAPI.getList({
|
||||
modelType: Incident,
|
||||
query: {
|
||||
monitors: [modelId] as any,
|
||||
declaredAt: new InBetween(startDate, endDate),
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
select: {
|
||||
_id: true,
|
||||
title: true,
|
||||
declaredAt: true,
|
||||
incidentSeverity: {
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
currentIncidentState: {
|
||||
_id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
monitors: {
|
||||
_id: true,
|
||||
},
|
||||
},
|
||||
sort: {
|
||||
declaredAt: SortOrder.Descending,
|
||||
},
|
||||
});
|
||||
|
||||
const parsedIncidents: Array<UptimeBarTooltipIncident> =
|
||||
incidentResult.data.map((incident: Incident) => {
|
||||
return {
|
||||
id: incident._id || "",
|
||||
title: incident.title || "",
|
||||
declaredAt: incident.declaredAt || new Date(),
|
||||
incidentSeverity: incident.incidentSeverity
|
||||
? {
|
||||
name: incident.incidentSeverity.name || "",
|
||||
color:
|
||||
incident.incidentSeverity.color || new Color("#000000"),
|
||||
}
|
||||
: undefined,
|
||||
currentIncidentState: incident.currentIncidentState
|
||||
? {
|
||||
name: incident.currentIncidentState.name || "",
|
||||
color:
|
||||
incident.currentIncidentState.color || new Color("#000000"),
|
||||
}
|
||||
: undefined,
|
||||
monitorIds: (incident.monitors || []).map((m: Monitor) => {
|
||||
return new ObjectID(m._id?.toString() || "");
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
setTimelineIncidents(parsedIncidents);
|
||||
|
||||
const isMonitoredByProbe: boolean = item.monitorType
|
||||
? MonitorTypeHelper.isProbableMonitor(item.monitorType)
|
||||
: false;
|
||||
@@ -614,9 +687,42 @@ const MonitorView: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
isLoading={isLoading}
|
||||
defaultBarColor={Green}
|
||||
downtimeMonitorStatuses={downTimeMonitorStatues}
|
||||
incidents={timelineIncidents}
|
||||
onIncidentClick={(incidentId: string) => {
|
||||
Navigation.navigate(
|
||||
RouteUtil.populateRouteParams(RouteMap[PageMap.INCIDENT_VIEW]!, {
|
||||
modelId: new ObjectID(incidentId),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onBarClick={(
|
||||
date: Date,
|
||||
incidents: Array<UptimeBarTooltipIncident>,
|
||||
) => {
|
||||
setSelectedDay(date);
|
||||
setSelectedDayIncidents(incidents);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{selectedDay && (
|
||||
<UptimeBarDayModal
|
||||
date={selectedDay}
|
||||
incidents={selectedDayIncidents}
|
||||
onIncidentClick={(incidentId: string) => {
|
||||
Navigation.navigate(
|
||||
RouteUtil.populateRouteParams(RouteMap[PageMap.INCIDENT_VIEW]!, {
|
||||
modelId: new ObjectID(incidentId),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onClose={() => {
|
||||
setSelectedDay(null);
|
||||
setSelectedDayIncidents([]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Summary
|
||||
monitorType={monitorType!}
|
||||
probes={probes}
|
||||
|
||||
@@ -1,21 +1,108 @@
|
||||
import DisabledWarning from "../../../Components/Monitor/DisabledWarning";
|
||||
import MonitorMetricsElement from "../../../Components/Monitor/MonitorMetrics";
|
||||
import MonitorCustomMetrics from "../../../Components/Monitor/MonitorCustomMetrics";
|
||||
import MonitorIncidentMetrics from "../../../Components/Monitor/MonitorIncidentMetrics";
|
||||
import MonitorAlertMetrics from "../../../Components/Monitor/MonitorAlertMetrics";
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import MonitorMetricsElement from "../../../Components/Monitor/MonitorMetrics";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import Tabs from "Common/UI/Components/Tabs/Tabs";
|
||||
import { Tab } from "Common/UI/Components/Tabs/Tab";
|
||||
import MonitorType from "Common/Types/Monitor/MonitorType";
|
||||
import MonitorMetricTypeUtil from "Common/Utils/Monitor/MonitorMetricType";
|
||||
import Monitor from "Common/Models/DatabaseModels/Monitor";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
|
||||
const MonitorDelete: FunctionComponent<
|
||||
const MonitorMetrics: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
|
||||
const [, setCurrentTab] = useState<Tab | null>(null);
|
||||
const [monitorType, setMonitorType] = useState<MonitorType | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
ModelAPI.getItem({
|
||||
modelType: Monitor,
|
||||
id: modelId,
|
||||
select: { monitorType: true },
|
||||
})
|
||||
.then((item: Monitor | null) => {
|
||||
setMonitorType(item?.monitorType || null);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
const hasMonitorMetrics: boolean =
|
||||
monitorType !== null &&
|
||||
MonitorMetricTypeUtil.getMonitorMetricTypesByMonitorType(monitorType)
|
||||
.length > 0;
|
||||
|
||||
const tabs: Array<Tab> = [];
|
||||
|
||||
if (hasMonitorMetrics) {
|
||||
tabs.push({
|
||||
name: "Monitor Metrics",
|
||||
children: <MonitorMetricsElement monitorId={modelId} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
monitorType === MonitorType.CustomJavaScriptCode ||
|
||||
monitorType === MonitorType.SyntheticMonitor
|
||||
) {
|
||||
tabs.push({
|
||||
name: "Custom Metrics",
|
||||
children: <MonitorCustomMetrics monitorId={modelId} />,
|
||||
});
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
name: "Incident Metrics",
|
||||
children: <MonitorIncidentMetrics monitorId={modelId} />,
|
||||
});
|
||||
|
||||
tabs.push({
|
||||
name: "Alert Metrics",
|
||||
children: <MonitorAlertMetrics monitorId={modelId} />,
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<DisabledWarning monitorId={modelId} />
|
||||
<MonitorMetricsElement monitorId={modelId} />
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
onTabChange={(tab: Tab) => {
|
||||
setCurrentTab(tab);
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitorDelete;
|
||||
export default MonitorMetrics;
|
||||
|
||||
@@ -46,18 +46,16 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
];
|
||||
|
||||
if (MonitorTypeHelper.doesMonitorTypeHaveGraphs(props.monitorType)) {
|
||||
overviewItems.push({
|
||||
link: {
|
||||
title: "Metrics",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.MONITOR_VIEW_METRICS] as Route,
|
||||
{ modelId: props.modelId },
|
||||
),
|
||||
},
|
||||
icon: IconProp.Graph,
|
||||
});
|
||||
}
|
||||
overviewItems.push({
|
||||
link: {
|
||||
title: "Metrics",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.MONITOR_VIEW_METRICS] as Route,
|
||||
{ modelId: props.modelId },
|
||||
),
|
||||
},
|
||||
icon: IconProp.Graph,
|
||||
});
|
||||
|
||||
overviewItems.push({
|
||||
link: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import ProfileTable from "../../Components/Profiles/ProfileTable";
|
||||
import ProfilesDashboard from "../../Components/Profiles/ProfilesDashboard";
|
||||
|
||||
const ProfilesPage: FunctionComponent<PageComponentProps> = (
|
||||
props: PageComponentProps,
|
||||
@@ -62,7 +62,7 @@ const ProfilesPage: FunctionComponent<PageComponentProps> = (
|
||||
return <TelemetryDocumentation telemetryType="profiles" />;
|
||||
}
|
||||
|
||||
return <ProfileTable />;
|
||||
return <ProfilesDashboard />;
|
||||
};
|
||||
|
||||
export default ProfilesPage;
|
||||
|
||||
@@ -14,7 +14,7 @@ const ProfilesLayout: FunctionComponent<
|
||||
|
||||
return (
|
||||
<Page
|
||||
title="Profiles"
|
||||
title="Performance Profiles"
|
||||
breadcrumbLinks={getProfilesBreadcrumbs(path)}
|
||||
sideMenu={<SideMenu />}
|
||||
>
|
||||
|
||||
11
App/FeatureSet/Dashboard/src/Pages/Profiles/List.tsx
Normal file
11
App/FeatureSet/Dashboard/src/Pages/Profiles/List.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import ProfileTable from "../../Components/Profiles/ProfileTable";
|
||||
|
||||
const ProfilesListPage: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
return <ProfileTable />;
|
||||
};
|
||||
|
||||
export default ProfilesListPage;
|
||||
@@ -10,13 +10,22 @@ import React, { FunctionComponent, ReactElement } from "react";
|
||||
const DashboardSideMenu: FunctionComponent = (): ReactElement => {
|
||||
const sections: SideMenuSectionProps[] = [
|
||||
{
|
||||
title: "Profiles",
|
||||
title: "Performance",
|
||||
items: [
|
||||
{
|
||||
link: {
|
||||
title: "Overview",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.PROFILES] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.Home,
|
||||
},
|
||||
{
|
||||
link: {
|
||||
title: "All Profiles",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.PROFILES] as Route,
|
||||
RouteMap[PageMap.PROFILES_LIST] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.Fire,
|
||||
@@ -24,11 +33,11 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => {
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Documentation",
|
||||
title: "Help",
|
||||
items: [
|
||||
{
|
||||
link: {
|
||||
title: "Documentation",
|
||||
title: "Setup Guide",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.PROFILES_DOCUMENTATION] as Route,
|
||||
),
|
||||
|
||||
@@ -23,7 +23,7 @@ const ProfileViewPage: FunctionComponent<
|
||||
|
||||
const tabs: Array<Tab> = [
|
||||
{
|
||||
name: "Flamegraph",
|
||||
name: "Performance Map",
|
||||
children: (
|
||||
<ProfileFlamegraph
|
||||
profileId={profileId}
|
||||
@@ -32,7 +32,7 @@ const ProfileViewPage: FunctionComponent<
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Function List",
|
||||
name: "Hotspots",
|
||||
children: (
|
||||
<ProfileFunctionList
|
||||
profileId={profileId}
|
||||
@@ -41,12 +41,13 @@ const ProfileViewPage: FunctionComponent<
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Diff",
|
||||
name: "Compare",
|
||||
children: (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Compare profile data between two time ranges. Baseline is the
|
||||
earlier period, comparison is the more recent period.
|
||||
Compare performance between two time periods to see what got faster
|
||||
or slower. The baseline is the earlier period, and the comparison is
|
||||
the more recent period.
|
||||
</p>
|
||||
<DiffFlamegraph
|
||||
baselineStartTime={twoHoursAgo}
|
||||
|
||||
@@ -12,7 +12,7 @@ const ProfilesViewLayout: FunctionComponent<
|
||||
const path: string = Navigation.getRoutePath(RouteUtil.getRoutes());
|
||||
return (
|
||||
<Page
|
||||
title="Profile Explorer"
|
||||
title="Profile Details"
|
||||
breadcrumbLinks={getProfilesBreadcrumbs(path)}
|
||||
>
|
||||
<Outlet />
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import ExceptionsTable from "../../../Components/Exceptions/ExceptionsTable";
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
|
||||
const ServiceExceptions: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ExceptionsTable
|
||||
serviceId={modelId}
|
||||
query={{}}
|
||||
title="Exceptions"
|
||||
description="All the exceptions for this service."
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceExceptions;
|
||||
@@ -136,7 +136,7 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Profiles",
|
||||
title: "Performance Profiles",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_PROFILES] as Route,
|
||||
{ modelId: props.modelId },
|
||||
@@ -144,6 +144,17 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
}}
|
||||
icon={IconProp.Fire}
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Exceptions",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_EXCEPTIONS] as Route,
|
||||
{ modelId: props.modelId },
|
||||
),
|
||||
}}
|
||||
icon={IconProp.Error}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
<SideMenuSection title="Advanced">
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import TraceTable from "../../Components/Traces/TraceTable";
|
||||
import TracesDashboard from "../../Components/Traces/TracesDashboard";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
@@ -62,7 +62,7 @@ const TracesPage: FunctionComponent<PageComponentProps> = (
|
||||
return <TelemetryDocumentation telemetryType="traces" />;
|
||||
}
|
||||
|
||||
return <TraceTable />;
|
||||
return <TracesDashboard />;
|
||||
};
|
||||
|
||||
export default TracesPage;
|
||||
|
||||
11
App/FeatureSet/Dashboard/src/Pages/Traces/List.tsx
Normal file
11
App/FeatureSet/Dashboard/src/Pages/Traces/List.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import TraceTable from "../../Components/Traces/TraceTable";
|
||||
|
||||
const TracesListPage: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
return <TraceTable />;
|
||||
};
|
||||
|
||||
export default TracesListPage;
|
||||
@@ -14,21 +14,30 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => {
|
||||
items: [
|
||||
{
|
||||
link: {
|
||||
title: "All Traces",
|
||||
title: "Overview",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACES] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.Home,
|
||||
},
|
||||
{
|
||||
link: {
|
||||
title: "All Spans",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACES_LIST] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.RectangleStack,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Documentation",
|
||||
title: "Help",
|
||||
items: [
|
||||
{
|
||||
link: {
|
||||
title: "Documentation",
|
||||
title: "Setup Guide",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACES_DOCUMENTATION] as Route,
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ const TracesViewLayout: FunctionComponent<
|
||||
> = (): ReactElement => {
|
||||
const path: string = Navigation.getRoutePath(RouteUtil.getRoutes());
|
||||
return (
|
||||
<Page title="Trace Explorer" breadcrumbLinks={getTracesBreadcrumbs(path)}>
|
||||
<Page title="Trace Details" breadcrumbLinks={getTracesBreadcrumbs(path)}>
|
||||
<Outlet />
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
const [nodes, setNodes] = useState<Array<Node>>([]);
|
||||
const [edges, setEdges] = useState<Array<Edge>>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [webhookSecretKey, setWebhookSecretKey] = useState<string>("");
|
||||
|
||||
const [showRunSuccessConfirmation, setShowRunSuccessConfirmation] =
|
||||
useState<boolean>(false);
|
||||
@@ -63,11 +64,16 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
id: modelId,
|
||||
select: {
|
||||
graph: true,
|
||||
webhookSecretKey: true,
|
||||
},
|
||||
requestOptions: {},
|
||||
});
|
||||
|
||||
if (workflow) {
|
||||
if (workflow.webhookSecretKey) {
|
||||
setWebhookSecretKey(workflow.webhookSecretKey);
|
||||
}
|
||||
|
||||
const allComponents: {
|
||||
components: Array<ComponentMetadata>;
|
||||
categories: Array<ComponentCategory>;
|
||||
@@ -349,6 +355,7 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
) : (
|
||||
<Workflow
|
||||
workflowId={modelId}
|
||||
webhookSecretKey={webhookSecretKey}
|
||||
showComponentsPickerModal={showComponentPickerModal}
|
||||
onComponentPickerModalUpdate={(value: boolean) => {
|
||||
setShowComponentPickerModal(value);
|
||||
|
||||
@@ -5,15 +5,161 @@ import Route from "Common/Types/API/Route";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import DuplicateModel from "Common/UI/Components/DuplicateModel/DuplicateModel";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import Workflow from "Common/Models/DatabaseModels/Workflow";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import UUID from "Common/Utils/UUID";
|
||||
import ComponentID from "Common/Types/Workflow/ComponentID";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import {
|
||||
ComponentType,
|
||||
NodeDataProp,
|
||||
NodeType,
|
||||
} from "Common/Types/Workflow/Component";
|
||||
import { useAsyncEffect } from "use-async-effect";
|
||||
|
||||
const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
const [showResetConfirmation, setShowResetConfirmation] =
|
||||
useState<boolean>(false);
|
||||
const [refresher, setRefresher] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [isWebhookTrigger, setIsWebhookTrigger] = useState<boolean>(false);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
try {
|
||||
const workflow: Workflow | null = await ModelAPI.getItem({
|
||||
modelType: Workflow,
|
||||
id: modelId,
|
||||
select: {
|
||||
graph: true,
|
||||
},
|
||||
requestOptions: {},
|
||||
});
|
||||
|
||||
if (workflow?.graph && (workflow.graph as JSONObject)["nodes"]) {
|
||||
const nodes: Array<JSONObject> = (workflow.graph as JSONObject)[
|
||||
"nodes"
|
||||
] as Array<JSONObject>;
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeData: NodeDataProp = node["data"] as any;
|
||||
|
||||
if (
|
||||
nodeData.componentType === ComponentType.Trigger &&
|
||||
nodeData.nodeType === NodeType.Node &&
|
||||
nodeData.metadataId === ComponentID.Webhook
|
||||
) {
|
||||
setIsWebhookTrigger(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore - just don't show the webhook section
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetSecretKey: () => void = (): void => {
|
||||
setShowResetConfirmation(false);
|
||||
|
||||
ModelAPI.updateById({
|
||||
modelType: Workflow,
|
||||
id: modelId,
|
||||
data: {
|
||||
webhookSecretKey: UUID.generate(),
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
setRefresher(!refresher);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{isWebhookTrigger && (
|
||||
<CardModelDetail<Workflow>
|
||||
name="Workflow > Webhook Secret Key"
|
||||
cardProps={{
|
||||
title: "Webhook Secret Key",
|
||||
description:
|
||||
"This secret key is used to trigger this workflow via webhook. Use this key in the webhook URL instead of the workflow ID for security. You can reset this key if it is compromised.",
|
||||
buttons: [
|
||||
{
|
||||
title: "Reset Secret Key",
|
||||
buttonStyle: ButtonStyleType.DANGER_OUTLINE,
|
||||
onClick: () => {
|
||||
setShowResetConfirmation(true);
|
||||
},
|
||||
icon: IconProp.Refresh,
|
||||
},
|
||||
],
|
||||
}}
|
||||
isEditable={false}
|
||||
refresher={refresher}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: Workflow,
|
||||
id: "model-detail-workflow-webhook-secret",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
webhookSecretKey: true,
|
||||
},
|
||||
fieldType: FieldType.HiddenText,
|
||||
title: "Webhook Secret Key",
|
||||
placeholder:
|
||||
"No secret key generated yet. Save the workflow to generate one.",
|
||||
opts: {
|
||||
isCopyable: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showResetConfirmation && (
|
||||
<ConfirmModal
|
||||
title="Reset Webhook Secret Key"
|
||||
description="Are you sure you want to reset the webhook secret key? Any existing integrations using the current key will stop working."
|
||||
submitButtonText="Reset Key"
|
||||
submitButtonType={ButtonStyleType.DANGER}
|
||||
onClose={() => {
|
||||
setShowResetConfirmation(false);
|
||||
}}
|
||||
onSubmit={resetSecretKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ConfirmModal
|
||||
title="Error"
|
||||
description={error}
|
||||
submitButtonText="Close"
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
onSubmit={() => {
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DuplicateModel
|
||||
modelId={modelId}
|
||||
modelType={Workflow}
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, { FunctionComponent, ReactElement } from "react";
|
||||
import { Route as PageRoute, Routes } from "react-router-dom";
|
||||
|
||||
// Pages
|
||||
import ExceptionsOverview from "../Pages/Exceptions/Overview";
|
||||
import ExceptionsUnresolved from "../Pages/Exceptions/Unresolved";
|
||||
import ExceptionsResolved from "../Pages/Exceptions/Resolved";
|
||||
import ExceptionsArchived from "../Pages/Exceptions/Archived";
|
||||
@@ -23,13 +24,23 @@ const ExceptionsRoutes: FunctionComponent<ComponentProps> = (
|
||||
<PageRoute
|
||||
index
|
||||
element={
|
||||
<ExceptionsUnresolved
|
||||
<ExceptionsOverview
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.EXCEPTIONS] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={ExceptionsRoutePath[PageMap.EXCEPTIONS_OVERVIEW] || ""}
|
||||
element={
|
||||
<ExceptionsOverview
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.EXCEPTIONS_OVERVIEW] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={ExceptionsRoutePath[PageMap.EXCEPTIONS_UNRESOLVED] || ""}
|
||||
element={
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Route as PageRoute, Routes } from "react-router-dom";
|
||||
|
||||
// Pages
|
||||
import MetricsPage from "../Pages/Metrics/Index";
|
||||
import MetricsListPage from "../Pages/Metrics/List";
|
||||
import MetricsDocumentationPage from "../Pages/Metrics/Documentation";
|
||||
|
||||
import MetricViewPage from "../Pages/Metrics/View/Index";
|
||||
@@ -28,6 +29,10 @@ const MetricsRoutes: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PageRoute
|
||||
path={MetricsRoutePath[PageMap.METRICS_LIST] || ""}
|
||||
element={<MetricsListPage />}
|
||||
/>
|
||||
<PageRoute
|
||||
path={MetricsRoutePath[PageMap.METRICS_DOCUMENTATION] || ""}
|
||||
element={
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Route as PageRoute, Routes } from "react-router-dom";
|
||||
|
||||
// Pages
|
||||
import ProfilesPage from "../Pages/Profiles/Index";
|
||||
import ProfilesListPage from "../Pages/Profiles/List";
|
||||
import ProfilesDocumentationPage from "../Pages/Profiles/Documentation";
|
||||
import ProfileViewPage from "../Pages/Profiles/View/Index";
|
||||
|
||||
@@ -27,6 +28,15 @@ const ProfilesRoutes: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PageRoute
|
||||
path={ProfilesRoutePath[PageMap.PROFILES_LIST] || ""}
|
||||
element={
|
||||
<ProfilesListPage
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.PROFILES_LIST] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PageRoute
|
||||
path={ProfilesRoutePath[PageMap.PROFILES_DOCUMENTATION] || ""}
|
||||
element={
|
||||
|
||||
@@ -26,6 +26,8 @@ import ServiceViewMetrics from "../Pages/Service/View/Metrics";
|
||||
|
||||
import ServiceViewProfiles from "../Pages/Service/View/Profiles";
|
||||
|
||||
import ServiceViewExceptions from "../Pages/Service/View/Exceptions";
|
||||
|
||||
import ServiceViewDelete from "../Pages/Service/View/Delete";
|
||||
|
||||
import ServiceViewSettings from "../Pages/Service/View/Settings";
|
||||
@@ -166,6 +168,16 @@ const ServiceRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.SERVICE_VIEW_EXCEPTIONS)}
|
||||
element={
|
||||
<ServiceViewExceptions
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.SERVICE_VIEW_EXCEPTIONS] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.SERVICE_VIEW_OWNERS)}
|
||||
element={
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Route as PageRoute, Routes } from "react-router-dom";
|
||||
|
||||
// Pages
|
||||
import TracesPage from "../Pages/Traces/Index";
|
||||
import TracesListPage from "../Pages/Traces/List";
|
||||
import TracesDocumentationPage from "../Pages/Traces/Documentation";
|
||||
|
||||
import TraceViewPage from "../Pages/Traces/View/Index";
|
||||
@@ -28,6 +29,15 @@ const TracesRoutes: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PageRoute
|
||||
path={TracesRoutePath[PageMap.TRACES_LIST] || ""}
|
||||
element={
|
||||
<TracesListPage
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.TRACES_LIST] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PageRoute
|
||||
path={TracesRoutePath[PageMap.TRACES_DOCUMENTATION] || ""}
|
||||
element={
|
||||
|
||||
@@ -11,6 +11,11 @@ export function getExceptionsBreadcrumbs(
|
||||
"Project",
|
||||
"Exceptions",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.EXCEPTIONS_OVERVIEW, [
|
||||
"Project",
|
||||
"Exceptions",
|
||||
"Overview",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.EXCEPTIONS_UNRESOLVED, [
|
||||
"Project",
|
||||
"Exceptions",
|
||||
@@ -29,12 +34,12 @@ export function getExceptionsBreadcrumbs(
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.EXCEPTIONS_VIEW, [
|
||||
"Project",
|
||||
"Exceptions",
|
||||
"View Exception",
|
||||
"Exception Details",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.EXCEPTIONS_DOCUMENTATION, [
|
||||
"Project",
|
||||
"Exceptions",
|
||||
"Documentation",
|
||||
"Setup Guide",
|
||||
]),
|
||||
};
|
||||
return breadcrumpLinksMap[path];
|
||||
|
||||
@@ -6,15 +6,20 @@ import Link from "Common/Types/Link";
|
||||
export function getMetricsBreadcrumbs(path: string): Array<Link> | undefined {
|
||||
const breadcrumpLinksMap: Dictionary<Link[]> = {
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.METRICS, ["Project", "Metrics"]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.METRICS_LIST, [
|
||||
"Project",
|
||||
"Metrics",
|
||||
"All Metrics",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.METRIC_VIEW, [
|
||||
"Project",
|
||||
"Metrics",
|
||||
"Metrics Explorer",
|
||||
"Metric Explorer",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.METRICS_DOCUMENTATION, [
|
||||
"Project",
|
||||
"Metrics",
|
||||
"Documentation",
|
||||
"Setup Guide",
|
||||
]),
|
||||
};
|
||||
return breadcrumpLinksMap[path];
|
||||
|
||||
@@ -5,16 +5,24 @@ import Link from "Common/Types/Link";
|
||||
|
||||
export function getProfilesBreadcrumbs(path: string): Array<Link> | undefined {
|
||||
const breadcrumpLinksMap: Dictionary<Link[]> = {
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.PROFILES, ["Project", "Profiles"]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.PROFILES, [
|
||||
"Project",
|
||||
"Performance Profiles",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.PROFILES_LIST, [
|
||||
"Project",
|
||||
"Performance Profiles",
|
||||
"All Profiles",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.PROFILE_VIEW, [
|
||||
"Project",
|
||||
"Profiles",
|
||||
"Profile Explorer",
|
||||
"Performance Profiles",
|
||||
"Profile Details",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.PROFILES_DOCUMENTATION, [
|
||||
"Project",
|
||||
"Profiles",
|
||||
"Documentation",
|
||||
"Performance Profiles",
|
||||
"Setup Guide",
|
||||
]),
|
||||
};
|
||||
return breadcrumpLinksMap[path];
|
||||
|
||||
@@ -6,15 +6,20 @@ import Link from "Common/Types/Link";
|
||||
export function getTracesBreadcrumbs(path: string): Array<Link> | undefined {
|
||||
const breadcrumpLinksMap: Dictionary<Link[]> = {
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.TRACES, ["Project", "Traces"]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.TRACES_LIST, [
|
||||
"Project",
|
||||
"Traces",
|
||||
"All Spans",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.TRACE_VIEW, [
|
||||
"Project",
|
||||
"Traces",
|
||||
"Trace Explorer",
|
||||
"Trace Details",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.TRACES_DOCUMENTATION, [
|
||||
"Project",
|
||||
"Traces",
|
||||
"Documentation",
|
||||
"Setup Guide",
|
||||
]),
|
||||
};
|
||||
return breadcrumpLinksMap[path];
|
||||
|
||||
@@ -15,18 +15,21 @@ enum PageMap {
|
||||
// Metrics (standalone product)
|
||||
METRICS_ROOT = "METRICS_ROOT",
|
||||
METRICS = "METRICS",
|
||||
METRICS_LIST = "METRICS_LIST",
|
||||
METRIC_VIEW = "METRIC_VIEW",
|
||||
METRICS_DOCUMENTATION = "METRICS_DOCUMENTATION",
|
||||
|
||||
// Traces (standalone product)
|
||||
TRACES_ROOT = "TRACES_ROOT",
|
||||
TRACES = "TRACES",
|
||||
TRACES_LIST = "TRACES_LIST",
|
||||
TRACE_VIEW = "TRACE_VIEW",
|
||||
TRACES_DOCUMENTATION = "TRACES_DOCUMENTATION",
|
||||
|
||||
// Profiles (standalone product)
|
||||
PROFILES_ROOT = "PROFILES_ROOT",
|
||||
PROFILES = "PROFILES",
|
||||
PROFILES_LIST = "PROFILES_LIST",
|
||||
PROFILE_VIEW = "PROFILE_VIEW",
|
||||
PROFILES_DOCUMENTATION = "PROFILES_DOCUMENTATION",
|
||||
|
||||
@@ -218,6 +221,7 @@ enum PageMap {
|
||||
SERVICE_VIEW_TRACES = "SERVICE_VIEW_TRACES",
|
||||
SERVICE_VIEW_METRICS = "SERVICE_VIEW_METRICS",
|
||||
SERVICE_VIEW_PROFILES = "SERVICE_VIEW_PROFILES",
|
||||
SERVICE_VIEW_EXCEPTIONS = "SERVICE_VIEW_EXCEPTIONS",
|
||||
SERVICE_VIEW_OWNERS = "SERVICE_VIEW_OWNERS",
|
||||
SERVICE_VIEW_DEPENDENCIES = "SERVICE_VIEW_DEPENDENCIES",
|
||||
SERVICE_VIEW_CODE_REPOSITORIES = "SERVICE_VIEW_CODE_REPOSITORIES",
|
||||
@@ -499,6 +503,7 @@ enum PageMap {
|
||||
// Exceptions (standalone, not under Telemetry)
|
||||
EXCEPTIONS_ROOT = "EXCEPTIONS_ROOT",
|
||||
EXCEPTIONS = "EXCEPTIONS",
|
||||
EXCEPTIONS_OVERVIEW = "EXCEPTIONS_OVERVIEW",
|
||||
EXCEPTIONS_UNRESOLVED = "EXCEPTIONS_UNRESOLVED",
|
||||
EXCEPTIONS_RESOLVED = "EXCEPTIONS_RESOLVED",
|
||||
EXCEPTIONS_ARCHIVED = "EXCEPTIONS_ARCHIVED",
|
||||
|
||||
@@ -5,6 +5,65 @@ export interface ParsedStackFrame {
|
||||
}
|
||||
|
||||
export default class ProfileUtil {
|
||||
public static getProfileTypeDisplayName(profileType: string): string {
|
||||
const type: string = profileType.toLowerCase().trim();
|
||||
|
||||
switch (type) {
|
||||
case "cpu":
|
||||
return "CPU Usage";
|
||||
case "wall":
|
||||
return "Wall Clock Time";
|
||||
case "inuse_objects":
|
||||
return "Memory Objects in Use";
|
||||
case "inuse_space":
|
||||
return "Memory Space in Use";
|
||||
case "alloc_objects":
|
||||
return "Memory Allocations (Count)";
|
||||
case "alloc_space":
|
||||
return "Memory Allocations (Size)";
|
||||
case "goroutine":
|
||||
return "Goroutines";
|
||||
case "contention":
|
||||
return "Lock Contention";
|
||||
case "samples":
|
||||
return "CPU Samples";
|
||||
case "mutex":
|
||||
return "Mutex Contention";
|
||||
case "block":
|
||||
return "Blocking Operations";
|
||||
case "heap":
|
||||
return "Heap Memory";
|
||||
default:
|
||||
return profileType;
|
||||
}
|
||||
}
|
||||
|
||||
public static getProfileTypeBadgeColor(profileType: string): string {
|
||||
const type: string = profileType.toLowerCase().trim();
|
||||
|
||||
switch (type) {
|
||||
case "cpu":
|
||||
case "samples":
|
||||
return "bg-orange-100 text-orange-800";
|
||||
case "wall":
|
||||
return "bg-purple-100 text-purple-800";
|
||||
case "inuse_objects":
|
||||
case "inuse_space":
|
||||
case "alloc_objects":
|
||||
case "alloc_space":
|
||||
case "heap":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "goroutine":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "contention":
|
||||
case "mutex":
|
||||
case "block":
|
||||
return "bg-red-100 text-red-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
}
|
||||
|
||||
public static getFrameTypeColor(frameType: string): string {
|
||||
const type: string = frameType.toLowerCase();
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ServiceRoutePath: Dictionary<string> = {
|
||||
[PageMap.SERVICE_VIEW_TRACES]: `${RouteParams.ModelID}/traces`,
|
||||
[PageMap.SERVICE_VIEW_METRICS]: `${RouteParams.ModelID}/metrics`,
|
||||
[PageMap.SERVICE_VIEW_PROFILES]: `${RouteParams.ModelID}/profiles`,
|
||||
[PageMap.SERVICE_VIEW_EXCEPTIONS]: `${RouteParams.ModelID}/exceptions`,
|
||||
[PageMap.SERVICE_VIEW_CODE_REPOSITORIES]: `${RouteParams.ModelID}/code-repositories`,
|
||||
};
|
||||
|
||||
@@ -123,6 +124,7 @@ export const LogsRoutePath: Dictionary<string> = {
|
||||
// Metrics product routes
|
||||
export const MetricsRoutePath: Dictionary<string> = {
|
||||
[PageMap.METRICS]: "",
|
||||
[PageMap.METRICS_LIST]: "list",
|
||||
[PageMap.METRIC_VIEW]: "view",
|
||||
[PageMap.METRICS_DOCUMENTATION]: "documentation",
|
||||
};
|
||||
@@ -130,6 +132,7 @@ export const MetricsRoutePath: Dictionary<string> = {
|
||||
// Traces product routes
|
||||
export const TracesRoutePath: Dictionary<string> = {
|
||||
[PageMap.TRACES]: "",
|
||||
[PageMap.TRACES_LIST]: "list",
|
||||
[PageMap.TRACE_VIEW]: `view/${RouteParams.ModelID}`,
|
||||
[PageMap.TRACES_DOCUMENTATION]: "documentation",
|
||||
};
|
||||
@@ -137,12 +140,14 @@ export const TracesRoutePath: Dictionary<string> = {
|
||||
// Profiles product routes
|
||||
export const ProfilesRoutePath: Dictionary<string> = {
|
||||
[PageMap.PROFILES]: "",
|
||||
[PageMap.PROFILES_LIST]: "list",
|
||||
[PageMap.PROFILE_VIEW]: `view/${RouteParams.ModelID}`,
|
||||
[PageMap.PROFILES_DOCUMENTATION]: "documentation",
|
||||
};
|
||||
|
||||
export const ExceptionsRoutePath: Dictionary<string> = {
|
||||
[PageMap.EXCEPTIONS]: "unresolved",
|
||||
[PageMap.EXCEPTIONS]: "overview",
|
||||
[PageMap.EXCEPTIONS_OVERVIEW]: "overview",
|
||||
[PageMap.EXCEPTIONS_UNRESOLVED]: "unresolved",
|
||||
[PageMap.EXCEPTIONS_RESOLVED]: "resolved",
|
||||
[PageMap.EXCEPTIONS_ARCHIVED]: "archived",
|
||||
@@ -1483,6 +1488,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.SERVICE_VIEW_EXCEPTIONS]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/service/${
|
||||
ServiceRoutePath[PageMap.SERVICE_VIEW_EXCEPTIONS]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.SERVICE_VIEW_CODE_REPOSITORIES]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/service/${
|
||||
ServiceRoutePath[PageMap.SERVICE_VIEW_CODE_REPOSITORIES]
|
||||
@@ -2265,6 +2276,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
|
||||
[PageMap.METRICS]: new Route(`/dashboard/${RouteParams.ProjectID}/metrics`),
|
||||
|
||||
[PageMap.METRICS_LIST]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/metrics/${
|
||||
MetricsRoutePath[PageMap.METRICS_LIST]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.METRIC_VIEW]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/metrics/${
|
||||
MetricsRoutePath[PageMap.METRIC_VIEW]
|
||||
@@ -2284,6 +2301,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
|
||||
[PageMap.TRACES]: new Route(`/dashboard/${RouteParams.ProjectID}/traces`),
|
||||
|
||||
[PageMap.TRACES_LIST]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/traces/${
|
||||
TracesRoutePath[PageMap.TRACES_LIST]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.TRACE_VIEW]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/traces/${
|
||||
TracesRoutePath[PageMap.TRACE_VIEW]
|
||||
@@ -2303,6 +2326,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
|
||||
[PageMap.PROFILES]: new Route(`/dashboard/${RouteParams.ProjectID}/profiles`),
|
||||
|
||||
[PageMap.PROFILES_LIST]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/profiles/${
|
||||
ProfilesRoutePath[PageMap.PROFILES_LIST]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.PROFILE_VIEW]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/profiles/${
|
||||
ProfilesRoutePath[PageMap.PROFILE_VIEW]
|
||||
@@ -2768,6 +2797,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.EXCEPTIONS_OVERVIEW]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/exceptions/${
|
||||
ExceptionsRoutePath[PageMap.EXCEPTIONS_OVERVIEW]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.EXCEPTIONS_UNRESOLVED]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/exceptions/${
|
||||
ExceptionsRoutePath[PageMap.EXCEPTIONS_UNRESOLVED]
|
||||
|
||||
@@ -4,7 +4,7 @@ Custom Code Monitor allows you to write custom scripts to monitor your applicati
|
||||
|
||||
#### Example
|
||||
|
||||
The following example shows how to use a Synthetic Monitor:
|
||||
The following example shows how to use a Custom Code Monitor:
|
||||
|
||||
```javascript
|
||||
// You can use axios module.
|
||||
@@ -50,10 +50,49 @@ console.log(stringSecret);
|
||||
```
|
||||
|
||||
|
||||
### Custom Metrics
|
||||
|
||||
You can capture custom metrics from your script using the `oneuptime.captureMetric()` function. These metrics are stored in OneUptime and can be charted on dashboards using the Metric Explorer.
|
||||
|
||||
```javascript
|
||||
oneuptime.captureMetric(name, value, attributes);
|
||||
```
|
||||
|
||||
- `name` (string, required): The metric name (e.g. `"api.response.time"`). It will be stored with a `custom.monitor.` prefix automatically.
|
||||
- `value` (number, required): The numeric metric value.
|
||||
- `attributes` (object, optional): Key-value pairs for additional context.
|
||||
|
||||
#### Example
|
||||
|
||||
```javascript
|
||||
const response = await axios.get('https://api.example.com/health');
|
||||
|
||||
// Capture a simple metric
|
||||
oneuptime.captureMetric('api.response.time', response.data.latency);
|
||||
|
||||
// Capture a metric with attributes
|
||||
oneuptime.captureMetric('api.queue.depth', response.data.queueDepth, {
|
||||
region: 'us-east-1',
|
||||
environment: 'production'
|
||||
});
|
||||
|
||||
return {
|
||||
data: response.data
|
||||
};
|
||||
```
|
||||
|
||||
Once captured, these metrics appear in the Metric Explorer under names like `custom.monitor.api.response.time`. You can add them to dashboard charts, set up alerts, and filter by monitor, probe, or any custom attributes you provided.
|
||||
|
||||
**Limits:**
|
||||
- Maximum 100 metrics per script execution.
|
||||
- Metric names are limited to 200 characters.
|
||||
- Values must be numeric.
|
||||
|
||||
### Modules available in the script
|
||||
- `axios`: You can use this module to make HTTP requests. It is a promise-based HTTP client for the browser and Node.js.
|
||||
- `crypto`: You can use this module to perform cryptographic operations. It is a built-in Node.js module that provides cryptographic functionality that includes a set of wrappers for OpenSSL's hash, HMAC, cipher, decipher, sign, and verify functions.
|
||||
- `console.log`: You can use this module to log data to the console. This is useful for debugging purposes.
|
||||
- `oneuptime.captureMetric`: You can use this to capture custom metrics from your script. See the Custom Metrics section above.
|
||||
- `http`: You can use this module to make HTTP requests. It is a built-in Node.js module that provides an HTTP client and server.
|
||||
- `https`: You can use this module to make HTTPS requests. It is a built-in Node.js module that provides an HTTPS client and server.
|
||||
|
||||
|
||||
@@ -103,11 +103,54 @@ let booleanSecret = {{monitorSecrets.BooleanSecret}};
|
||||
console.log(stringSecret);
|
||||
```
|
||||
|
||||
### Custom Metrics
|
||||
|
||||
You can capture custom metrics from your script using the `oneuptime.captureMetric()` function. These metrics are stored in OneUptime and can be charted on dashboards using the Metric Explorer.
|
||||
|
||||
```javascript
|
||||
oneuptime.captureMetric(name, value, attributes);
|
||||
```
|
||||
|
||||
- `name` (string, required): The metric name (e.g. `"dashboard.load.time"`). It will be stored with a `custom.monitor.` prefix automatically.
|
||||
- `value` (number, required): The numeric metric value.
|
||||
- `attributes` (object, optional): Key-value pairs for additional context.
|
||||
|
||||
#### Example
|
||||
|
||||
```javascript
|
||||
await page.goto('https://app.example.com');
|
||||
|
||||
const startTime = Date.now();
|
||||
await page.waitForSelector('#dashboard-loaded');
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
// Capture page load time as a custom metric
|
||||
oneuptime.captureMetric('dashboard.load.time', loadTime, {
|
||||
page: 'dashboard'
|
||||
});
|
||||
|
||||
const screenshots = {};
|
||||
screenshots['dashboard'] = await page.screenshot();
|
||||
|
||||
return {
|
||||
data: { loadTime },
|
||||
screenshots: screenshots
|
||||
};
|
||||
```
|
||||
|
||||
Once captured, these metrics appear in the Metric Explorer under names like `custom.monitor.dashboard.load.time`. You can add them to dashboard charts, set up alerts, and filter by monitor, probe, browser type, screen size, or any custom attributes you provided.
|
||||
|
||||
**Limits:**
|
||||
- Maximum 100 metrics per script execution.
|
||||
- Metric names are limited to 200 characters.
|
||||
- Values must be numeric.
|
||||
|
||||
### Modules available in the script
|
||||
- `page`: You can use this module to interact with the browser. It is a Playwright Page object that allows you to perform actions like clicking buttons, filling forms, and taking screenshots. You can access the browser context via `page.context()` if needed (for example, to create a new page or deal with popups).
|
||||
- `axios`: You can use this module to make HTTP requests. It is a promise-based HTTP client for the browser and Node.js.
|
||||
- `crypto`: You can use this module to perform cryptographic operations. It is a built-in Node.js module that provides cryptographic functionality that includes a set of wrappers for OpenSSL's hash, HMAC, cipher, decipher, sign, and verify functions.
|
||||
- `console.log`: You can use this module to log data to the console. This is useful for debugging purposes.
|
||||
- `oneuptime.captureMetric`: You can use this to capture custom metrics from your script. See the Custom Metrics section above.
|
||||
- `http`: You can use this module to make HTTP requests. It is a built-in Node.js module that provides an HTTP client and server.
|
||||
- `https`: You can use this module to make HTTPS requests. It is a built-in Node.js module that provides an HTTPS client and server.
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ pyroscope.ebpf "default" {
|
||||
|
||||
pyroscope.write "oneuptime" {
|
||||
endpoint {
|
||||
url = "https://oneuptime.com/otlp/v1/profiles"
|
||||
url = "https://oneuptime.com/pyroscope"
|
||||
headers = {
|
||||
"x-oneuptime-token" = "YOUR_ONEUPTIME_SERVICE_TOKEN",
|
||||
}
|
||||
|
||||
@@ -81,6 +81,12 @@ export default class SSOUtil {
|
||||
throw new BadRequestException("SAML Assertion not found");
|
||||
}
|
||||
|
||||
if (samlAssertion.length !== 1) {
|
||||
throw new BadRequestException(
|
||||
"Expected exactly one Assertion in SAML Response",
|
||||
);
|
||||
}
|
||||
|
||||
const samlSubject: JSONArray =
|
||||
((samlAssertion[0] as JSONObject)["saml2:Subject"] as JSONArray) ||
|
||||
((samlAssertion[0] as JSONObject)["saml:Subject"] as JSONArray) ||
|
||||
@@ -158,6 +164,10 @@ export default class SSOUtil {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (samlAssertion.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const samlAttributeStatement: JSONArray =
|
||||
((samlAssertion[0] as JSONObject)[
|
||||
"saml2:AttributeStatement"
|
||||
@@ -242,6 +252,12 @@ export default class SSOUtil {
|
||||
throw new BadRequestException("SAML Assertion not found");
|
||||
}
|
||||
|
||||
if (samlAssertion.length !== 1) {
|
||||
throw new BadRequestException(
|
||||
"Expected exactly one Assertion in SAML Response",
|
||||
);
|
||||
}
|
||||
|
||||
const samlSubject: JSONArray =
|
||||
((samlAssertion[0] as JSONObject)["saml2:Subject"] as JSONArray) ||
|
||||
((samlAssertion[0] as JSONObject)["saml:Subject"] as JSONArray) ||
|
||||
|
||||
1
App/FeatureSet/PublicDashboard/index.d.ts
vendored
1
App/FeatureSet/PublicDashboard/index.d.ts
vendored
@@ -2,6 +2,7 @@ declare module "*.png";
|
||||
declare module "*.svg";
|
||||
declare module "*.jpg";
|
||||
declare module "*.gif";
|
||||
declare module "*.css";
|
||||
|
||||
declare module "react-syntax-highlighter/dist/esm/prism-light";
|
||||
declare module "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
15
App/FeatureSet/PublicDashboard/package-lock.json
generated
15
App/FeatureSet/PublicDashboard/package-lock.json
generated
@@ -52,6 +52,7 @@
|
||||
"@opentelemetry/sdk-node": "^0.207.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@pyroscope/nodejs": "^0.4.11",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
@@ -84,7 +85,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"isolated-vm": "^6.1.2",
|
||||
"json2csv": "^5.0.7",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -382,9 +383,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -692,9 +693,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
15
App/FeatureSet/StatusPage/package-lock.json
generated
15
App/FeatureSet/StatusPage/package-lock.json
generated
@@ -52,6 +52,7 @@
|
||||
"@opentelemetry/sdk-node": "^0.207.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@pyroscope/nodejs": "^0.4.11",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
@@ -84,7 +85,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"isolated-vm": "^6.1.2",
|
||||
"json2csv": "^5.0.7",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -777,9 +778,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -900,9 +901,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
|
||||
@@ -5,13 +5,15 @@ import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import MarkdownViewer from "Common/UI/Components/Markdown.tsx/LazyMarkdownViewer";
|
||||
import MonitorUptimeGraph from "Common/UI/Components/MonitorGraphs/Uptime";
|
||||
import UptimeUtil from "Common/UI/Components/MonitorGraphs/UptimeUtil";
|
||||
import UptimeBarDayModal from "Common/UI/Components/MonitorGraphs/UptimeBarDayModal";
|
||||
import Tooltip from "Common/UI/Components/Tooltip/Tooltip";
|
||||
import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes";
|
||||
import MonitorStatus from "Common/Models/DatabaseModels/MonitorStatus";
|
||||
import MonitorStatusTimelne from "Common/Models/DatabaseModels/MonitorStatusTimeline";
|
||||
import StatusPageHistoryChartBarColorRule from "Common/Models/DatabaseModels/StatusPageHistoryChartBarColorRule";
|
||||
import UptimePrecision from "Common/Types/StatusPage/UptimePrecision";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import UptimeBarTooltipIncident from "Common/Types/Monitor/UptimeBarTooltipIncident";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
|
||||
export interface ComponentProps {
|
||||
monitorName: string;
|
||||
@@ -31,11 +33,18 @@ export interface ComponentProps {
|
||||
downtimeMonitorStatuses: Array<MonitorStatus>;
|
||||
defaultBarColor: Color;
|
||||
uptimeHistoryDays?: number | undefined;
|
||||
incidents?: Array<UptimeBarTooltipIncident> | undefined;
|
||||
onIncidentClick?: ((incidentId: string) => void) | undefined;
|
||||
}
|
||||
|
||||
const MonitorOverview: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||||
const [selectedDayIncidents, setSelectedDayIncidents] = useState<
|
||||
Array<UptimeBarTooltipIncident>
|
||||
>([]);
|
||||
|
||||
const getCurrentStatus: GetReactElementFunction = (): ReactElement => {
|
||||
// if the current status is operational then show uptime Percent.
|
||||
|
||||
@@ -137,6 +146,15 @@ const MonitorOverview: FunctionComponent<ComponentProps> = (
|
||||
endDate={props.endDate}
|
||||
isLoading={false}
|
||||
height={props.uptimeGraphHeight}
|
||||
incidents={props.incidents}
|
||||
onIncidentClick={props.onIncidentClick}
|
||||
onBarClick={(
|
||||
date: Date,
|
||||
incidents: Array<UptimeBarTooltipIncident>,
|
||||
) => {
|
||||
setSelectedDay(date);
|
||||
setSelectedDayIncidents(incidents);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -148,6 +166,19 @@ const MonitorOverview: FunctionComponent<ComponentProps> = (
|
||||
<div>Today</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incident detail modal */}
|
||||
{selectedDay && (
|
||||
<UptimeBarDayModal
|
||||
date={selectedDay}
|
||||
incidents={selectedDayIncidents}
|
||||
onIncidentClick={props.onIncidentClick}
|
||||
onClose={() => {
|
||||
setSelectedDay(null);
|
||||
setSelectedDayIncidents([]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import ScheduledMaintenanceGroup from "../../Types/ScheduledMaintenanceGroup";
|
||||
import API from "../../Utils/API";
|
||||
import { STATUS_PAGE_API_URL } from "../../Utils/Config";
|
||||
import StatusPageUtil from "../../Utils/StatusPage";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import { getAnnouncementEventItem } from "../Announcement/Detail";
|
||||
import { getIncidentEventItem, getEpisodeEventItem } from "../Incidents/Detail";
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
@@ -40,6 +42,7 @@ import IncidentEpisodePublicNote from "Common/Models/DatabaseModels/IncidentEpis
|
||||
import IncidentEpisodeStateTimeline from "Common/Models/DatabaseModels/IncidentEpisodeStateTimeline";
|
||||
import IncidentPublicNote from "Common/Models/DatabaseModels/IncidentPublicNote";
|
||||
import IncidentStateTimeline from "Common/Models/DatabaseModels/IncidentStateTimeline";
|
||||
import Monitor from "Common/Models/DatabaseModels/Monitor";
|
||||
import MonitorStatus from "Common/Models/DatabaseModels/MonitorStatus";
|
||||
import MonitorStatusTimeline from "Common/Models/DatabaseModels/MonitorStatusTimeline";
|
||||
import ScheduledMaintenance from "Common/Models/DatabaseModels/ScheduledMaintenance";
|
||||
@@ -59,6 +62,8 @@ import React, {
|
||||
import UptimePrecision from "Common/Types/StatusPage/UptimePrecision";
|
||||
import StatusPageResourceUptimeUtil from "Common/Utils/StatusPage/ResourceUptime";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import UptimeBarTooltipIncident from "Common/Types/Monitor/UptimeBarTooltipIncident";
|
||||
import Color from "Common/Types/Color";
|
||||
|
||||
const Overview: FunctionComponent<PageComponentProps> = (
|
||||
props: PageComponentProps,
|
||||
@@ -141,6 +146,10 @@ const Overview: FunctionComponent<PageComponentProps> = (
|
||||
const [monitorGroupCurrentStatuses, setMonitorGroupCurrentStatuses] =
|
||||
useState<Dictionary<ObjectID>>({});
|
||||
|
||||
const [timelineIncidents, setTimelineIncidents] = useState<
|
||||
Array<UptimeBarTooltipIncident>
|
||||
>([]);
|
||||
|
||||
StatusPageUtil.checkIfUserHasLoggedIn();
|
||||
|
||||
const loadPage: PromiseVoidFunction = async (): Promise<void> => {
|
||||
@@ -277,6 +286,39 @@ const Overview: FunctionComponent<PageComponentProps> = (
|
||||
(data["monitorGroupCurrentStatuses"] as JSONObject) || {},
|
||||
) as Dictionary<ObjectID>;
|
||||
|
||||
// Parse timeline incidents for uptime bar tooltips
|
||||
const rawTimelineIncidents: Array<Incident> = BaseModel.fromJSONArray(
|
||||
(data["timelineIncidents"] as JSONArray) || [],
|
||||
Incident,
|
||||
);
|
||||
|
||||
const parsedTimelineIncidents: Array<UptimeBarTooltipIncident> =
|
||||
rawTimelineIncidents.map((incident: Incident) => {
|
||||
return {
|
||||
id: incident._id || "",
|
||||
title: incident.title || "",
|
||||
declaredAt: incident.declaredAt || new Date(),
|
||||
incidentSeverity: incident.incidentSeverity
|
||||
? {
|
||||
name: incident.incidentSeverity.name || "",
|
||||
color:
|
||||
incident.incidentSeverity.color || new Color("#000000"),
|
||||
}
|
||||
: undefined,
|
||||
currentIncidentState: incident.currentIncidentState
|
||||
? {
|
||||
name: incident.currentIncidentState.name || "",
|
||||
color:
|
||||
incident.currentIncidentState.color || new Color("#000000"),
|
||||
}
|
||||
: undefined,
|
||||
monitorIds: (incident.monitors || []).map((m: Monitor) => {
|
||||
return new ObjectID(m._id?.toString() || "");
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
setTimelineIncidents(parsedTimelineIncidents);
|
||||
setMonitorsInGroup(monitorsInGroup);
|
||||
setMonitorGroupCurrentStatuses(monitorGroupCurrentStatuses);
|
||||
|
||||
@@ -463,6 +505,15 @@ const Overview: FunctionComponent<PageComponentProps> = (
|
||||
currentStatus.color = Green;
|
||||
}
|
||||
|
||||
const monitorId: string = resource.monitor?._id?.toString() || "";
|
||||
|
||||
const monitorIncidents: Array<UptimeBarTooltipIncident> =
|
||||
timelineIncidents.filter((incident: UptimeBarTooltipIncident) => {
|
||||
return incident.monitorIds.some((id: ObjectID) => {
|
||||
return id.toString() === monitorId;
|
||||
});
|
||||
});
|
||||
|
||||
elements.push(
|
||||
<MonitorOverview
|
||||
key={Math.random()}
|
||||
@@ -495,6 +546,17 @@ const Overview: FunctionComponent<PageComponentProps> = (
|
||||
uptimeGraphHeight={10}
|
||||
defaultBarColor={statusPage?.defaultBarColor || Green}
|
||||
uptimeHistoryDays={uptimeHistoryDays}
|
||||
incidents={monitorIncidents}
|
||||
onIncidentClick={(incidentId: string) => {
|
||||
Navigation.navigate(
|
||||
RouteUtil.populateRouteParams(
|
||||
StatusPageUtil.isPreviewPage()
|
||||
? (RouteMap[PageMap.PREVIEW_INCIDENT_DETAIL] as Route)
|
||||
: (RouteMap[PageMap.INCIDENT_DETAIL] as Route),
|
||||
new ObjectID(incidentId),
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -519,6 +581,20 @@ const Overview: FunctionComponent<PageComponentProps> = (
|
||||
currentStatus.color = Green;
|
||||
}
|
||||
|
||||
// Get monitor IDs in this group
|
||||
const groupMonitorIds: Array<string> = (
|
||||
monitorsInGroup[resource.monitorGroupId?.toString() || ""] || []
|
||||
).map((id: ObjectID) => {
|
||||
return id.toString();
|
||||
});
|
||||
|
||||
const groupIncidents: Array<UptimeBarTooltipIncident> =
|
||||
timelineIncidents.filter((incident: UptimeBarTooltipIncident) => {
|
||||
return incident.monitorIds.some((id: ObjectID) => {
|
||||
return groupMonitorIds.includes(id.toString());
|
||||
});
|
||||
});
|
||||
|
||||
elements.push(
|
||||
<MonitorOverview
|
||||
key={Math.random()}
|
||||
@@ -551,6 +627,17 @@ const Overview: FunctionComponent<PageComponentProps> = (
|
||||
uptimeGraphHeight={10}
|
||||
defaultBarColor={statusPage?.defaultBarColor || Green}
|
||||
uptimeHistoryDays={uptimeHistoryDays}
|
||||
incidents={groupIncidents}
|
||||
onIncidentClick={(incidentId: string) => {
|
||||
Navigation.navigate(
|
||||
RouteUtil.populateRouteParams(
|
||||
StatusPageUtil.isPreviewPage()
|
||||
? (RouteMap[PageMap.PREVIEW_INCIDENT_DETAIL] as Route)
|
||||
: (RouteMap[PageMap.INCIDENT_DETAIL] as Route),
|
||||
new ObjectID(incidentId),
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
router.post(
|
||||
"/otlp/v1/traces",
|
||||
OpenTelemetryRequestMiddleware.parseBody,
|
||||
OpenTelemetryRequestMiddleware.getProductType,
|
||||
TelemetryIngest.isAuthorizedServiceMiddleware,
|
||||
async (
|
||||
@@ -38,6 +39,7 @@ router.post(
|
||||
|
||||
router.post(
|
||||
"/otlp/v1/metrics",
|
||||
OpenTelemetryRequestMiddleware.parseBody,
|
||||
OpenTelemetryRequestMiddleware.getProductType,
|
||||
TelemetryIngest.isAuthorizedServiceMiddleware,
|
||||
async (
|
||||
@@ -51,6 +53,7 @@ router.post(
|
||||
|
||||
router.post(
|
||||
"/otlp/v1/logs",
|
||||
OpenTelemetryRequestMiddleware.parseBody,
|
||||
OpenTelemetryRequestMiddleware.getProductType,
|
||||
TelemetryIngest.isAuthorizedServiceMiddleware,
|
||||
async (
|
||||
@@ -64,6 +67,7 @@ router.post(
|
||||
|
||||
router.post(
|
||||
"/otlp/v1/profiles",
|
||||
OpenTelemetryRequestMiddleware.parseBody,
|
||||
OpenTelemetryRequestMiddleware.getProductType,
|
||||
TelemetryIngest.isAuthorizedServiceMiddleware,
|
||||
async (
|
||||
62
App/FeatureSet/Telemetry/API/Pyroscope.ts
Normal file
62
App/FeatureSet/Telemetry/API/Pyroscope.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import TelemetryIngest, {
|
||||
TelemetryRequest,
|
||||
} from "Common/Server/Middleware/TelemetryIngest";
|
||||
import ProductType from "Common/Types/MeteredPlan/ProductType";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
ExpressRouter,
|
||||
NextFunction,
|
||||
RequestHandler,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import PyroscopeIngestService from "../Services/PyroscopeIngestService";
|
||||
import MultipartFormDataMiddleware from "Common/Server/Middleware/MultipartFormData";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
// Set product type to Profiles for metering
|
||||
const setProfilesProductType: RequestHandler = (
|
||||
req: ExpressRequest,
|
||||
_res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): void => {
|
||||
(req as TelemetryRequest).productType = ProductType.Profiles;
|
||||
next();
|
||||
};
|
||||
|
||||
/*
|
||||
* Map Authorization: Bearer <token> to x-oneuptime-token header
|
||||
* Pyroscope SDKs use authToken which sends Authorization: Bearer
|
||||
*/
|
||||
const mapBearerTokenMiddleware: RequestHandler = (
|
||||
req: ExpressRequest,
|
||||
_res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): void => {
|
||||
if (!req.headers["x-oneuptime-token"]) {
|
||||
const authHeader: string | undefined = req.headers[
|
||||
"authorization"
|
||||
] as string;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
req.headers["x-oneuptime-token"] = authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
router.post(
|
||||
"/pyroscope/ingest",
|
||||
MultipartFormDataMiddleware,
|
||||
mapBearerTokenMiddleware,
|
||||
setProfilesProductType,
|
||||
TelemetryIngest.isAuthorizedServiceMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
return PyroscopeIngestService.ingestPyroscopeProfile(req, res, next);
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
80
App/FeatureSet/Telemetry/Index.ts
Normal file
80
App/FeatureSet/Telemetry/Index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import OTelIngestAPI from "./API/OTelIngest";
|
||||
import MetricsAPI from "./API/Metrics";
|
||||
import SyslogAPI from "./API/Syslog";
|
||||
import FluentAPI from "./API/Fluent";
|
||||
import PyroscopeAPI from "./API/Pyroscope";
|
||||
// ProbeIngest routes
|
||||
import ProbeIngestRegisterAPI from "./API/ProbeIngest/Register";
|
||||
import ProbeIngestMonitorAPI from "./API/ProbeIngest/Monitor";
|
||||
import ProbeIngestAPI from "./API/ProbeIngest/Probe";
|
||||
import IncomingEmailAPI from "./API/ProbeIngest/IncomingEmail";
|
||||
// ServerMonitorIngest routes
|
||||
import ServerMonitorAPI from "./API/ServerMonitorIngest/ServerMonitor";
|
||||
// IncomingRequestIngest routes
|
||||
import IncomingRequestAPI from "./API/IncomingRequestIngest/IncomingRequest";
|
||||
|
||||
import "./Jobs/TelemetryIngest/ProcessTelemetry";
|
||||
import { TELEMETRY_CONCURRENCY } from "./Config";
|
||||
import { startGrpcServer } from "./GrpcServer";
|
||||
|
||||
import FeatureSet from "Common/Server/Types/FeatureSet";
|
||||
import Express, { ExpressApplication } from "Common/Server/Utils/Express";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
|
||||
const app: ExpressApplication = Express.getExpressApp();
|
||||
|
||||
const TELEMETRY_PREFIXES: Array<string> = ["/telemetry", "/"];
|
||||
const PROBE_INGEST_PREFIXES: Array<string> = [
|
||||
"/probe-ingest",
|
||||
"/ingestor",
|
||||
"/",
|
||||
];
|
||||
const SERVER_MONITOR_PREFIXES: Array<string> = ["/server-monitor-ingest", "/"];
|
||||
const INCOMING_REQUEST_PREFIXES: Array<string> = [
|
||||
"/incoming-request-ingest",
|
||||
"/",
|
||||
];
|
||||
|
||||
const TelemetryFeatureSet: FeatureSet = {
|
||||
init: async (): Promise<void> => {
|
||||
try {
|
||||
/*
|
||||
* Mount telemetry routes only during feature-set init so they sit behind
|
||||
* the shared middleware stack from StartServer (body parsers, headers, etc.).
|
||||
*/
|
||||
app.use(TELEMETRY_PREFIXES, OTelIngestAPI);
|
||||
app.use(TELEMETRY_PREFIXES, MetricsAPI);
|
||||
app.use(TELEMETRY_PREFIXES, SyslogAPI);
|
||||
app.use(TELEMETRY_PREFIXES, FluentAPI);
|
||||
app.use(TELEMETRY_PREFIXES, PyroscopeAPI);
|
||||
|
||||
/*
|
||||
* ProbeIngest routes under ["/probe-ingest", "/ingestor", "/"]
|
||||
* "/ingestor" is used for backward compatibility because probes are already deployed with this path in client environments.
|
||||
*/
|
||||
app.use(PROBE_INGEST_PREFIXES, ProbeIngestRegisterAPI);
|
||||
app.use(PROBE_INGEST_PREFIXES, ProbeIngestMonitorAPI);
|
||||
app.use(PROBE_INGEST_PREFIXES, ProbeIngestAPI);
|
||||
app.use(["/probe-ingest", "/"], IncomingEmailAPI);
|
||||
|
||||
// ServerMonitorIngest routes under ["/server-monitor-ingest", "/"]
|
||||
app.use(SERVER_MONITOR_PREFIXES, ServerMonitorAPI);
|
||||
|
||||
// IncomingRequestIngest routes under ["/incoming-request-ingest", "/"]
|
||||
app.use(INCOMING_REQUEST_PREFIXES, IncomingRequestAPI);
|
||||
|
||||
logger.info(
|
||||
`Telemetry Service - Queue concurrency: ${TELEMETRY_CONCURRENCY}`,
|
||||
);
|
||||
|
||||
// Start gRPC OTLP server on port 4317
|
||||
startGrpcServer();
|
||||
} catch (err) {
|
||||
logger.error("Telemetry FeatureSet Init Failed:");
|
||||
logger.error(err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default TelemetryFeatureSet;
|
||||
@@ -5,28 +5,40 @@ import {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
NextFunction,
|
||||
headerValueToString,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import CaptureSpan from "Common/Server/Utils/Telemetry/CaptureSpan";
|
||||
import protobuf from "protobufjs";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import path from "path";
|
||||
import zlib from "zlib";
|
||||
import { promisify } from "util";
|
||||
|
||||
// Load proto file for OTel
|
||||
|
||||
const PROTO_DIR: string = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"ProtoFiles",
|
||||
"OTel",
|
||||
"v1",
|
||||
);
|
||||
|
||||
// Create a root namespace
|
||||
const LogsProto: protobuf.Root = protobuf.loadSync(
|
||||
"/usr/src/app/ProtoFiles/OTel/v1/logs.proto",
|
||||
path.join(PROTO_DIR, "logs.proto"),
|
||||
);
|
||||
|
||||
const TracesProto: protobuf.Root = protobuf.loadSync(
|
||||
"/usr/src/app/ProtoFiles/OTel/v1/traces.proto",
|
||||
path.join(PROTO_DIR, "traces.proto"),
|
||||
);
|
||||
|
||||
const MetricsProto: protobuf.Root = protobuf.loadSync(
|
||||
"/usr/src/app/ProtoFiles/OTel/v1/metrics.proto",
|
||||
path.join(PROTO_DIR, "metrics.proto"),
|
||||
);
|
||||
|
||||
const ProfilesProto: protobuf.Root = protobuf.loadSync(
|
||||
"/usr/src/app/ProtoFiles/OTel/v1/profiles.proto",
|
||||
path.join(PROTO_DIR, "profiles.proto"),
|
||||
);
|
||||
|
||||
// Lookup the message type
|
||||
@@ -34,8 +46,56 @@ const LogsData: protobuf.Type = LogsProto.lookupType("LogsData");
|
||||
const TracesData: protobuf.Type = TracesProto.lookupType("TracesData");
|
||||
const MetricsData: protobuf.Type = MetricsProto.lookupType("MetricsData");
|
||||
const ProfilesData: protobuf.Type = ProfilesProto.lookupType("ProfilesData");
|
||||
const gunzipAsync: (buffer: Uint8Array) => Promise<Buffer> = promisify(
|
||||
zlib.gunzip,
|
||||
);
|
||||
|
||||
export default class OpenTelemetryRequestMiddleware {
|
||||
@CaptureSpan()
|
||||
public static async parseBody(
|
||||
req: ExpressRequest,
|
||||
_res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (req.body !== undefined && req.body !== null) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const requestBuffer: Buffer = await new Promise<Buffer>(
|
||||
(resolve: (value: Buffer) => void, reject: (err: Error) => void) => {
|
||||
const chunks: Array<Buffer> = [];
|
||||
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
chunks.push(
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf-8"),
|
||||
);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
|
||||
req.on("error", (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const contentEncoding: string | undefined = headerValueToString(
|
||||
req.headers["content-encoding"],
|
||||
);
|
||||
|
||||
req.body = contentEncoding?.includes("gzip")
|
||||
? await gunzipAsync(requestBuffer)
|
||||
: requestBuffer;
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getProductType(
|
||||
req: ExpressRequest,
|
||||
@@ -45,7 +105,9 @@ export default class OpenTelemetryRequestMiddleware {
|
||||
try {
|
||||
let productType: ProductType;
|
||||
|
||||
const contentType: string | undefined = req.headers["content-type"];
|
||||
const contentType: string | undefined = headerValueToString(
|
||||
req.headers["content-type"],
|
||||
);
|
||||
const isProtobuf: boolean =
|
||||
req.body instanceof Uint8Array &&
|
||||
(!contentType ||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user