mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
Compare commits
186 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 | ||
|
|
d8ec86adb3 | ||
|
|
64f21ac8b1 | ||
|
|
e953b33703 | ||
|
|
ffafada55b | ||
|
|
4caed413a3 | ||
|
|
594c5a7fc3 | ||
|
|
2845177743 | ||
|
|
75b2d63353 | ||
|
|
b5a5cf8b40 | ||
|
|
cc68ea4539 | ||
|
|
02c0c02760 | ||
|
|
ae230589c5 | ||
|
|
a0577b0175 | ||
|
|
472ebed3be | ||
|
|
796c52da4d | ||
|
|
3a19e600d5 | ||
|
|
b847d3a0b9 | ||
|
|
9f09eacf25 | ||
|
|
809a85c91d | ||
|
|
38ff1ae0c7 | ||
|
|
194bb87b45 | ||
|
|
26c402928e | ||
|
|
e0fe6e9827 | ||
|
|
0269593326 | ||
|
|
13d33b6df3 | ||
|
|
2c7a560aee | ||
|
|
b8e0f0de91 | ||
|
|
27ad3d6b99 | ||
|
|
e655385c4d | ||
|
|
9adbd04538 | ||
|
|
6ef8cc6db6 | ||
|
|
1c12f516ff | ||
|
|
9e9c7743f4 | ||
|
|
5bfd6ebd3d | ||
|
|
78d608a6cf | ||
|
|
5155858f67 | ||
|
|
862682388e | ||
|
|
308bade79e | ||
|
|
a41dfa8980 | ||
|
|
1a8fee15b8 | ||
|
|
7e4efeaeaa | ||
|
|
2d007b8676 | ||
|
|
0ba3a70a4b | ||
|
|
8672f442db | ||
|
|
e0f1da768b | ||
|
|
71b8891232 | ||
|
|
a48e8a2710 | ||
|
|
465cc798ec | ||
|
|
0130a850ca | ||
|
|
526eb756b1 | ||
|
|
59a9636870 | ||
|
|
a994c7b7b8 | ||
|
|
dc44e92867 | ||
|
|
4a0151243f | ||
|
|
e06b9a95ce | ||
|
|
3fd22cd3fb | ||
|
|
3c8dc1eee1 | ||
|
|
c91c653d9c | ||
|
|
086f01617c | ||
|
|
1d78ec8922 | ||
|
|
5ecf8ce881 | ||
|
|
147ff47aa2 | ||
|
|
a1122ed241 | ||
|
|
72a796c03d | ||
|
|
bec1c760ca | ||
|
|
b939b4ebf0 | ||
|
|
50717e5167 | ||
|
|
4b339f07ec | ||
|
|
e9be1c0898 | ||
|
|
b4dc6f1f02 | ||
|
|
ad6ac1a480 | ||
|
|
af3004394e | ||
|
|
028212731f | ||
|
|
7419ff4437 | ||
|
|
5b579fa55c | ||
|
|
f0ed6ae29f | ||
|
|
16e1d5ccf3 | ||
|
|
8e671a9a41 | ||
|
|
02e7506f89 |
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
|
||||
|
||||
33
AIAgent/package-lock.json
generated
33
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": {
|
||||
@@ -3986,9 +3987,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -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;
|
||||
@@ -72,6 +72,10 @@ WORKDIR /usr/src/app/FeatureSet/StatusPage
|
||||
COPY ./App/FeatureSet/StatusPage/package*.json /usr/src/app/FeatureSet/StatusPage/
|
||||
RUN npm install
|
||||
|
||||
WORKDIR /usr/src/app/FeatureSet/PublicDashboard
|
||||
COPY ./App/FeatureSet/PublicDashboard/package*.json /usr/src/app/FeatureSet/PublicDashboard/
|
||||
RUN npm install
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Expose ports.
|
||||
@@ -89,6 +93,7 @@ COPY ./App/FeatureSet/Accounts /usr/src/app/FeatureSet/Accounts
|
||||
COPY ./App/FeatureSet/Dashboard /usr/src/app/FeatureSet/Dashboard
|
||||
COPY ./App/FeatureSet/AdminDashboard /usr/src/app/FeatureSet/AdminDashboard
|
||||
COPY ./App/FeatureSet/StatusPage /usr/src/app/FeatureSet/StatusPage
|
||||
COPY ./App/FeatureSet/PublicDashboard /usr/src/app/FeatureSet/PublicDashboard
|
||||
# Bundle frontend source
|
||||
RUN npm run build-frontends:prod
|
||||
# Bundle app source
|
||||
|
||||
22
App/FeatureSet/Accounts/package-lock.json
generated
22
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"
|
||||
@@ -1179,10 +1180,11 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
|
||||
22
App/FeatureSet/AdminDashboard/package-lock.json
generated
22
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"
|
||||
@@ -1163,10 +1164,11 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
|
||||
@@ -20,12 +20,6 @@ const DashboardNavbar: FunctionComponent = (): ReactElement => {
|
||||
icon: IconProp.Folder,
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.PROJECTS] as Route),
|
||||
},
|
||||
{
|
||||
id: "settings-nav-bar-item",
|
||||
title: "Settings",
|
||||
icon: IconProp.Settings,
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.SETTINGS] as Route),
|
||||
},
|
||||
{
|
||||
id: "more-nav-bar-item",
|
||||
title: "More",
|
||||
@@ -34,6 +28,12 @@ const DashboardNavbar: FunctionComponent = (): ReactElement => {
|
||||
RouteMap[PageMap.MORE_EMAIL] as Route,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "settings-nav-bar-item",
|
||||
title: "Settings",
|
||||
icon: IconProp.Settings,
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.SETTINGS] as Route),
|
||||
},
|
||||
];
|
||||
|
||||
return <NavBar items={navItems} />;
|
||||
|
||||
@@ -399,6 +399,12 @@ import PushNotificationLogService, {
|
||||
import SpanService, {
|
||||
SpanService as SpanServiceType,
|
||||
} from "Common/Server/Services/SpanService";
|
||||
import ProfileService, {
|
||||
ProfileService as ProfileServiceType,
|
||||
} from "Common/Server/Services/ProfileService";
|
||||
import ProfileSampleService, {
|
||||
ProfileSampleService as ProfileSampleServiceType,
|
||||
} from "Common/Server/Services/ProfileSampleService";
|
||||
import StatusPageAnnouncementAPI from "Common/Server/API/StatusPageAnnouncementAPI";
|
||||
import StatusPageCustomFieldService, {
|
||||
Service as StatusPageCustomFieldServiceType,
|
||||
@@ -502,6 +508,8 @@ import Express, { ExpressApplication } from "Common/Server/Utils/Express";
|
||||
import Log from "Common/Models/AnalyticsModels/Log";
|
||||
import Metric from "Common/Models/AnalyticsModels/Metric";
|
||||
import Span from "Common/Models/AnalyticsModels/Span";
|
||||
import Profile from "Common/Models/AnalyticsModels/Profile";
|
||||
import ProfileSample from "Common/Models/AnalyticsModels/ProfileSample";
|
||||
import ApiKey from "Common/Models/DatabaseModels/ApiKey";
|
||||
import ApiKeyPermission from "Common/Models/DatabaseModels/ApiKeyPermission";
|
||||
import CallLog from "Common/Models/DatabaseModels/CallLog";
|
||||
@@ -1286,6 +1294,22 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAnalyticsAPI<Profile, ProfileServiceType>(
|
||||
Profile,
|
||||
ProfileService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAnalyticsAPI<ProfileSample, ProfileSampleServiceType>(
|
||||
ProfileSample,
|
||||
ProfileSampleService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<TelemetryUsageBilling, TelemetryUsageBillingServiceType>(
|
||||
|
||||
19
App/FeatureSet/Dashboard/package-lock.json
generated
19
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"
|
||||
@@ -1720,7 +1721,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -92,6 +92,15 @@ const ExceptionsRoutes: React.LazyExoticComponent<
|
||||
};
|
||||
});
|
||||
});
|
||||
const ProfilesRoutes: React.LazyExoticComponent<
|
||||
AllRoutesModule["ProfilesRoutes"]
|
||||
> = lazy(() => {
|
||||
return import("./Routes/AllRoutes").then((m: AllRoutesModule) => {
|
||||
return {
|
||||
default: m.ProfilesRoutes,
|
||||
};
|
||||
});
|
||||
});
|
||||
const IncidentsRoutes: React.LazyExoticComponent<
|
||||
AllRoutesModule["IncidentsRoutes"]
|
||||
> = lazy(() => {
|
||||
@@ -507,6 +516,12 @@ const App: () => JSX.Element = () => {
|
||||
element={<TracesRoutes {...commonPageProps} />}
|
||||
/>
|
||||
|
||||
{/* Profiles */}
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.PROFILES_ROOT]?.toString() || ""}
|
||||
element={<ProfilesRoutes {...commonPageProps} />}
|
||||
/>
|
||||
|
||||
{/* Monitors */}
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.MONITORS_ROOT]?.toString() || ""}
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, {
|
||||
} from "react";
|
||||
import {
|
||||
ComponentArgument,
|
||||
ComponentArgumentSection,
|
||||
ComponentInputType,
|
||||
} from "Common/Types/Dashboard/DashboardComponents/ComponentArgument";
|
||||
import DashboardComponentsUtil from "Common/Utils/Dashboard/Components/Index";
|
||||
@@ -21,8 +22,13 @@ import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentTyp
|
||||
import MetricQueryConfig from "../../Metrics/MetricQueryConfig";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import { CustomElementProps } from "Common/UI/Components/Forms/Types/Field";
|
||||
import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import CollapsibleSection from "Common/UI/Components/CollapsibleSection/CollapsibleSection";
|
||||
import Button, {
|
||||
ButtonSize,
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
|
||||
export interface ComponentProps {
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
@@ -37,16 +43,30 @@ export interface ComponentProps {
|
||||
onFormChange: (component: DashboardBaseComponent) => void;
|
||||
}
|
||||
|
||||
interface SectionGroup {
|
||||
section: ComponentArgumentSection;
|
||||
args: Array<ComponentArgument<DashboardBaseComponent>>;
|
||||
}
|
||||
|
||||
const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const formRef: any = useRef<FormProps<FormValues<JSONObject>>>(null);
|
||||
const formRefs: React.MutableRefObject<
|
||||
Record<string, FormProps<FormValues<JSONObject>> | null>
|
||||
> = useRef({});
|
||||
const [component, setComponent] = useState<DashboardBaseComponent>(
|
||||
props.component,
|
||||
);
|
||||
const [hasFormValidationErrors, setHasFormValidationErrors] = useState<
|
||||
Dictionary<boolean>
|
||||
>({});
|
||||
const [multiQueryConfigs, setMultiQueryConfigs] = useState<
|
||||
Array<MetricQueryConfigData>
|
||||
>(
|
||||
((props.component?.arguments as JSONObject)?.[
|
||||
"metricQueryConfigs"
|
||||
] as unknown as Array<MetricQueryConfigData>) || [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.onHasFormValidationErrors) {
|
||||
@@ -66,6 +86,57 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
const componentArguments: Array<ComponentArgument<DashboardBaseComponent>> =
|
||||
DashboardComponentsUtil.getComponentSettingsArguments(componentType);
|
||||
|
||||
// Group arguments by section
|
||||
const groupArgumentsBySections: () => Array<SectionGroup> =
|
||||
(): Array<SectionGroup> => {
|
||||
const sectionMap: Map<string, SectionGroup> = new Map();
|
||||
const unsectionedArgs: Array<ComponentArgument<DashboardBaseComponent>> =
|
||||
[];
|
||||
|
||||
for (const arg of componentArguments) {
|
||||
// Skip MetricsQueryConfigs - we render it as a custom multi-query UI
|
||||
if (arg.type === ComponentInputType.MetricsQueryConfigs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.section) {
|
||||
const key: string = arg.section.name;
|
||||
if (!sectionMap.has(key)) {
|
||||
sectionMap.set(key, {
|
||||
section: arg.section,
|
||||
args: [],
|
||||
});
|
||||
}
|
||||
sectionMap.get(key)!.args.push(arg);
|
||||
} else {
|
||||
unsectionedArgs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: Array<SectionGroup> = [];
|
||||
|
||||
// Add unsectioned args as a "General" section if they exist
|
||||
if (unsectionedArgs.length > 0) {
|
||||
groups.push({
|
||||
section: {
|
||||
name: "General",
|
||||
order: 0,
|
||||
},
|
||||
args: unsectionedArgs,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort sections by order
|
||||
const sortedSections: Array<SectionGroup> = Array.from(
|
||||
sectionMap.values(),
|
||||
).sort((a: SectionGroup, b: SectionGroup) => {
|
||||
return a.section.order - b.section.order;
|
||||
});
|
||||
groups.push(...sortedSections);
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
type GetMetricsQueryConfigFormFunction = (
|
||||
arg: ComponentArgument<DashboardBaseComponent>,
|
||||
) => (
|
||||
@@ -85,13 +156,20 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
componentProps: CustomElementProps,
|
||||
) => {
|
||||
return (
|
||||
<MetricQueryConfig
|
||||
{...componentProps}
|
||||
data={value[arg.id] as MetricQueryConfigData}
|
||||
metricTypes={props.metrics.metricTypes}
|
||||
telemetryAttributes={props.metrics.telemetryAttributes}
|
||||
hideCard={true}
|
||||
/>
|
||||
<div className="p-3 border border-gray-200 rounded-lg bg-gray-50">
|
||||
<div className="mb-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Query 1
|
||||
</span>
|
||||
</div>
|
||||
<MetricQueryConfig
|
||||
{...componentProps}
|
||||
data={value[arg.id] as MetricQueryConfigData}
|
||||
metricTypes={props.metrics.metricTypes}
|
||||
telemetryAttributes={props.metrics.telemetryAttributes}
|
||||
hideCard={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -105,7 +183,7 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
) => ReactElement)
|
||||
| undefined;
|
||||
|
||||
const getCustomElememnt: GetCustomElementFunction = (
|
||||
const getCustomElement: GetCustomElementFunction = (
|
||||
arg: ComponentArgument<DashboardBaseComponent>,
|
||||
):
|
||||
| ((
|
||||
@@ -119,11 +197,17 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getForm: GetReactElementFunction = (): ReactElement => {
|
||||
const renderSectionForm: (sectionGroup: SectionGroup) => ReactElement = (
|
||||
sectionGroup: SectionGroup,
|
||||
): ReactElement => {
|
||||
const sectionKey: string = sectionGroup.section.name;
|
||||
|
||||
return (
|
||||
<BasicForm
|
||||
hideSubmitButton={true}
|
||||
ref={formRef}
|
||||
ref={(ref: FormProps<FormValues<JSONObject>> | null) => {
|
||||
formRefs.current[sectionKey] = ref;
|
||||
}}
|
||||
values={{
|
||||
...(component?.arguments || {}),
|
||||
}}
|
||||
@@ -137,54 +221,190 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
});
|
||||
}}
|
||||
onFormValidationErrorChanged={(hasError: boolean) => {
|
||||
if (hasFormValidationErrors["id"] !== hasError) {
|
||||
if (hasFormValidationErrors[sectionKey] !== hasError) {
|
||||
setHasFormValidationErrors({
|
||||
...hasFormValidationErrors,
|
||||
id: hasError,
|
||||
[sectionKey]: hasError,
|
||||
});
|
||||
}
|
||||
}}
|
||||
fields={
|
||||
componentArguments &&
|
||||
componentArguments.map(
|
||||
(arg: ComponentArgument<DashboardBaseComponent>) => {
|
||||
return {
|
||||
title: `${arg.name}`,
|
||||
description: `${
|
||||
arg.required ? "Required" : "Optional"
|
||||
}. ${arg.description}`,
|
||||
field: {
|
||||
[arg.id]: true,
|
||||
},
|
||||
required: arg.required,
|
||||
placeholder: arg.placeholder,
|
||||
...ComponentInputTypeToFormFieldType.getFormFieldTypeByComponentInputType(
|
||||
arg.type,
|
||||
arg.dropdownOptions,
|
||||
),
|
||||
getCustomElement: getCustomElememnt(arg),
|
||||
};
|
||||
},
|
||||
)
|
||||
}
|
||||
fields={sectionGroup.args.map(
|
||||
(arg: ComponentArgument<DashboardBaseComponent>) => {
|
||||
return {
|
||||
title: arg.name,
|
||||
description: arg.description,
|
||||
field: {
|
||||
[arg.id]: true,
|
||||
},
|
||||
required: arg.required,
|
||||
placeholder: arg.placeholder,
|
||||
...ComponentInputTypeToFormFieldType.getFormFieldTypeByComponentInputType(
|
||||
arg.type,
|
||||
arg.dropdownOptions,
|
||||
),
|
||||
getCustomElement: getCustomElement(arg),
|
||||
};
|
||||
},
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-3 mt-3">
|
||||
<div className="mt-5 mb-5">
|
||||
<h2 className="text-base font-medium text-gray-500">Arguments</h2>
|
||||
<p className="text-sm font-medium text-gray-400 mb-5">
|
||||
Arguments for this component
|
||||
</p>
|
||||
{componentArguments && componentArguments.length === 0 && (
|
||||
<ErrorMessage
|
||||
message={"This component does not take any arguments."}
|
||||
// Check if this component has a MetricsQueryConfigs argument
|
||||
const hasMultiQueryArg: boolean = componentArguments.some(
|
||||
(arg: ComponentArgument<DashboardBaseComponent>) => {
|
||||
return arg.type === ComponentInputType.MetricsQueryConfigs;
|
||||
},
|
||||
);
|
||||
|
||||
const multiQueryArg: ComponentArgument<DashboardBaseComponent> | undefined =
|
||||
componentArguments.find(
|
||||
(arg: ComponentArgument<DashboardBaseComponent>) => {
|
||||
return arg.type === ComponentInputType.MetricsQueryConfigs;
|
||||
},
|
||||
);
|
||||
|
||||
const renderMultiQuerySection: () => ReactElement | null =
|
||||
(): ReactElement | null => {
|
||||
if (!hasMultiQueryArg || !multiQueryArg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{multiQueryConfigs.map(
|
||||
(queryConfig: MetricQueryConfigData, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="mb-4 p-3 border border-gray-200 rounded-lg bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Query {index + 2}
|
||||
</span>
|
||||
<Button
|
||||
title="Remove"
|
||||
buttonSize={ButtonSize.Small}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
icon={IconProp.Trash}
|
||||
onClick={() => {
|
||||
const updated: Array<MetricQueryConfigData> = [
|
||||
...multiQueryConfigs,
|
||||
];
|
||||
updated.splice(index, 1);
|
||||
setMultiQueryConfigs(updated);
|
||||
setComponent({
|
||||
...component,
|
||||
arguments: {
|
||||
...((component.arguments as JSONObject) || {}),
|
||||
metricQueryConfigs: updated as any,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<MetricQueryConfig
|
||||
data={queryConfig}
|
||||
metricTypes={props.metrics.metricTypes}
|
||||
telemetryAttributes={props.metrics.telemetryAttributes}
|
||||
hideCard={true}
|
||||
onChange={(data: MetricQueryConfigData) => {
|
||||
const updated: Array<MetricQueryConfigData> = [
|
||||
...multiQueryConfigs,
|
||||
];
|
||||
updated[index] = data;
|
||||
setMultiQueryConfigs(updated);
|
||||
setComponent({
|
||||
...component,
|
||||
arguments: {
|
||||
...((component.arguments as JSONObject) || {}),
|
||||
metricQueryConfigs: updated as any,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
<Button
|
||||
title="Add Query"
|
||||
buttonSize={ButtonSize.Small}
|
||||
buttonStyle={ButtonStyleType.OUTLINE}
|
||||
icon={IconProp.Add}
|
||||
onClick={() => {
|
||||
const variableIndex: number = multiQueryConfigs.length + 1; // +1 because primary query is "a"
|
||||
const variableLetter: string = String.fromCharCode(
|
||||
97 + variableIndex,
|
||||
); // b, c, d, ...
|
||||
const newQuery: MetricQueryConfigData = {
|
||||
metricAliasData: {
|
||||
metricVariable: variableLetter,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
legend: undefined,
|
||||
legendUnit: undefined,
|
||||
},
|
||||
metricQueryData: {
|
||||
filterData: {},
|
||||
groupBy: undefined,
|
||||
},
|
||||
};
|
||||
const updated: Array<MetricQueryConfigData> = [
|
||||
...multiQueryConfigs,
|
||||
newQuery,
|
||||
];
|
||||
setMultiQueryConfigs(updated);
|
||||
setComponent({
|
||||
...component,
|
||||
arguments: {
|
||||
...((component.arguments as JSONObject) || {}),
|
||||
metricQueryConfigs: updated as any,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{componentArguments && componentArguments.length > 0 && getForm()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const sectionGroups: Array<SectionGroup> = groupArgumentsBySections();
|
||||
|
||||
return (
|
||||
<div className="mb-3 mt-1">
|
||||
{componentArguments && componentArguments.length === 0 && (
|
||||
<ErrorMessage message={"This component does not take any arguments."} />
|
||||
)}
|
||||
{sectionGroups.map((sectionGroup: SectionGroup, index: number) => {
|
||||
const isFirstSection: boolean = index === 0;
|
||||
const shouldCollapse: boolean =
|
||||
!isFirstSection && (sectionGroup.section.defaultCollapsed ?? false);
|
||||
|
||||
return (
|
||||
<div key={sectionGroup.section.name} className="mt-3">
|
||||
<CollapsibleSection
|
||||
title={sectionGroup.section.name}
|
||||
description={sectionGroup.section.description}
|
||||
variant="bordered"
|
||||
defaultCollapsed={shouldCollapse}
|
||||
>
|
||||
<div>
|
||||
{renderSectionForm(sectionGroup)}
|
||||
{/* Render multi-query UI inside the Data Source section */}
|
||||
{sectionGroup.section.name === "Data Source" &&
|
||||
renderMultiQuerySection()}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* If no Data Source section exists, render multi-query at end */}
|
||||
{!sectionGroups.some((g: SectionGroup) => {
|
||||
return g.section.name === "Data Source";
|
||||
}) && renderMultiQuerySection()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import DefaultDashboardSize from "Common/Types/Dashboard/DashboardSize";
|
||||
import DefaultDashboardSize, {
|
||||
GetDashboardUnitWidthInPx,
|
||||
SpaceBetweenUnitsInPx,
|
||||
} from "Common/Types/Dashboard/DashboardSize";
|
||||
import BlankRowElement from "./BlankRow";
|
||||
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
|
||||
@@ -21,7 +24,10 @@ const BlankCanvasElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
if (!props.isEditMode && props.dashboardViewConfig.components.length === 0) {
|
||||
return (
|
||||
<div className="mx-3 mt-4 rounded-lg border border-dashed border-gray-200 bg-gray-50/50 text-center py-20 px-10">
|
||||
<div
|
||||
className="mx-3 mt-4 rounded-2xl border border-dashed border-gray-200 bg-gray-50/50 text-center py-20 px-10"
|
||||
style={{ boxShadow: "0 2px 8px -2px rgba(0, 0, 0, 0.06)" }}
|
||||
>
|
||||
<div
|
||||
className="mx-auto w-14 h-14 rounded-full bg-white border border-gray-200 flex items-center justify-center mb-4"
|
||||
style={{ boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.04)" }}
|
||||
@@ -44,35 +50,31 @@ const BlankCanvasElement: FunctionComponent<ComponentProps> = (
|
||||
No widgets yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 max-w-sm mx-auto">
|
||||
Click <strong className="text-gray-500">Edit</strong> to start adding
|
||||
charts, values, gauges, and more to this dashboard.
|
||||
This dashboard does not have any widgets.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// have a grid with width cols and height rows
|
||||
const gap: number = SpaceBetweenUnitsInPx;
|
||||
const unitSize: number = GetDashboardUnitWidthInPx(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid grid-cols-${width}`}
|
||||
style={
|
||||
props.isEditMode
|
||||
? {
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, #d1d5db 0.8px, transparent 0.8px)",
|
||||
backgroundSize: "20px 20px",
|
||||
borderRadius: "8px",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${width}, 1fr)`,
|
||||
gap: `${gap}px`,
|
||||
gridAutoRows: `${unitSize}px`,
|
||||
borderRadius: "16px",
|
||||
}}
|
||||
>
|
||||
{Array.from(Array(height).keys()).map((_: number, index: number) => {
|
||||
return (
|
||||
<BlankRowElement
|
||||
key={index}
|
||||
totalCurrentDashboardWidthInPx={
|
||||
props.totalCurrentDashboardWidthInPx
|
||||
}
|
||||
isEditMode={props.isEditMode}
|
||||
rowNumber={index}
|
||||
onClick={(top: number, left: number) => {
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
import {
|
||||
GetDashboardUnitHeightInPx,
|
||||
MarginForEachUnitInPx,
|
||||
} from "Common/Types/Dashboard/DashboardSize";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface ComponentProps {
|
||||
isEditMode: boolean;
|
||||
onClick: () => void;
|
||||
currentTotalDashboardWidthInPx: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const BlankDashboardUnitElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const heightOfUnitInPx: number = GetDashboardUnitHeightInPx(
|
||||
props.currentTotalDashboardWidthInPx,
|
||||
);
|
||||
|
||||
const widthOfUnitInPx: number = heightOfUnitInPx; // its a square
|
||||
|
||||
let className: string = "transition-all duration-150";
|
||||
|
||||
if (props.isEditMode) {
|
||||
className +=
|
||||
" border border-dashed border-gray-200 rounded-md hover:border-gray-300 hover:bg-blue-50/30 cursor-pointer";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={props.id}
|
||||
className={className}
|
||||
className={
|
||||
props.isEditMode
|
||||
? "rounded-md cursor-pointer transition-all duration-150"
|
||||
: "transition-all duration-150"
|
||||
}
|
||||
onClick={() => {
|
||||
props.onClick();
|
||||
}}
|
||||
style={{
|
||||
width: widthOfUnitInPx + "px",
|
||||
height: heightOfUnitInPx + "px",
|
||||
margin: MarginForEachUnitInPx + "px",
|
||||
border: props.isEditMode
|
||||
? "1px solid rgba(203, 213, 225, 0.4)"
|
||||
: "none",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
></div>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface ComponentProps {
|
||||
rowNumber: number;
|
||||
onClick: (top: number, left: number) => void;
|
||||
isEditMode: boolean;
|
||||
totalCurrentDashboardWidthInPx: number;
|
||||
}
|
||||
|
||||
const BlankRowElement: FunctionComponent<ComponentProps> = (
|
||||
@@ -20,9 +19,6 @@ const BlankRowElement: FunctionComponent<ComponentProps> = (
|
||||
(_: number, index: number) => {
|
||||
return (
|
||||
<BlankDashboardUnitElement
|
||||
currentTotalDashboardWidthInPx={
|
||||
props.totalCurrentDashboardWidthInPx
|
||||
}
|
||||
key={props.rowNumber + "-" + index}
|
||||
isEditMode={props.isEditMode}
|
||||
onClick={() => {
|
||||
|
||||
@@ -53,6 +53,12 @@ export default class ComponentInputTypeToFormFieldType {
|
||||
};
|
||||
}
|
||||
|
||||
if (componentInputType === ComponentInputType.MetricsQueryConfigs) {
|
||||
return {
|
||||
fieldType: FormFieldSchemaType.CustomComponent,
|
||||
};
|
||||
}
|
||||
|
||||
if (componentInputType === ComponentInputType.Dropdown) {
|
||||
return {
|
||||
fieldType: FormFieldSchemaType.Dropdown,
|
||||
|
||||
@@ -2,7 +2,6 @@ import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import Divider from "Common/UI/Components/Divider/Divider";
|
||||
import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent";
|
||||
import SideOver from "Common/UI/Components/SideOver/SideOver";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
@@ -82,10 +81,10 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Widget type indicator */}
|
||||
<div className="flex items-center gap-2 mb-4 px-1">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-600 capitalize">
|
||||
{component.componentType} Widget
|
||||
{/* Widget type and size info */}
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-semibold bg-indigo-50 text-indigo-700 capitalize">
|
||||
{component.componentType}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{component.widthInDashboardUnits} x{" "}
|
||||
@@ -93,8 +92,6 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ArgumentsForm
|
||||
component={component}
|
||||
/*
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import BlankCanvasElement from "./BlankCanvas";
|
||||
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import DefaultDashboardSize from "Common/Types/Dashboard/DashboardSize";
|
||||
import DefaultDashboardSize, {
|
||||
GetDashboardUnitWidthInPx,
|
||||
SpaceBetweenUnitsInPx,
|
||||
} from "Common/Types/Dashboard/DashboardSize";
|
||||
import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent";
|
||||
import BlankDashboardUnitElement from "./BlankDashboardUnit";
|
||||
import DashboardBaseComponentElement from "../Components/DashboardBaseComponent";
|
||||
@@ -34,6 +37,11 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
const dashboardCanvasRef: React.RefObject<HTMLDivElement> =
|
||||
React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const gap: number = SpaceBetweenUnitsInPx;
|
||||
const unitSize: number = GetDashboardUnitWidthInPx(
|
||||
props.currentTotalDashboardWidthInPx,
|
||||
);
|
||||
|
||||
const renderComponents: GetReactElementFunction = (): ReactElement => {
|
||||
const canvasHeight: number =
|
||||
props.dashboardViewConfig.heightInDashboardUnits ||
|
||||
@@ -52,7 +60,7 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
grid[row] = new Array(canvasWidth).fill(null);
|
||||
}
|
||||
|
||||
let maxHeightInDashboardUnits: number = 0; // max height of the grid
|
||||
let maxHeightInDashboardUnits: number = 0;
|
||||
|
||||
// Place components in the grid
|
||||
allComponents.forEach((component: DashboardBaseComponent) => {
|
||||
@@ -106,16 +114,11 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
|
||||
if (!component) {
|
||||
if (!props.isEditMode && i >= maxHeightInDashboardUnits) {
|
||||
// if we are not in edit mode, we should not render blank units
|
||||
continue;
|
||||
}
|
||||
|
||||
// render a blank unit
|
||||
renderedComponents.push(
|
||||
<BlankDashboardUnitElement
|
||||
currentTotalDashboardWidthInPx={
|
||||
props.currentTotalDashboardWidthInPx
|
||||
}
|
||||
isEditMode={props.isEditMode}
|
||||
key={`blank-unit-${i}-${j}`}
|
||||
onClick={() => {
|
||||
@@ -128,8 +131,6 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
}
|
||||
|
||||
// remove nulls from the renderedComponents array
|
||||
|
||||
const finalRenderedComponents: Array<ReactElement> =
|
||||
renderedComponents.filter(
|
||||
(component: ReactElement | null): component is ReactElement => {
|
||||
@@ -137,26 +138,17 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
);
|
||||
|
||||
const width: number = DefaultDashboardSize.widthInDashboardUnits;
|
||||
|
||||
const canvasClassName: string = props.isEditMode
|
||||
? `grid grid-cols-${width}`
|
||||
: `grid grid-cols-${width}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dashboardCanvasRef}
|
||||
className={canvasClassName}
|
||||
style={
|
||||
props.isEditMode
|
||||
? {
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, #d1d5db 0.8px, transparent 0.8px)",
|
||||
backgroundSize: "20px 20px",
|
||||
borderRadius: "8px",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${canvasWidth}, 1fr)`,
|
||||
gap: `${gap}px`,
|
||||
gridAutoRows: `${unitSize}px`,
|
||||
borderRadius: "16px",
|
||||
padding: "8px",
|
||||
}}
|
||||
>
|
||||
{finalRenderedComponents}
|
||||
</div>
|
||||
@@ -209,14 +201,13 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
return c.componentId.toString() === componentId.toString();
|
||||
});
|
||||
|
||||
const currentUnitSizeInPx: number =
|
||||
props.currentTotalDashboardWidthInPx / 12;
|
||||
const w: number = component?.widthInDashboardUnits || 0;
|
||||
const h: number = component?.heightInDashboardUnits || 0;
|
||||
|
||||
const heightOfComponentInPx: number =
|
||||
currentUnitSizeInPx * (component?.heightInDashboardUnits || 0);
|
||||
// Compute pixel dimensions for child component rendering (charts, etc.)
|
||||
const widthOfComponentInPx: number = unitSize * w + gap * (w - 1);
|
||||
|
||||
const widthOfComponentInPx: number =
|
||||
currentUnitSizeInPx * (component?.widthInDashboardUnits || 0);
|
||||
const heightOfComponentInPx: number = unitSize * h + gap * (h - 1);
|
||||
|
||||
return (
|
||||
<DashboardBaseComponentElement
|
||||
@@ -241,7 +232,6 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
isSelected={isSelected}
|
||||
refreshTick={props.refreshTick}
|
||||
onClick={() => {
|
||||
// component is selected
|
||||
props.onComponentSelected(componentId);
|
||||
}}
|
||||
/>
|
||||
@@ -271,7 +261,6 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
description="Edit the settings of this component"
|
||||
dashboardViewConfig={props.dashboardViewConfig}
|
||||
onClose={() => {
|
||||
// unselect this component.
|
||||
props.onComponentUnselected();
|
||||
}}
|
||||
onComponentDelete={() => {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import DashboardTextComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTextComponent";
|
||||
import DashboardChartComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardChartComponent";
|
||||
import DashboardValueComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardValueComponent";
|
||||
@@ -19,7 +25,6 @@ import DefaultDashboardSize, {
|
||||
GetDashboardComponentWidthInDashboardUnits,
|
||||
GetDashboardUnitHeightInPx,
|
||||
GetDashboardUnitWidthInPx,
|
||||
MarginForEachUnitInPx,
|
||||
SpaceBetweenUnitsInPx,
|
||||
} from "Common/Types/Dashboard/DashboardSize";
|
||||
import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes";
|
||||
@@ -52,323 +57,430 @@ export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/*
|
||||
* ────────────────────────────────────────────────────────────
|
||||
* All mutable drag/resize state lives here, outside React.
|
||||
* Nothing in this struct triggers a re-render.
|
||||
* ────────────────────────────────────────────────────────────
|
||||
*/
|
||||
interface DragSession {
|
||||
mode: "move" | "resize-w" | "resize-h" | "resize-corner";
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
// Snapped values at the START of the gesture (dashboard units)
|
||||
originTop: number;
|
||||
originLeft: number;
|
||||
originWidth: number;
|
||||
originHeight: number;
|
||||
// Live snapped values (updated every mousemove, used on commit)
|
||||
liveTop: number;
|
||||
liveLeft: number;
|
||||
liveWidth: number;
|
||||
liveHeight: number;
|
||||
}
|
||||
|
||||
const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
// ── Derived data ──────────────────────────────────────────
|
||||
const component: DashboardBaseComponent =
|
||||
props.dashboardViewConfig.components.find(
|
||||
(component: DashboardBaseComponent) => {
|
||||
return (
|
||||
component.componentId.toString() === props.componentId.toString()
|
||||
);
|
||||
},
|
||||
) as DashboardBaseComponent;
|
||||
props.dashboardViewConfig.components.find((c: DashboardBaseComponent) => {
|
||||
return c.componentId.toString() === props.componentId.toString();
|
||||
}) as DashboardBaseComponent;
|
||||
|
||||
const widthOfComponent: number = component.widthInDashboardUnits;
|
||||
const heightOfComponent: number = component.heightInDashboardUnits;
|
||||
|
||||
const [topInPx, setTopInPx] = React.useState<number>(0);
|
||||
const [leftInPx, setLeftInPx] = React.useState<number>(0);
|
||||
// ── Minimal React state (only for hover gating) ───────────
|
||||
const [isHovered, setIsHovered] = useState<boolean>(false);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
|
||||
let className: string = `relative rounded-lg col-span-${widthOfComponent} row-span-${heightOfComponent} p-3 bg-white border border-gray-200 transition-all duration-200 overflow-hidden`;
|
||||
// ── Refs ──────────────────────────────────────────────────
|
||||
const elRef: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(null);
|
||||
const tooltipRef: React.RefObject<HTMLDivElement> =
|
||||
useRef<HTMLDivElement>(null);
|
||||
const sessionRef: React.MutableRefObject<DragSession | null> =
|
||||
useRef<DragSession | null>(null);
|
||||
const overlayRef: React.MutableRefObject<HTMLDivElement | null> =
|
||||
useRef<HTMLDivElement | null>(null);
|
||||
const latestProps: React.MutableRefObject<ComponentProps> =
|
||||
useRef<ComponentProps>(props);
|
||||
const latestComponent: React.MutableRefObject<DashboardBaseComponent> =
|
||||
useRef<DashboardBaseComponent>(component);
|
||||
latestProps.current = props;
|
||||
latestComponent.current = component;
|
||||
|
||||
if (props.isEditMode && !props.isSelected) {
|
||||
className += " cursor-pointer hover:border-gray-300 hover:shadow-md";
|
||||
// ── Core imperative handlers (stable — no deps) ──────────
|
||||
|
||||
function updateTooltip(session: DragSession): void {
|
||||
if (!tooltipRef.current) {
|
||||
return;
|
||||
}
|
||||
if (session.mode === "move") {
|
||||
tooltipRef.current.textContent = `${session.liveLeft}, ${session.liveTop}`;
|
||||
} else {
|
||||
tooltipRef.current.textContent = `${session.liveWidth} \u00d7 ${session.liveHeight}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.isSelected && props.isEditMode) {
|
||||
className +=
|
||||
" !border-blue-400 ring-2 ring-blue-50 shadow-lg shadow-blue-100/50";
|
||||
}
|
||||
|
||||
if (!props.isEditMode) {
|
||||
className += " hover:shadow-md";
|
||||
}
|
||||
|
||||
const dashboardComponentRef: React.RefObject<HTMLDivElement> =
|
||||
React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const refreshTopAndLeftInPx: () => void = () => {
|
||||
if (dashboardComponentRef.current === null) {
|
||||
function onMouseMove(e: MouseEvent): void {
|
||||
const s: DragSession | null = sessionRef.current;
|
||||
if (!s) {
|
||||
return;
|
||||
}
|
||||
|
||||
const topInPx: number =
|
||||
dashboardComponentRef.current.getBoundingClientRect().top;
|
||||
const leftInPx: number =
|
||||
dashboardComponentRef.current.getBoundingClientRect().left;
|
||||
const p: ComponentProps = latestProps.current;
|
||||
const c: DashboardBaseComponent = latestComponent.current;
|
||||
const uW: number = GetDashboardUnitWidthInPx(
|
||||
p.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
const uH: number = GetDashboardUnitHeightInPx(
|
||||
p.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
const g: number = SpaceBetweenUnitsInPx;
|
||||
|
||||
setTopInPx(topInPx);
|
||||
setLeftInPx(leftInPx);
|
||||
};
|
||||
const dxPx: number = e.clientX - s.startMouseX;
|
||||
const dyPx: number = e.clientY - s.startMouseY;
|
||||
|
||||
const el: HTMLDivElement | null = elRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.mode === "move") {
|
||||
el.style.transform = `translate(${dxPx}px, ${dyPx}px) scale(1.01)`;
|
||||
el.style.zIndex = "100";
|
||||
|
||||
const dxUnits: number = Math.round(dxPx / uW);
|
||||
const dyUnits: number = Math.round(dyPx / uH);
|
||||
|
||||
let newLeft: number = s.originLeft + dxUnits;
|
||||
let newTop: number = s.originTop + dyUnits;
|
||||
const maxLeft: number =
|
||||
DefaultDashboardSize.widthInDashboardUnits - c.widthInDashboardUnits;
|
||||
const maxTop: number =
|
||||
p.dashboardViewConfig.heightInDashboardUnits - c.heightInDashboardUnits;
|
||||
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
|
||||
newTop = Math.max(0, Math.min(newTop, maxTop));
|
||||
|
||||
s.liveLeft = newLeft;
|
||||
s.liveTop = newTop;
|
||||
|
||||
updateTooltip(s);
|
||||
} else {
|
||||
const rect: DOMRect = el.getBoundingClientRect();
|
||||
|
||||
if (s.mode === "resize-w" || s.mode === "resize-corner") {
|
||||
const wPx: number = Math.max(
|
||||
uW,
|
||||
e.pageX - (window.scrollX + rect.left),
|
||||
);
|
||||
let wUnits: number = GetDashboardComponentWidthInDashboardUnits(
|
||||
p.totalCurrentDashboardWidthInPx,
|
||||
wPx,
|
||||
);
|
||||
wUnits = Math.max(c.minWidthInDashboardUnits, wUnits);
|
||||
wUnits = Math.min(DefaultDashboardSize.widthInDashboardUnits, wUnits);
|
||||
s.liveWidth = wUnits;
|
||||
|
||||
const newWidthPx: number = uW * wUnits + g * (wUnits - 1);
|
||||
el.style.width = `${newWidthPx}px`;
|
||||
}
|
||||
|
||||
if (s.mode === "resize-h" || s.mode === "resize-corner") {
|
||||
const hPx: number = Math.max(uH, e.pageY - (window.scrollY + rect.top));
|
||||
let hUnits: number = GetDashboardComponentHeightInDashboardUnits(
|
||||
p.totalCurrentDashboardWidthInPx,
|
||||
hPx,
|
||||
);
|
||||
hUnits = Math.max(c.minHeightInDashboardUnits, hUnits);
|
||||
s.liveHeight = hUnits;
|
||||
|
||||
const newHeightPx: number = uH * hUnits + g * (hUnits - 1);
|
||||
el.style.height = `${newHeightPx}px`;
|
||||
}
|
||||
|
||||
updateTooltip(s);
|
||||
}
|
||||
}
|
||||
|
||||
function removeOverlay(): void {
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.remove();
|
||||
overlayRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function createOverlay(cursor: string): void {
|
||||
removeOverlay();
|
||||
const overlay: HTMLDivElement = document.createElement("div");
|
||||
overlay.style.position = "fixed";
|
||||
overlay.style.inset = "0";
|
||||
overlay.style.zIndex = "9999";
|
||||
overlay.style.cursor = cursor;
|
||||
overlay.style.background = "transparent";
|
||||
document.body.appendChild(overlay);
|
||||
overlayRef.current = overlay;
|
||||
}
|
||||
|
||||
function onMouseUp(): void {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
removeOverlay();
|
||||
|
||||
const s: DragSession | null = sessionRef.current;
|
||||
const el: HTMLDivElement | null = elRef.current;
|
||||
|
||||
if (el) {
|
||||
el.style.transform = "";
|
||||
el.style.zIndex = "";
|
||||
el.style.width = "";
|
||||
el.style.height = "";
|
||||
}
|
||||
|
||||
sessionRef.current = null;
|
||||
setIsDragging(false);
|
||||
|
||||
if (!s) {
|
||||
return;
|
||||
}
|
||||
|
||||
const c: DashboardBaseComponent = latestComponent.current;
|
||||
const p: ComponentProps = latestProps.current;
|
||||
|
||||
const updated: DashboardBaseComponent = { ...c };
|
||||
let changed: boolean = false;
|
||||
|
||||
if (s.mode === "move") {
|
||||
if (
|
||||
s.liveTop !== c.topInDashboardUnits ||
|
||||
s.liveLeft !== c.leftInDashboardUnits
|
||||
) {
|
||||
updated.topInDashboardUnits = s.liveTop;
|
||||
updated.leftInDashboardUnits = s.liveLeft;
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
if (s.liveWidth !== c.widthInDashboardUnits) {
|
||||
updated.widthInDashboardUnits = s.liveWidth;
|
||||
changed = true;
|
||||
}
|
||||
if (s.liveHeight !== c.heightInDashboardUnits) {
|
||||
updated.heightInDashboardUnits = s.liveHeight;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
p.onComponentUpdate(updated);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshTopAndLeftInPx();
|
||||
}, [props.dashboardViewConfig]);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
removeOverlay();
|
||||
};
|
||||
}, []);
|
||||
|
||||
type MoveComponentFunction = (mouseEvent: MouseEvent) => void;
|
||||
// ── Start a drag / resize session ─────────────────────────
|
||||
function startSession(e: React.MouseEvent, mode: DragSession["mode"]): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const moveComponent: MoveComponentFunction = (
|
||||
mouseEvent: MouseEvent,
|
||||
): void => {
|
||||
const dashboardComponentOldTopInPx: number = topInPx;
|
||||
const dashboardComponentOldLeftInPx: number = leftInPx;
|
||||
const c: DashboardBaseComponent = latestComponent.current;
|
||||
|
||||
const newMoveToTop: number = mouseEvent.clientY;
|
||||
const newMoveToLeft: number = mouseEvent.clientX;
|
||||
|
||||
const deltaXInPx: number = newMoveToLeft - dashboardComponentOldLeftInPx;
|
||||
const deltaYInPx: number = newMoveToTop - dashboardComponentOldTopInPx;
|
||||
|
||||
const eachDashboardUnitInPx: number = GetDashboardUnitWidthInPx(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
|
||||
const deltaXInDashboardUnits: number = Math.round(
|
||||
deltaXInPx / eachDashboardUnitInPx,
|
||||
);
|
||||
const deltaYInDashboardUnits: number = Math.round(
|
||||
deltaYInPx / eachDashboardUnitInPx,
|
||||
);
|
||||
|
||||
let newTopInDashboardUnits: number =
|
||||
component.topInDashboardUnits + deltaYInDashboardUnits;
|
||||
let newLeftInDashboardUnits: number =
|
||||
component.leftInDashboardUnits + deltaXInDashboardUnits;
|
||||
|
||||
// now make sure these are within the bounds of the dashboard inch component width and height in dashbosrd units
|
||||
|
||||
const dahsboardTotalWidthInDashboardUnits: number =
|
||||
DefaultDashboardSize.widthInDashboardUnits; // width does not change
|
||||
const dashboardTotalHeightInDashboardUnits: number =
|
||||
props.dashboardViewConfig.heightInDashboardUnits;
|
||||
|
||||
const heightOfTheComponntInDashboardUnits: number =
|
||||
component.heightInDashboardUnits;
|
||||
|
||||
const widthOfTheComponentInDashboardUnits: number =
|
||||
component.widthInDashboardUnits;
|
||||
|
||||
// if it goes outside the bounds then max it out to the bounds
|
||||
|
||||
if (
|
||||
newTopInDashboardUnits + heightOfTheComponntInDashboardUnits >
|
||||
dashboardTotalHeightInDashboardUnits
|
||||
) {
|
||||
newTopInDashboardUnits =
|
||||
dashboardTotalHeightInDashboardUnits -
|
||||
heightOfTheComponntInDashboardUnits;
|
||||
}
|
||||
|
||||
if (
|
||||
newLeftInDashboardUnits + widthOfTheComponentInDashboardUnits >
|
||||
dahsboardTotalWidthInDashboardUnits
|
||||
) {
|
||||
newLeftInDashboardUnits =
|
||||
dahsboardTotalWidthInDashboardUnits -
|
||||
widthOfTheComponentInDashboardUnits;
|
||||
}
|
||||
|
||||
// make sure they are not negative
|
||||
|
||||
if (newTopInDashboardUnits < 0) {
|
||||
newTopInDashboardUnits = 0;
|
||||
}
|
||||
|
||||
if (newLeftInDashboardUnits < 0) {
|
||||
newLeftInDashboardUnits = 0;
|
||||
}
|
||||
|
||||
// update the component
|
||||
const newComponentProps: DashboardBaseComponent = {
|
||||
...component,
|
||||
topInDashboardUnits: newTopInDashboardUnits,
|
||||
leftInDashboardUnits: newLeftInDashboardUnits,
|
||||
const session: DragSession = {
|
||||
mode,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
originTop: c.topInDashboardUnits,
|
||||
originLeft: c.leftInDashboardUnits,
|
||||
originWidth: c.widthInDashboardUnits,
|
||||
originHeight: c.heightInDashboardUnits,
|
||||
liveTop: c.topInDashboardUnits,
|
||||
liveLeft: c.leftInDashboardUnits,
|
||||
liveWidth: c.widthInDashboardUnits,
|
||||
liveHeight: c.heightInDashboardUnits,
|
||||
};
|
||||
|
||||
props.onComponentUpdate(newComponentProps);
|
||||
};
|
||||
sessionRef.current = session;
|
||||
setIsDragging(true);
|
||||
|
||||
const resizeWidth: (event: MouseEvent) => void = (event: MouseEvent) => {
|
||||
if (dashboardComponentRef.current === null) {
|
||||
return;
|
||||
updateTooltip(session);
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
let cursor: string = "grabbing";
|
||||
if (mode === "resize-w") {
|
||||
cursor = "ew-resize";
|
||||
} else if (mode === "resize-h") {
|
||||
cursor = "ns-resize";
|
||||
} else if (mode === "resize-corner") {
|
||||
cursor = "nwse-resize";
|
||||
}
|
||||
|
||||
let newDashboardComponentwidthInPx: number =
|
||||
event.pageX -
|
||||
(window.scrollX +
|
||||
dashboardComponentRef.current.getBoundingClientRect().left);
|
||||
if (
|
||||
GetDashboardUnitWidthInPx(props.totalCurrentDashboardWidthInPx) >
|
||||
newDashboardComponentwidthInPx
|
||||
) {
|
||||
newDashboardComponentwidthInPx = GetDashboardUnitWidthInPx(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
}
|
||||
document.body.style.cursor = cursor;
|
||||
createOverlay(cursor);
|
||||
}
|
||||
|
||||
// get this in dashboard units.,
|
||||
let widthInDashboardUnits: number =
|
||||
GetDashboardComponentWidthInDashboardUnits(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
newDashboardComponentwidthInPx,
|
||||
);
|
||||
// ── Styling ───────────────────────────────────────────────
|
||||
const showHandles: boolean =
|
||||
props.isEditMode && (props.isSelected || isHovered || isDragging);
|
||||
|
||||
// if this width is less than the min width then set it to min width
|
||||
let borderClass: string = "border-gray-200";
|
||||
let extraClass: string = "";
|
||||
|
||||
if (widthInDashboardUnits < component.minWidthInDashboardUnits) {
|
||||
widthInDashboardUnits = component.minWidthInDashboardUnits;
|
||||
}
|
||||
if (isDragging) {
|
||||
borderClass = "border-blue-400";
|
||||
extraClass = "ring-2 ring-blue-400/40 shadow-2xl";
|
||||
} else if (props.isSelected && props.isEditMode) {
|
||||
borderClass = "border-blue-400";
|
||||
extraClass = "ring-2 ring-blue-100 shadow-lg z-10";
|
||||
} else if (props.isEditMode && isHovered) {
|
||||
borderClass = "border-blue-300";
|
||||
extraClass = "shadow-md z-10 cursor-pointer";
|
||||
} else if (props.isEditMode) {
|
||||
extraClass =
|
||||
"hover:border-blue-300 hover:shadow-md cursor-pointer transition-all duration-200";
|
||||
} else {
|
||||
extraClass = "hover:shadow-md transition-shadow duration-200";
|
||||
}
|
||||
|
||||
// if its more than the max width of dashboard.
|
||||
if (widthInDashboardUnits > DefaultDashboardSize.widthInDashboardUnits) {
|
||||
widthInDashboardUnits = DefaultDashboardSize.widthInDashboardUnits;
|
||||
}
|
||||
const className: string = [
|
||||
"relative rounded-xl bg-white border overflow-hidden",
|
||||
borderClass,
|
||||
extraClass,
|
||||
].join(" ");
|
||||
|
||||
// update the component
|
||||
const newComponentProps: DashboardBaseComponent = {
|
||||
...component,
|
||||
widthInDashboardUnits: widthInDashboardUnits,
|
||||
};
|
||||
// ── Render ────────────────────────────────────────────────
|
||||
|
||||
props.onComponentUpdate(newComponentProps);
|
||||
};
|
||||
|
||||
const resizeHeight: (event: MouseEvent) => void = (event: MouseEvent) => {
|
||||
if (dashboardComponentRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newDashboardComponentHeightInPx: number =
|
||||
event.pageY -
|
||||
(window.scrollY +
|
||||
dashboardComponentRef.current.getBoundingClientRect().top);
|
||||
|
||||
if (
|
||||
GetDashboardUnitHeightInPx(props.totalCurrentDashboardWidthInPx) >
|
||||
newDashboardComponentHeightInPx
|
||||
) {
|
||||
newDashboardComponentHeightInPx = GetDashboardUnitHeightInPx(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
}
|
||||
|
||||
// get this in dashboard units
|
||||
let heightInDashboardUnits: number =
|
||||
GetDashboardComponentHeightInDashboardUnits(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
newDashboardComponentHeightInPx,
|
||||
);
|
||||
|
||||
// if this height is less tan the min height then set it to min height
|
||||
|
||||
if (heightInDashboardUnits < component.minHeightInDashboardUnits) {
|
||||
heightInDashboardUnits = component.minHeightInDashboardUnits;
|
||||
}
|
||||
|
||||
// update the component
|
||||
const newComponentProps: DashboardBaseComponent = {
|
||||
...component,
|
||||
heightInDashboardUnits: heightInDashboardUnits,
|
||||
};
|
||||
|
||||
props.onComponentUpdate(newComponentProps);
|
||||
};
|
||||
|
||||
const stopResizeAndMove: () => void = () => {
|
||||
window.removeEventListener("mousemove", resizeHeight);
|
||||
window.removeEventListener("mousemove", resizeWidth);
|
||||
window.removeEventListener("mousemove", moveComponent);
|
||||
window.removeEventListener("mouseup", stopResizeAndMove);
|
||||
};
|
||||
|
||||
const getResizeWidthElement: GetReactElementFunction = (): ReactElement => {
|
||||
if (!props.isSelected || !props.isEditMode) {
|
||||
const getMoveHandle: GetReactElementFunction = (): ReactElement => {
|
||||
if (!showHandles) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
let resizeCursorIcon: string = "cursor-ew-resize";
|
||||
|
||||
// if already at min width then change icon to e-resize
|
||||
|
||||
if (component.widthInDashboardUnits <= component.minWidthInDashboardUnits) {
|
||||
resizeCursorIcon = "cursor-e-resize";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 z-20 flex items-center justify-center cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
top: "calc(50% - 20px)",
|
||||
right: "-5px",
|
||||
height: "28px",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(59,130,246,0.08) 0%, rgba(59,130,246,0.02) 100%)",
|
||||
borderBottom: "1px solid rgba(59,130,246,0.12)",
|
||||
}}
|
||||
onMouseDown={(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
window.addEventListener("mousemove", resizeWidth);
|
||||
window.addEventListener("mouseup", stopResizeAndMove);
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
startSession(e, "move");
|
||||
}}
|
||||
className={`resize-width-element ${resizeCursorIcon} absolute right-0 w-1.5 h-10 bg-blue-400 hover:bg-blue-500 rounded-full cursor-pointer transition-colors duration-150 opacity-70 hover:opacity-100`}
|
||||
></div>
|
||||
>
|
||||
<div className="flex items-center gap-0.5 opacity-40 hover:opacity-70 transition-opacity">
|
||||
<svg width="20" height="10" viewBox="0 0 20 10" fill="none">
|
||||
<circle cx="4" cy="3" r="1.2" fill="#3b82f6" />
|
||||
<circle cx="10" cy="3" r="1.2" fill="#3b82f6" />
|
||||
<circle cx="16" cy="3" r="1.2" fill="#3b82f6" />
|
||||
<circle cx="4" cy="7" r="1.2" fill="#3b82f6" />
|
||||
<circle cx="10" cy="7" r="1.2" fill="#3b82f6" />
|
||||
<circle cx="16" cy="7" r="1.2" fill="#3b82f6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getMoveElement: GetReactElementFunction = (): ReactElement => {
|
||||
// if not selected, then return null
|
||||
|
||||
if (!props.isSelected || !props.isEditMode) {
|
||||
const getResizeWidthHandle: GetReactElementFunction = (): ReactElement => {
|
||||
if (!showHandles) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-20 group"
|
||||
style={{
|
||||
top: "-9px",
|
||||
left: "-9px",
|
||||
top: "28px",
|
||||
right: "-4px",
|
||||
bottom: "4px",
|
||||
width: "8px",
|
||||
cursor: "ew-resize",
|
||||
}}
|
||||
key={props.key}
|
||||
onMouseDown={(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
|
||||
window.addEventListener("mousemove", moveComponent);
|
||||
window.addEventListener("mouseup", stopResizeAndMove);
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
startSession(e, "resize-w");
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
stopResizeAndMove();
|
||||
}}
|
||||
className="move-element cursor-move absolute w-4 h-4 bg-blue-400 hover:bg-blue-500 rounded-full cursor-pointer transition-colors duration-150 opacity-70 hover:opacity-100 shadow-sm"
|
||||
onDragStart={(_event: React.DragEvent<HTMLDivElement>) => {}}
|
||||
onDragEnd={(_event: React.DragEvent<HTMLDivElement>) => {}}
|
||||
></div>
|
||||
>
|
||||
<div
|
||||
className="absolute top-1/2 right-0.5 w-1 rounded-full bg-blue-400 group-hover:bg-blue-500 transition-all duration-150"
|
||||
style={{
|
||||
height: "32px",
|
||||
transform: "translateY(-50%)",
|
||||
opacity: props.isSelected ? 0.8 : 0.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getResizeHeightElement: GetReactElementFunction = (): ReactElement => {
|
||||
if (!props.isSelected || !props.isEditMode) {
|
||||
const getResizeHeightHandle: GetReactElementFunction = (): ReactElement => {
|
||||
if (!showHandles) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
let resizeCursorIcon: string = "cursor-ns-resize";
|
||||
|
||||
// if already at min height then change icon to s-resize
|
||||
|
||||
if (
|
||||
component.heightInDashboardUnits <= component.minHeightInDashboardUnits
|
||||
) {
|
||||
resizeCursorIcon = "cursor-s-resize";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-20 group"
|
||||
style={{
|
||||
bottom: "-5px",
|
||||
left: "calc(50% - 20px)",
|
||||
bottom: "-4px",
|
||||
left: "4px",
|
||||
right: "12px",
|
||||
height: "8px",
|
||||
cursor: "ns-resize",
|
||||
}}
|
||||
onMouseDown={(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
window.addEventListener("mousemove", resizeHeight);
|
||||
window.addEventListener("mouseup", stopResizeAndMove);
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
startSession(e, "resize-h");
|
||||
}}
|
||||
className={`resize-height-element ${resizeCursorIcon} absolute bottom-0 left-0 w-10 h-1.5 bg-blue-400 hover:bg-blue-500 rounded-full cursor-pointer transition-colors duration-150 opacity-70 hover:opacity-100`}
|
||||
></div>
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-0.5 left-1/2 h-1 rounded-full bg-blue-400 group-hover:bg-blue-500 transition-all duration-150"
|
||||
style={{
|
||||
width: "32px",
|
||||
transform: "translateX(-50%)",
|
||||
opacity: props.isSelected ? 0.8 : 0.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getResizeCornerHandle: GetReactElementFunction = (): ReactElement => {
|
||||
if (!showHandles) {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="absolute z-30 group"
|
||||
style={{
|
||||
bottom: "-4px",
|
||||
right: "-4px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
cursor: "nwse-resize",
|
||||
}}
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
startSession(e, "resize-corner");
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-1 right-1"
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRight: `2px solid ${props.isSelected ? "rgba(59,130,246,0.8)" : "rgba(59,130,246,0.5)"}`,
|
||||
borderBottom: `2px solid ${props.isSelected ? "rgba(59,130,246,0.8)" : "rgba(59,130,246,0.5)"}`,
|
||||
borderRadius: "0 0 2px 0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -376,94 +488,145 @@ const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
margin: `${MarginForEachUnitInPx}px`,
|
||||
height: `${
|
||||
GetDashboardUnitHeightInPx(props.totalCurrentDashboardWidthInPx) *
|
||||
heightOfComponent +
|
||||
SpaceBetweenUnitsInPx * (heightOfComponent - 1)
|
||||
}px`,
|
||||
width: `${
|
||||
GetDashboardUnitWidthInPx(props.totalCurrentDashboardWidthInPx) *
|
||||
widthOfComponent +
|
||||
(SpaceBetweenUnitsInPx - 2) * (widthOfComponent - 1)
|
||||
}px`,
|
||||
boxShadow:
|
||||
"0 1px 3px 0 rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.03)",
|
||||
gridColumn: `span ${widthOfComponent}`,
|
||||
gridRow: `span ${heightOfComponent}`,
|
||||
boxShadow: isDragging
|
||||
? "0 20px 40px -8px rgba(59,130,246,0.15), 0 8px 16px -4px rgba(0,0,0,0.08)"
|
||||
: props.isSelected && props.isEditMode
|
||||
? "0 4px 12px -2px rgba(59,130,246,0.12), 0 2px 4px -1px rgba(0,0,0,0.04)"
|
||||
: "0 2px 8px -2px rgba(0,0,0,0.08), 0 1px 4px -1px rgba(0,0,0,0.04)",
|
||||
transition: isDragging
|
||||
? "none"
|
||||
: "box-shadow 0.2s ease, border-color 0.2s ease",
|
||||
}}
|
||||
ref={elRef}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (!isDragging) {
|
||||
props.onClick();
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!isDragging) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
}}
|
||||
key={component.componentId?.toString() || Math.random().toString()}
|
||||
ref={dashboardComponentRef}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{getMoveElement()}
|
||||
{getMoveHandle()}
|
||||
|
||||
{/* Component type badge - visible in edit mode */}
|
||||
{props.isEditMode && props.isSelected && (
|
||||
<div className="absolute top-1.5 right-1.5 z-10">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500 capitalize">
|
||||
{/* Tooltip — updated imperatively via ref, never causes a render */}
|
||||
<div
|
||||
className="absolute z-50 pointer-events-none"
|
||||
style={{
|
||||
top: "-32px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
display: isDragging ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="px-2 py-1 rounded-md text-xs font-mono font-medium text-white whitespace-nowrap"
|
||||
style={{
|
||||
background: "rgba(30, 41, 59, 0.9)",
|
||||
backdropFilter: "blur(4px)",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Component type badge */}
|
||||
{props.isEditMode && (props.isSelected || isHovered) && !isDragging && (
|
||||
<div
|
||||
className="absolute z-10 pointer-events-none"
|
||||
style={{
|
||||
top: showHandles ? "32px" : "6px",
|
||||
right: "6px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium capitalize"
|
||||
style={{
|
||||
background: "rgba(241, 245, 249, 0.9)",
|
||||
color: "#64748b",
|
||||
}}
|
||||
>
|
||||
{component.componentType}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{component.componentType === DashboardComponentType.Text && (
|
||||
<DashboardTextComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTextComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Chart && (
|
||||
<DashboardChartComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardChartComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Value && (
|
||||
<DashboardValueComponent
|
||||
{...props}
|
||||
isSelected={props.isSelected}
|
||||
isEditMode={props.isEditMode}
|
||||
component={component as DashboardValueComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Table && (
|
||||
<DashboardTableComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTableComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Gauge && (
|
||||
<DashboardGaugeComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardGaugeComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.LogStream && (
|
||||
<DashboardLogStreamComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardLogStreamComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.TraceList && (
|
||||
<DashboardTraceListComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTraceListComponentType}
|
||||
/>
|
||||
)}
|
||||
{/* Component content */}
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
padding: showHandles ? "28px 12px 12px 12px" : "12px",
|
||||
}}
|
||||
>
|
||||
{component.componentType === DashboardComponentType.Text && (
|
||||
<DashboardTextComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTextComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Chart && (
|
||||
<DashboardChartComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardChartComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Value && (
|
||||
<DashboardValueComponent
|
||||
{...props}
|
||||
isSelected={props.isSelected}
|
||||
isEditMode={props.isEditMode}
|
||||
component={component as DashboardValueComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Table && (
|
||||
<DashboardTableComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTableComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Gauge && (
|
||||
<DashboardGaugeComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardGaugeComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.LogStream && (
|
||||
<DashboardLogStreamComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardLogStreamComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.TraceList && (
|
||||
<DashboardTraceListComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTraceListComponentType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{getResizeWidthElement()}
|
||||
{getResizeHeightElement()}
|
||||
{getResizeWidthHandle()}
|
||||
{getResizeHeightHandle()}
|
||||
{getResizeCornerHandle()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,18 +29,22 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
|
||||
// Resolve query configs - support both single and multi-query
|
||||
// Resolve query configs - combine primary query with additional queries
|
||||
const resolveQueryConfigs: () => Array<MetricQueryConfigData> = () => {
|
||||
const configs: Array<MetricQueryConfigData> = [];
|
||||
|
||||
if (props.component.arguments.metricQueryConfig) {
|
||||
configs.push(props.component.arguments.metricQueryConfig);
|
||||
}
|
||||
|
||||
if (
|
||||
props.component.arguments.metricQueryConfigs &&
|
||||
props.component.arguments.metricQueryConfigs.length > 0
|
||||
) {
|
||||
return props.component.arguments.metricQueryConfigs;
|
||||
configs.push(...props.component.arguments.metricQueryConfigs);
|
||||
}
|
||||
if (props.component.arguments.metricQueryConfig) {
|
||||
return [props.component.arguments.metricQueryConfig];
|
||||
}
|
||||
return [];
|
||||
|
||||
return configs;
|
||||
};
|
||||
|
||||
const queryConfigs: Array<MetricQueryConfigData> = resolveQueryConfigs();
|
||||
@@ -140,8 +144,8 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props.component.arguments.metricQueryConfigs,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
// Skeleton loading for chart
|
||||
if (isLoading && metricResults.length === 0) {
|
||||
// Skeleton loading for chart - only on initial load
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col p-1 animate-pulse">
|
||||
<div className="h-3 w-28 bg-gray-100 rounded mb-3"></div>
|
||||
@@ -176,10 +180,22 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
}
|
||||
|
||||
const numberOfCharts: number = queryConfigs.length || 1;
|
||||
// Account for widget-level header and per-chart overhead (title + legend + padding)
|
||||
const hasWidgetHeader: boolean = Boolean(
|
||||
props.component.arguments.chartTitle ||
|
||||
props.component.arguments.chartDescription,
|
||||
);
|
||||
const widgetHeaderHeight: number = hasWidgetHeader ? 50 : 0;
|
||||
// Each chart section: pt-5(20) + title(20) + legend(24) + pb-4(16) = ~80px overhead
|
||||
const perChartOverhead: number = 80;
|
||||
let heightOfChart: number | undefined =
|
||||
(props.dashboardComponentHeightInPx || 0) - 100;
|
||||
((props.dashboardComponentHeightInPx || 0) -
|
||||
widgetHeaderHeight -
|
||||
numberOfCharts * perChartOverhead) /
|
||||
numberOfCharts;
|
||||
|
||||
if (heightOfChart < 0) {
|
||||
if (heightOfChart < 50) {
|
||||
heightOfChart = undefined;
|
||||
}
|
||||
|
||||
@@ -199,41 +215,19 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
|
||||
const chartMetricViewData: MetricViewData = {
|
||||
queryConfigs: queryConfigs.map(
|
||||
(config: MetricQueryConfigData, index: number) => {
|
||||
// For the first query, apply the chart-level title/description/legend
|
||||
if (index === 0) {
|
||||
return {
|
||||
...config,
|
||||
metricAliasData: {
|
||||
title:
|
||||
config.metricAliasData?.title ||
|
||||
props.component.arguments.chartTitle ||
|
||||
undefined,
|
||||
description:
|
||||
config.metricAliasData?.description ||
|
||||
props.component.arguments.chartDescription ||
|
||||
undefined,
|
||||
metricVariable:
|
||||
config.metricAliasData?.metricVariable || undefined,
|
||||
legend:
|
||||
config.metricAliasData?.legend ||
|
||||
props.component.arguments.legendText ||
|
||||
undefined,
|
||||
legendUnit:
|
||||
config.metricAliasData?.legendUnit ||
|
||||
props.component.arguments.legendUnit ||
|
||||
undefined,
|
||||
},
|
||||
chartType: config.chartType || getMetricChartType(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
chartType: config.chartType || getMetricChartType(),
|
||||
};
|
||||
},
|
||||
),
|
||||
queryConfigs: queryConfigs.map((config: MetricQueryConfigData) => {
|
||||
return {
|
||||
...config,
|
||||
metricAliasData: {
|
||||
metricVariable: config.metricAliasData?.metricVariable || undefined,
|
||||
title: config.metricAliasData?.title || undefined,
|
||||
description: config.metricAliasData?.description || undefined,
|
||||
legend: config.metricAliasData?.legend || undefined,
|
||||
legendUnit: config.metricAliasData?.legendUnit || undefined,
|
||||
},
|
||||
chartType: config.chartType || getMetricChartType(),
|
||||
};
|
||||
}),
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
),
|
||||
@@ -241,14 +235,37 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<MetricCharts
|
||||
metricResults={metricResults}
|
||||
metricTypes={props.metricTypes}
|
||||
metricViewData={chartMetricViewData}
|
||||
hideCard={true}
|
||||
heightInPx={heightOfChart}
|
||||
/>
|
||||
<div
|
||||
className="w-full h-full overflow-hidden flex flex-col"
|
||||
style={{
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{(props.component.arguments.chartTitle ||
|
||||
props.component.arguments.chartDescription) && (
|
||||
<div className="px-2 pt-2 pb-1 flex-shrink-0">
|
||||
{props.component.arguments.chartTitle && (
|
||||
<h3 className="text-sm font-semibold text-gray-700 tracking-tight">
|
||||
{props.component.arguments.chartTitle}
|
||||
</h3>
|
||||
)}
|
||||
{props.component.arguments.chartDescription && (
|
||||
<p className="mt-0.5 text-xs text-gray-400">
|
||||
{props.component.arguments.chartDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<MetricCharts
|
||||
metricResults={metricResults}
|
||||
metricTypes={props.metricTypes}
|
||||
metricViewData={chartMetricViewData}
|
||||
hideCard={true}
|
||||
heightInPx={heightOfChart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -111,8 +111,8 @@ const DashboardGaugeComponentElement: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
}, [props.component.arguments.metricQueryConfig]);
|
||||
|
||||
if (isLoading) {
|
||||
// Skeleton loading for gauge
|
||||
if (isLoading && metricResults.length === 0) {
|
||||
// Skeleton loading for gauge - only on initial load
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center animate-pulse">
|
||||
<div className="h-3 w-20 bg-gray-100 rounded mb-3"></div>
|
||||
@@ -318,7 +318,13 @@ const DashboardGaugeComponentElement: FunctionComponent<ComponentProps> = (
|
||||
const percentDisplay: number = Math.round(percentage * 100);
|
||||
|
||||
return (
|
||||
<div className="w-full text-center h-full flex flex-col items-center justify-center">
|
||||
<div
|
||||
className="w-full text-center h-full flex flex-col items-center justify-center"
|
||||
style={{
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{props.component.arguments.gaugeTitle && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -168,7 +168,7 @@ const DashboardLogStreamComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props.component.arguments.maxRows,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading && logs.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex flex-col animate-pulse">
|
||||
<div className="h-3 w-24 bg-gray-100 rounded mb-3"></div>
|
||||
@@ -208,7 +208,13 @@ const DashboardLogStreamComponentElement: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto flex flex-col">
|
||||
<div
|
||||
className="h-full overflow-auto flex flex-col"
|
||||
style={{
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{props.component.arguments.title && (
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
@@ -227,7 +233,7 @@ const DashboardLogStreamComponentElement: FunctionComponent<ComponentProps> = (
|
||||
const colors: SeverityColor = getSeverityColor(severity);
|
||||
const body: string = (log.body as string) || "";
|
||||
const time: Date | undefined = log.time
|
||||
? OneUptimeDate.fromString(log.time as string)
|
||||
? OneUptimeDate.fromString(log.time as unknown as string)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
||||
@@ -112,8 +112,8 @@ const DashboardTableComponentElement: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
}, [props.component.arguments.metricQueryConfig]);
|
||||
|
||||
if (isLoading) {
|
||||
// Skeleton loading for table
|
||||
if (isLoading && metricResults.length === 0) {
|
||||
// Skeleton loading for table - only on initial load
|
||||
return (
|
||||
<div className="h-full flex flex-col animate-pulse">
|
||||
<div className="h-3 w-24 bg-gray-100 rounded mb-3"></div>
|
||||
@@ -174,7 +174,13 @@ const DashboardTableComponentElement: FunctionComponent<ComponentProps> = (
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto flex flex-col">
|
||||
<div
|
||||
className="h-full overflow-auto flex flex-col"
|
||||
style={{
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{props.component.arguments.tableTitle && (
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
|
||||
@@ -150,7 +150,7 @@ const DashboardTraceListComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props.component.arguments.maxRows,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading && spans.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex flex-col animate-pulse">
|
||||
<div className="h-3 w-24 bg-gray-100 rounded mb-3"></div>
|
||||
@@ -192,7 +192,13 @@ const DashboardTraceListComponentElement: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto flex flex-col">
|
||||
<div
|
||||
className="h-full overflow-auto flex flex-col"
|
||||
style={{
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{props.component.arguments.title && (
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
@@ -241,7 +247,7 @@ const DashboardTraceListComponentElement: FunctionComponent<ComponentProps> = (
|
||||
const durationNano: number =
|
||||
(span.durationUnixNano as number) || 0;
|
||||
const startTime: Date | undefined = span.startTime
|
||||
? OneUptimeDate.fromString(span.startTime as string)
|
||||
? OneUptimeDate.fromString(span.startTime as unknown as string)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
||||
@@ -177,8 +177,8 @@ const DashboardValueComponentElement: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
}, [props.component.arguments.metricQueryConfig]);
|
||||
|
||||
if (isLoading) {
|
||||
// Skeleton loading state
|
||||
if (isLoading && metricResults.length === 0) {
|
||||
// Skeleton loading state - only on initial load
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center rounded-md animate-pulse">
|
||||
<div className="h-3 w-16 bg-gray-100 rounded mb-3"></div>
|
||||
@@ -359,7 +359,11 @@ const DashboardValueComponentElement: FunctionComponent<ComponentProps> = (
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex flex-col items-center justify-center rounded-md relative overflow-hidden"
|
||||
style={bgStyle}
|
||||
style={{
|
||||
...bgStyle,
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Icon, { SizeProp } from "Common/UI/Components/Icon/Icon";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface ComponentProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: IconProp;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const DashboardTemplateCard: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer border border-gray-200 rounded-lg p-4 hover:border-indigo-500 hover:shadow-md transition-all duration-200 bg-white"
|
||||
onClick={props.onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
props.onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-indigo-50 mb-3">
|
||||
<Icon
|
||||
icon={props.icon}
|
||||
size={SizeProp.Large}
|
||||
className="text-indigo-500 h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTemplateCard;
|
||||
@@ -102,6 +102,7 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
|
||||
const [dashboardName, setDashboardName] = useState<string>("");
|
||||
const [dashboardDescription, setDashboardDescription] = useState<string>("");
|
||||
|
||||
const handleResize: VoidFunction = (): void => {
|
||||
setDashboardTotalWidth(dashboardViewRef.current?.offsetWidth || 0);
|
||||
@@ -158,6 +159,8 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
dashboardViewConfig: true,
|
||||
name: true,
|
||||
description: true,
|
||||
pageTitle: true,
|
||||
pageDescription: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -172,7 +175,12 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
) as DashboardViewConfig;
|
||||
|
||||
setDashboardViewConfig(config);
|
||||
setDashboardName(dashboard.name || "Untitled Dashboard");
|
||||
setDashboardName(
|
||||
dashboard.pageTitle || dashboard.name || "Untitled Dashboard",
|
||||
);
|
||||
setDashboardDescription(
|
||||
dashboard.pageDescription || dashboard.description || "",
|
||||
);
|
||||
|
||||
// Restore saved auto-refresh interval
|
||||
if (config.refreshInterval) {
|
||||
@@ -274,7 +282,7 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
width: `calc(100% - ${sideBarWidth}px)`,
|
||||
background: isEditMode
|
||||
? "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)"
|
||||
: "#fafbfc",
|
||||
: "#f8f9fb",
|
||||
}}
|
||||
>
|
||||
<DashboardToolbar
|
||||
@@ -298,6 +306,7 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
}}
|
||||
dashboardViewConfig={dashboardViewConfig}
|
||||
dashboardName={dashboardName}
|
||||
dashboardDescription={dashboardDescription}
|
||||
isSaving={isSaving}
|
||||
onSaveClick={() => {
|
||||
// Save auto-refresh interval with the config
|
||||
@@ -407,7 +416,15 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
setDashboardViewConfig(newDashboardConfig);
|
||||
}}
|
||||
/>
|
||||
<div ref={dashboardCanvasRef} className="px-1 pb-4">
|
||||
<div
|
||||
ref={dashboardCanvasRef}
|
||||
className="px-1 pb-4 mx-3 mb-4 rounded-2xl border border-gray-200/60"
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
boxShadow:
|
||||
"0 1px 4px 0 rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.03)",
|
||||
}}
|
||||
>
|
||||
<DashboardCanvas
|
||||
dashboardViewConfig={dashboardViewConfig}
|
||||
onDashboardViewConfigChange={(newConfig: DashboardViewConfig) => {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import Button, {
|
||||
ButtonSize,
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import DashboardMode from "Common/Types/Dashboard/DashboardMode";
|
||||
import MoreMenu from "Common/UI/Components/MoreMenu/MoreMenu";
|
||||
import MoreMenuItem from "Common/UI/Components/MoreMenu/MoreMenuItem";
|
||||
@@ -9,12 +19,14 @@ import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
import DashboardViewConfig, {
|
||||
AutoRefreshInterval,
|
||||
getAutoRefreshIntervalInMs,
|
||||
getAutoRefreshIntervalLabel,
|
||||
} from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import DashboardVariable from "Common/Types/Dashboard/DashboardVariable";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import Loader from "Common/UI/Components/Loader/Loader";
|
||||
import DashboardVariableSelector from "./DashboardVariableSelector";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
|
||||
export interface ComponentProps {
|
||||
onEditClick: () => void;
|
||||
@@ -25,6 +37,7 @@ export interface ComponentProps {
|
||||
onAddComponentClick: (type: DashboardComponentType) => void;
|
||||
isSaving: boolean;
|
||||
dashboardName: string;
|
||||
dashboardDescription?: string | undefined;
|
||||
startAndEndDate: RangeStartAndEndDateTime;
|
||||
onStartAndEndDateChange: (startAndEndDate: RangeStartAndEndDateTime) => void;
|
||||
dashboardViewConfig: DashboardViewConfig;
|
||||
@@ -39,6 +52,214 @@ export interface ComponentProps {
|
||||
onResetZoom?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
interface CountdownCircleProps {
|
||||
durationMs: number;
|
||||
size: number;
|
||||
strokeWidth: number;
|
||||
label: string;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
const CountdownCircle: FunctionComponent<CountdownCircleProps> = (
|
||||
props: CountdownCircleProps,
|
||||
): ReactElement => {
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const startTimeRef: React.MutableRefObject<number> = useRef<number>(
|
||||
Date.now(),
|
||||
);
|
||||
const animationFrameRef: React.MutableRefObject<number | null> = useRef<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const animate: () => void = useCallback(() => {
|
||||
const elapsed: number = Date.now() - startTimeRef.current;
|
||||
const newProgress: number = Math.min(elapsed / props.durationMs, 1);
|
||||
setProgress(newProgress);
|
||||
|
||||
if (newProgress < 1) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Reset when complete
|
||||
startTimeRef.current = Date.now();
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
}, [props.durationMs]);
|
||||
|
||||
useEffect(() => {
|
||||
startTimeRef.current = Date.now();
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [props.durationMs, animate]);
|
||||
|
||||
// Reset on refresh
|
||||
useEffect(() => {
|
||||
if (props.isRefreshing) {
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
}, [props.isRefreshing]);
|
||||
|
||||
const radius: number = (props.size - props.strokeWidth) / 2;
|
||||
const circumference: number = 2 * Math.PI * radius;
|
||||
const strokeDashoffset: number = circumference * (1 - progress);
|
||||
const center: number = props.size / 2;
|
||||
|
||||
// Calculate remaining seconds
|
||||
const remainingMs: number = props.durationMs * (1 - progress);
|
||||
const remainingSec: number = Math.ceil(remainingMs / 1000);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: props.size, height: props.size }}
|
||||
>
|
||||
<svg
|
||||
width={props.size}
|
||||
height={props.size}
|
||||
className="transform -rotate-90"
|
||||
>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth={props.strokeWidth}
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#6366f1"
|
||||
strokeWidth={props.strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-none"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[8px] font-semibold text-indigo-600">
|
||||
{remainingSec}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-gray-500 font-medium">
|
||||
{props.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AutoRefreshDropdownProps {
|
||||
autoRefreshInterval: AutoRefreshInterval;
|
||||
autoRefreshMs: number | null;
|
||||
isAutoRefreshActive: boolean;
|
||||
isRefreshing: boolean;
|
||||
onAutoRefreshIntervalChange: (interval: AutoRefreshInterval) => void;
|
||||
}
|
||||
|
||||
const AutoRefreshDropdown: FunctionComponent<AutoRefreshDropdownProps> = (
|
||||
props: AutoRefreshDropdownProps,
|
||||
): ReactElement => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const dropdownRef: React.RefObject<HTMLDivElement> =
|
||||
useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside: (event: MouseEvent) => void = (
|
||||
event: MouseEvent,
|
||||
): void => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={dropdownRef}>
|
||||
{/* Trigger: countdown circle when active, refresh icon when not */}
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 transition-colors cursor-pointer border ${
|
||||
props.isAutoRefreshActive
|
||||
? "bg-indigo-50/50 border-indigo-100 hover:bg-indigo-50"
|
||||
: "bg-gray-50 border-gray-200/60 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
title="Auto-refresh settings"
|
||||
>
|
||||
{props.isAutoRefreshActive && props.autoRefreshMs ? (
|
||||
<CountdownCircle
|
||||
durationMs={props.autoRefreshMs}
|
||||
size={20}
|
||||
strokeWidth={2}
|
||||
label={getAutoRefreshIntervalLabel(props.autoRefreshInterval)}
|
||||
isRefreshing={props.isRefreshing}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Icon
|
||||
icon={IconProp.Refresh}
|
||||
className="w-3.5 h-3.5 text-gray-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Auto-refresh: Off</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-lg bg-white shadow-xl ring-1 ring-gray-200 focus:outline-none py-1">
|
||||
{Object.values(AutoRefreshInterval).map(
|
||||
(interval: AutoRefreshInterval) => {
|
||||
const isSelected: boolean =
|
||||
interval === props.autoRefreshInterval;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={interval}
|
||||
className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 hover:bg-gray-50 ${
|
||||
isSelected ? "text-indigo-600 font-medium" : "text-gray-700"
|
||||
}`}
|
||||
onClick={() => {
|
||||
props.onAutoRefreshIntervalChange(interval);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="w-4 text-center">
|
||||
{isSelected ? "\u2713" : ""}
|
||||
</span>
|
||||
{interval === AutoRefreshInterval.OFF
|
||||
? "Auto-refresh Off"
|
||||
: `Refresh every ${getAutoRefreshIntervalLabel(interval)}`}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
@@ -54,55 +275,136 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
props.dashboardViewConfig.components.length > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-3 mt-3 mb-2 rounded-lg bg-white border border-gray-200"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px -1px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
{/* Accent top bar */}
|
||||
<div
|
||||
className="h-0.5 rounded-t-lg"
|
||||
style={{
|
||||
background: isEditMode
|
||||
? "linear-gradient(90deg, #3b82f6 0%, #6366f1 50%, #8b5cf6 100%)"
|
||||
: "linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%)",
|
||||
}}
|
||||
></div>
|
||||
{/* Top row: Dashboard name + action buttons */}
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h1 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{props.dashboardName}
|
||||
</h1>
|
||||
{isEditMode && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600 border border-blue-100 animate-pulse">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full mr-1.5"></span>
|
||||
Editing
|
||||
</span>
|
||||
)}
|
||||
{hasComponents && !isEditMode && (
|
||||
<span className="text-xs text-gray-400 tabular-nums">
|
||||
{props.dashboardViewConfig.components.length} widget
|
||||
{props.dashboardViewConfig.components.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{/* Refreshing indicator */}
|
||||
{props.isRefreshing &&
|
||||
props.autoRefreshInterval !== AutoRefreshInterval.OFF && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-blue-600">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
|
||||
Refreshing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
const isAutoRefreshActive: boolean =
|
||||
props.autoRefreshInterval !== AutoRefreshInterval.OFF;
|
||||
const autoRefreshMs: number | null = getAutoRefreshIntervalInMs(
|
||||
props.autoRefreshInterval,
|
||||
);
|
||||
|
||||
{!isSaving && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isEditMode ? (
|
||||
return (
|
||||
<div className="mx-3 mt-3 mb-3">
|
||||
<div
|
||||
className="rounded-2xl bg-white border border-gray-200/60"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2px 8px -2px rgba(0, 0, 0, 0.08), 0 1px 4px -1px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
{/* Main toolbar row */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5">
|
||||
{/* Left: Icon + Title + Description */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-indigo-50 flex items-center justify-center">
|
||||
<Icon
|
||||
icon={IconProp.Layout}
|
||||
className="w-4 h-4 text-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-semibold text-gray-800 truncate">
|
||||
{props.dashboardName}
|
||||
</h1>
|
||||
{isEditMode && (
|
||||
<span className="inline-flex items-center px-1.5 py-px rounded-full text-[10px] font-medium bg-blue-50 text-blue-600 border border-blue-100 animate-pulse">
|
||||
<span className="w-1 h-1 bg-blue-500 rounded-full mr-1"></span>
|
||||
Editing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{props.dashboardDescription && !isEditMode && (
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5 max-w-md">
|
||||
{props.dashboardDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Time range + Auto-refresh + Variables + Actions */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{/* Time range + variables (view mode only) */}
|
||||
{hasComponents && !isEditMode && (
|
||||
<>
|
||||
{/* Template variables */}
|
||||
{props.variables &&
|
||||
props.variables.length > 0 &&
|
||||
props.onVariableValueChange && (
|
||||
<>
|
||||
<DashboardVariableSelector
|
||||
variables={props.variables}
|
||||
onVariableValueChange={props.onVariableValueChange}
|
||||
/>
|
||||
<div className="w-px h-5 bg-gray-200 mx-0.5"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={props.startAndEndDate}
|
||||
onChange={(startAndEndDate: RangeStartAndEndDateTime) => {
|
||||
props.onStartAndEndDateChange(startAndEndDate);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Auto-refresh section */}
|
||||
<AutoRefreshDropdown
|
||||
autoRefreshInterval={props.autoRefreshInterval}
|
||||
autoRefreshMs={autoRefreshMs}
|
||||
isAutoRefreshActive={isAutoRefreshActive}
|
||||
isRefreshing={props.isRefreshing || false}
|
||||
onAutoRefreshIntervalChange={
|
||||
props.onAutoRefreshIntervalChange
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Reset Zoom button */}
|
||||
{props.canResetZoom && props.onResetZoom && (
|
||||
<Button
|
||||
icon={IconProp.Refresh}
|
||||
title="Reset Zoom"
|
||||
buttonStyle={ButtonStyleType.HOVER_PRIMARY_OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={props.onResetZoom}
|
||||
tooltip="Reset to original time range"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* More menu: Edit + Full Screen (always visible in view mode) */}
|
||||
{!isEditMode && (
|
||||
<MoreMenu
|
||||
menuIcon={IconProp.EllipsisHorizontal}
|
||||
elementToBeShownInsteadOfButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center rounded-lg w-8 h-8 bg-gray-50 border border-gray-200/60 hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
title="More options"
|
||||
>
|
||||
<Icon
|
||||
icon={IconProp.EllipsisHorizontal}
|
||||
className="w-4 h-4 text-gray-500"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<MoreMenuItem
|
||||
text={"Edit Dashboard"}
|
||||
icon={IconProp.Pencil}
|
||||
key={"edit"}
|
||||
onClick={props.onEditClick}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Full Screen"}
|
||||
icon={IconProp.Expand}
|
||||
key={"fullscreen"}
|
||||
onClick={props.onFullScreenClick}
|
||||
/>
|
||||
</MoreMenu>
|
||||
)}
|
||||
|
||||
{/* Edit mode actions */}
|
||||
{!isSaving && isEditMode && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MoreMenu menuIcon={IconProp.Add} text="Add Widget">
|
||||
<MoreMenuItem
|
||||
text={"Chart"}
|
||||
@@ -138,7 +440,7 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Gauge"}
|
||||
icon={IconProp.Activity}
|
||||
icon={IconProp.Gauge}
|
||||
key={"add-gauge"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Gauge);
|
||||
@@ -156,7 +458,7 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Trace List"}
|
||||
icon={IconProp.QueueList}
|
||||
icon={IconProp.Waterfall}
|
||||
key={"add-trace-list"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(
|
||||
@@ -166,117 +468,36 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
</MoreMenu>
|
||||
|
||||
<div className="w-px h-6 bg-gray-200 mx-1"></div>
|
||||
<div className="w-px h-5 bg-gray-200 mx-0.5"></div>
|
||||
|
||||
<Button
|
||||
icon={IconProp.Check}
|
||||
title="Save"
|
||||
buttonStyle={ButtonStyleType.HOVER_PRIMARY_OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={props.onSaveClick}
|
||||
/>
|
||||
<Button
|
||||
icon={IconProp.Close}
|
||||
title="Cancel"
|
||||
buttonStyle={ButtonStyleType.HOVER_DANGER_OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
setShowCancelModal(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Reset Zoom button */}
|
||||
{props.canResetZoom && props.onResetZoom && (
|
||||
<Button
|
||||
icon={IconProp.Refresh}
|
||||
title="Reset Zoom"
|
||||
buttonStyle={ButtonStyleType.HOVER_PRIMARY_OUTLINE}
|
||||
onClick={props.onResetZoom}
|
||||
tooltip="Reset to original time range"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-refresh dropdown */}
|
||||
{hasComponents && (
|
||||
<MoreMenu
|
||||
menuIcon={IconProp.Refresh}
|
||||
text={
|
||||
props.autoRefreshInterval !== AutoRefreshInterval.OFF
|
||||
? getAutoRefreshIntervalLabel(props.autoRefreshInterval)
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{Object.values(AutoRefreshInterval).map(
|
||||
(interval: AutoRefreshInterval) => {
|
||||
return (
|
||||
<MoreMenuItem
|
||||
key={interval}
|
||||
text={getAutoRefreshIntervalLabel(interval)}
|
||||
onClick={() => {
|
||||
props.onAutoRefreshIntervalChange(interval);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</MoreMenu>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={IconProp.Expand}
|
||||
buttonStyle={ButtonStyleType.ICON}
|
||||
onClick={props.onFullScreenClick}
|
||||
tooltip="Full Screen"
|
||||
/>
|
||||
|
||||
<div className="w-px h-6 bg-gray-200 mx-0.5"></div>
|
||||
|
||||
<Button
|
||||
icon={IconProp.Pencil}
|
||||
title="Edit"
|
||||
buttonStyle={ButtonStyleType.ICON}
|
||||
onClick={props.onEditClick}
|
||||
tooltip="Edit Dashboard"
|
||||
/>
|
||||
</>
|
||||
{isSaving && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader />
|
||||
<span className="text-xs text-gray-500">Saving...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSaving && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader />
|
||||
<span className="text-sm text-gray-500">Saving...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom row: Time range + variables (only when components exist and not in edit mode) */}
|
||||
{hasComponents && !isEditMode && (
|
||||
<div className="flex items-center gap-3 px-5 pb-3 pt-0 flex-wrap">
|
||||
<div>
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={props.startAndEndDate}
|
||||
onChange={(startAndEndDate: RangeStartAndEndDateTime) => {
|
||||
props.onStartAndEndDateChange(startAndEndDate);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template variables */}
|
||||
{props.variables &&
|
||||
props.variables.length > 0 &&
|
||||
props.onVariableValueChange && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-gray-200"></div>
|
||||
<DashboardVariableSelector
|
||||
variables={props.variables}
|
||||
onVariableValueChange={props.onVariableValueChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCancelModal ? (
|
||||
<ConfirmModal
|
||||
|
||||
@@ -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;
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
buildKubernetesMonitorConfig,
|
||||
} from "Common/Types/Monitor/KubernetesAlertTemplates";
|
||||
import { KubernetesMetricDefinition } from "Common/Types/Monitor/KubernetesMetricCatalog";
|
||||
import MonitorCriteria from "Common/Types/Monitor/MonitorCriteria";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
|
||||
export type KubernetesFormMode = "quick" | "custom" | "advanced";
|
||||
@@ -43,9 +44,16 @@ export interface ComponentProps {
|
||||
onChange: (
|
||||
monitorStepKubernetesMonitor: MonitorStepKubernetesMonitor,
|
||||
) => void;
|
||||
onMonitorCriteriaChange?: ((criteria: MonitorCriteria) => void) | undefined;
|
||||
onModeChange?: ((mode: KubernetesFormMode) => void) | undefined;
|
||||
initialTemplateId?: string | undefined;
|
||||
initialClusterId?: string | undefined;
|
||||
// These IDs are needed to build proper criteria from templates
|
||||
onlineMonitorStatusId?: ObjectID | undefined;
|
||||
offlineMonitorStatusId?: ObjectID | undefined;
|
||||
defaultIncidentSeverityId?: ObjectID | undefined;
|
||||
defaultAlertSeverityId?: ObjectID | undefined;
|
||||
monitorName?: string | undefined;
|
||||
}
|
||||
|
||||
const resourceScopeOptions: Array<DropdownOption> = [
|
||||
@@ -222,25 +230,40 @@ const KubernetesMonitorStepForm: FunctionComponent<ComponentProps> = (
|
||||
monitorStepKubernetesMonitor.clusterIdentifier;
|
||||
|
||||
/*
|
||||
* Get a dummy monitor step from the template to extract the kubernetes config
|
||||
* Build even without a cluster so the metricViewConfig is populated for the METRIC dropdown
|
||||
* Use real monitor status and severity IDs if available,
|
||||
* so the template criteria are properly configured
|
||||
*/
|
||||
const dummyStep: MonitorStep = template.getMonitorStep({
|
||||
const onlineMonitorStatusId: ObjectID =
|
||||
props.onlineMonitorStatusId || ObjectID.generate();
|
||||
const offlineMonitorStatusId: ObjectID =
|
||||
props.offlineMonitorStatusId || ObjectID.generate();
|
||||
const defaultIncidentSeverityId: ObjectID =
|
||||
props.defaultIncidentSeverityId || ObjectID.generate();
|
||||
const defaultAlertSeverityId: ObjectID =
|
||||
props.defaultAlertSeverityId || ObjectID.generate();
|
||||
const monitorName: string = props.monitorName || template.name;
|
||||
|
||||
const templateStep: MonitorStep = template.getMonitorStep({
|
||||
clusterIdentifier: clusterIdentifier || "",
|
||||
onlineMonitorStatusId: ObjectID.generate(),
|
||||
offlineMonitorStatusId: ObjectID.generate(),
|
||||
defaultIncidentSeverityId: ObjectID.generate(),
|
||||
defaultAlertSeverityId: ObjectID.generate(),
|
||||
monitorName: template.name,
|
||||
onlineMonitorStatusId,
|
||||
offlineMonitorStatusId,
|
||||
defaultIncidentSeverityId,
|
||||
defaultAlertSeverityId,
|
||||
monitorName,
|
||||
});
|
||||
|
||||
// Extract the kubernetes monitor config
|
||||
if (dummyStep.data?.kubernetesMonitor) {
|
||||
if (templateStep.data?.kubernetesMonitor) {
|
||||
props.onChange({
|
||||
...dummyStep.data.kubernetesMonitor,
|
||||
...templateStep.data.kubernetesMonitor,
|
||||
clusterIdentifier: clusterIdentifier || "",
|
||||
});
|
||||
}
|
||||
|
||||
// Also apply the template's criteria (alert rules, thresholds, incidents, etc.)
|
||||
if (templateStep.data?.monitorCriteria && props.onMonitorCriteriaChange) {
|
||||
props.onMonitorCriteriaChange(templateStep.data.monitorCriteria);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomMetricSelection: (
|
||||
|
||||
@@ -110,6 +110,12 @@ export interface ComponentProps {
|
||||
allMonitorSteps: MonitorSteps;
|
||||
probes: Array<Probe>;
|
||||
monitorId?: ObjectID | undefined; // this is used to populate secrets when testing the monitor.
|
||||
// IDs needed for Kubernetes template criteria
|
||||
onlineMonitorStatusId?: ObjectID | undefined;
|
||||
offlineMonitorStatusId?: ObjectID | undefined;
|
||||
defaultIncidentSeverityId?: ObjectID | undefined;
|
||||
defaultAlertSeverityId?: ObjectID | undefined;
|
||||
monitorName?: string | undefined;
|
||||
}
|
||||
|
||||
const MonitorStepElement: FunctionComponent<ComponentProps> = (
|
||||
@@ -251,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.
|
||||
|
||||
@@ -269,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/');
|
||||
|
||||
@@ -280,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');
|
||||
@@ -760,6 +779,15 @@ return {
|
||||
monitorStep.setKubernetesMonitor(value);
|
||||
props.onChange?.(MonitorStep.clone(monitorStep));
|
||||
}}
|
||||
onMonitorCriteriaChange={(criteria: MonitorCriteria) => {
|
||||
monitorStep.setMonitorCriteria(criteria);
|
||||
props.onChange?.(MonitorStep.clone(monitorStep));
|
||||
}}
|
||||
onlineMonitorStatusId={props.onlineMonitorStatusId}
|
||||
offlineMonitorStatusId={props.offlineMonitorStatusId}
|
||||
defaultIncidentSeverityId={props.defaultIncidentSeverityId}
|
||||
defaultAlertSeverityId={props.defaultAlertSeverityId}
|
||||
monitorName={props.monitorName}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -74,6 +74,19 @@ const MonitorStepsElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const [probes, setProbes] = React.useState<Array<Probe>>([]);
|
||||
|
||||
// IDs needed for Kubernetes template criteria
|
||||
const [onlineMonitorStatusId, setOnlineMonitorStatusId] = React.useState<
|
||||
ObjectID | undefined
|
||||
>(undefined);
|
||||
const [offlineMonitorStatusId, setOfflineMonitorStatusId] = React.useState<
|
||||
ObjectID | undefined
|
||||
>(undefined);
|
||||
const [defaultIncidentSeverityId, setDefaultIncidentSeverityId] =
|
||||
React.useState<ObjectID | undefined>(undefined);
|
||||
const [defaultAlertSeverityId, setDefaultAlertSeverityId] = React.useState<
|
||||
ObjectID | undefined
|
||||
>(undefined);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string>();
|
||||
|
||||
@@ -109,6 +122,23 @@ const MonitorStepsElement: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Extract online (operational) and offline status IDs for template criteria
|
||||
const onlineStatus: MonitorStatus | undefined =
|
||||
monitorStatusList.data.find((i: MonitorStatus) => {
|
||||
return i.isOperationalState;
|
||||
});
|
||||
const offlineStatus: MonitorStatus | undefined =
|
||||
monitorStatusList.data.find((i: MonitorStatus) => {
|
||||
return i.isOfflineState;
|
||||
});
|
||||
|
||||
if (onlineStatus?._id) {
|
||||
setOnlineMonitorStatusId(new ObjectID(onlineStatus._id));
|
||||
}
|
||||
if (offlineStatus?._id) {
|
||||
setOfflineMonitorStatusId(new ObjectID(offlineStatus._id));
|
||||
}
|
||||
}
|
||||
|
||||
const incidentSeverityList: ListResult<IncidentSeverity> =
|
||||
@@ -162,6 +192,16 @@ const MonitorStepsElement: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Use the first (highest priority) severity as default for templates
|
||||
if (
|
||||
incidentSeverityList.data.length > 0 &&
|
||||
incidentSeverityList.data[0]?._id
|
||||
) {
|
||||
setDefaultIncidentSeverityId(
|
||||
new ObjectID(incidentSeverityList.data[0]._id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (alertSeverityList.data) {
|
||||
@@ -173,6 +213,16 @@ const MonitorStepsElement: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Use the first (highest priority) severity as default for templates
|
||||
if (
|
||||
alertSeverityList.data.length > 0 &&
|
||||
alertSeverityList.data[0]?._id
|
||||
) {
|
||||
setDefaultAlertSeverityId(
|
||||
new ObjectID(alertSeverityList.data[0]._id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (onCallPolicyList.data) {
|
||||
@@ -356,6 +406,11 @@ const MonitorStepsElement: FunctionComponent<ComponentProps> = (
|
||||
value={i}
|
||||
probes={probes}
|
||||
monitorId={props.monitorId}
|
||||
onlineMonitorStatusId={onlineMonitorStatusId}
|
||||
offlineMonitorStatusId={offlineMonitorStatusId}
|
||||
defaultIncidentSeverityId={defaultIncidentSeverityId}
|
||||
defaultAlertSeverityId={defaultAlertSeverityId}
|
||||
monitorName={props.monitorName}
|
||||
/*
|
||||
* onDelete={() => {
|
||||
* // remove the criteria filter
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ComponentProps {
|
||||
data: MetricAliasData;
|
||||
isFormula: boolean;
|
||||
onDataChanged: (data: MetricAliasData) => void;
|
||||
hideVariableBadge?: boolean | undefined;
|
||||
}
|
||||
|
||||
const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
@@ -15,45 +16,70 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
): ReactElement => {
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="flex space-x-3">
|
||||
{!props.isFormula && (
|
||||
<div className="bg-indigo-500 h-9 rounded w-9 p-3 pt-2 mt-2 font-medium text-white">
|
||||
{props.data.metricVariable}
|
||||
<div className="space-y-3">
|
||||
{/* Variable badge row — hidden when parent already shows it */}
|
||||
{!props.hideVariableBadge &&
|
||||
((!props.isFormula && props.data.metricVariable) ||
|
||||
props.isFormula) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{!props.isFormula && props.data.metricVariable && (
|
||||
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-xs font-semibold text-white">
|
||||
{props.data.metricVariable}
|
||||
</div>
|
||||
)}
|
||||
{props.isFormula && (
|
||||
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-white">
|
||||
<Icon thick={ThickProp.Thick} icon={IconProp.ChevronRight} />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">
|
||||
Display Settings
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and Description */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
value={props.data.title}
|
||||
onChange={(value: string) => {
|
||||
return props.onDataChanged({
|
||||
...props.data,
|
||||
metricVariable: props.data.metricVariable,
|
||||
title: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Chart title..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{props.isFormula && (
|
||||
<div className="bg-indigo-500 h-9 p-2 pt-2.5 rounded w-9 mt-2 font-bold text-white">
|
||||
<Icon thick={ThickProp.Thick} icon={IconProp.ChevronRight} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
value={props.data.description}
|
||||
onChange={(value: string) => {
|
||||
return props.onDataChanged({
|
||||
...props.data,
|
||||
metricVariable: props.data.metricVariable,
|
||||
description: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Chart description..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
value={props.data.title}
|
||||
onChange={(value: string) => {
|
||||
return props.onDataChanged({
|
||||
...props.data,
|
||||
metricVariable: props.data.metricVariable,
|
||||
title: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Title..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Input
|
||||
value={props.data.description}
|
||||
onChange={(value: string) => {
|
||||
return props.onDataChanged({
|
||||
...props.data,
|
||||
metricVariable: props.data.metricVariable,
|
||||
description: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Description..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/3 flex space-x-3">
|
||||
<div className="w-full">
|
||||
|
||||
{/* Legend and Unit */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Legend
|
||||
</label>
|
||||
<Input
|
||||
value={props.data.legend}
|
||||
onChange={(value: string) => {
|
||||
@@ -63,10 +89,13 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
legend: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Legend (e.g. Response Time)"
|
||||
placeholder="e.g. Response Time"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Unit
|
||||
</label>
|
||||
<Input
|
||||
value={props.data.legendUnit}
|
||||
onChange={(value: string) => {
|
||||
@@ -76,7 +105,7 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
legendUnit: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Unit (e.g. ms)"
|
||||
placeholder="e.g. bytes, ms, %"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
@@ -19,6 +21,8 @@ import YAxisType from "Common/UI/Components/Charts/Types/YAxis/YAxisType";
|
||||
import { YAxisPrecision } from "Common/UI/Components/Charts/Types/YAxis/YAxis";
|
||||
import ChartCurve from "Common/UI/Components/Charts/Types/ChartCurve";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import ChartReferenceLineProps from "Common/UI/Components/Charts/Types/ReferenceLineProps";
|
||||
import ValueFormatter from "Common/Utils/ValueFormatter";
|
||||
|
||||
export interface ComponentProps {
|
||||
metricViewData: MetricViewData;
|
||||
@@ -39,7 +43,6 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
props.metricViewData.startAndEndDate?.startValue &&
|
||||
props.metricViewData.startAndEndDate?.endValue
|
||||
) {
|
||||
// if these are less than a day then we can use time
|
||||
const hourDifference: number = OneUptimeDate.getHoursBetweenTwoDates(
|
||||
props.metricViewData.startAndEndDate.startValue as Date,
|
||||
props.metricViewData.startAndEndDate.endValue as Date,
|
||||
@@ -113,10 +116,6 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
const series: ChartSeries = queryConfig.getSeries(item);
|
||||
const seriesName: string = series.title;
|
||||
|
||||
//check if the series already exists if it does then add the data to the existing series
|
||||
|
||||
// if it does not exist then create a new series and add the data to it
|
||||
|
||||
const existingSeries: SeriesPoint | undefined = chartSeries.find(
|
||||
(s: SeriesPoint) => {
|
||||
return s.seriesName === seriesName;
|
||||
@@ -170,6 +169,69 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
chartType = ChartType.AREA;
|
||||
}
|
||||
|
||||
// Resolve the unit for formatting
|
||||
const metricType: MetricType | undefined = props.metricTypes.find(
|
||||
(m: MetricType) => {
|
||||
return m.name === queryConfig.metricQueryData.filterData.metricName;
|
||||
},
|
||||
);
|
||||
const unit: string =
|
||||
queryConfig.metricAliasData?.legendUnit || metricType?.unit || "";
|
||||
|
||||
// Build reference lines from thresholds
|
||||
const referenceLines: Array<ChartReferenceLineProps> = [];
|
||||
|
||||
if (
|
||||
queryConfig.warningThreshold !== undefined &&
|
||||
queryConfig.warningThreshold !== null
|
||||
) {
|
||||
referenceLines.push({
|
||||
value: queryConfig.warningThreshold,
|
||||
label: `Warning: ${ValueFormatter.formatValue(queryConfig.warningThreshold, unit)}`,
|
||||
color: "#f59e0b", // amber
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
queryConfig.criticalThreshold !== undefined &&
|
||||
queryConfig.criticalThreshold !== null
|
||||
) {
|
||||
referenceLines.push({
|
||||
value: queryConfig.criticalThreshold,
|
||||
label: `Critical: ${ValueFormatter.formatValue(queryConfig.criticalThreshold, unit)}`,
|
||||
color: "#ef4444", // red
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -178,6 +240,7 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
queryConfig.metricQueryData.filterData.metricName?.toString() ||
|
||||
"",
|
||||
description: queryConfig.metricAliasData?.description || "",
|
||||
metricInfo,
|
||||
props: {
|
||||
data: chartSeries,
|
||||
xAxis: {
|
||||
@@ -197,8 +260,7 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
// legend is the unit of the metric
|
||||
legend: queryConfig.metricAliasData?.legendUnit || "",
|
||||
legend: unit,
|
||||
options: {
|
||||
type: YAxisType.Number,
|
||||
formatter: (value: number) => {
|
||||
@@ -206,15 +268,7 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
return queryConfig.yAxisValueFormatter(value);
|
||||
}
|
||||
|
||||
const metricType: MetricType | undefined =
|
||||
props.metricTypes.find((m: MetricType) => {
|
||||
return (
|
||||
m.name ===
|
||||
queryConfig.metricQueryData.filterData.metricName
|
||||
);
|
||||
});
|
||||
|
||||
return `${value} ${queryConfig.metricAliasData?.legendUnit || metricType?.unit || ""}`;
|
||||
return ValueFormatter.formatValue(value, unit);
|
||||
},
|
||||
precision: YAxisPrecision.NoDecimals,
|
||||
max: "auto",
|
||||
@@ -223,6 +277,8 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
curve: ChartCurve.MONOTONE,
|
||||
sync: true,
|
||||
referenceLines:
|
||||
referenceLines.length > 0 ? referenceLines : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import MetricAlias from "./MetricAlias";
|
||||
import MetricQuery from "./MetricQuery";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import Button, {
|
||||
ButtonSize,
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import MetricAliasData from "Common/Types/Metrics/MetricAliasData";
|
||||
import MetricQueryData from "Common/Types/Metrics/MetricQueryData";
|
||||
import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import Input, { InputType } from "Common/UI/Components/Input/Input";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
|
||||
export interface ComponentProps {
|
||||
data: MetricQueryConfigData;
|
||||
@@ -34,56 +33,372 @@ export interface ComponentProps {
|
||||
const MetricGraphConfig: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const getContent: GetReactElementFunction = (): ReactElement => {
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||
const [showDisplaySettings, setShowDisplaySettings] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const defaultAliasData: MetricAliasData = {
|
||||
metricVariable: undefined,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
legend: undefined,
|
||||
legendUnit: undefined,
|
||||
};
|
||||
|
||||
// Compute active attribute count for the header summary
|
||||
const attributes: Dictionary<string | number | boolean> | undefined = (
|
||||
props.data?.metricQueryData?.filterData as Record<string, unknown>
|
||||
)?.["attributes"] as Dictionary<string | number | boolean> | undefined;
|
||||
|
||||
const activeAttributeCount: number = attributes
|
||||
? Object.keys(attributes).length
|
||||
: 0;
|
||||
|
||||
const metricName: string =
|
||||
props.data?.metricQueryData?.filterData?.metricName?.toString() ||
|
||||
"No metric selected";
|
||||
|
||||
const aggregationType: string =
|
||||
props.data?.metricQueryData?.filterData?.aggegationType?.toString() ||
|
||||
"Avg";
|
||||
|
||||
// Remove a single attribute filter
|
||||
const handleRemoveAttribute: (key: string) => void = (key: string): void => {
|
||||
if (!attributes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newAttributes: Dictionary<string | number | boolean> = {
|
||||
...attributes,
|
||||
};
|
||||
delete newAttributes[key];
|
||||
|
||||
const newFilterData: Record<string, unknown> = {
|
||||
...(props.data.metricQueryData.filterData as Record<string, unknown>),
|
||||
};
|
||||
|
||||
if (Object.keys(newAttributes).length > 0) {
|
||||
newFilterData["attributes"] = newAttributes;
|
||||
} else {
|
||||
delete newFilterData["attributes"];
|
||||
}
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
metricQueryData: {
|
||||
...props.data.metricQueryData,
|
||||
filterData: newFilterData as MetricQueryData["filterData"],
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all attribute filters
|
||||
const handleClearAllAttributes: () => void = (): void => {
|
||||
const newFilterData: Record<string, unknown> = {
|
||||
...(props.data.metricQueryData.filterData as Record<string, unknown>),
|
||||
};
|
||||
delete newFilterData["attributes"];
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
metricQueryData: {
|
||||
...props.data.metricQueryData,
|
||||
filterData: newFilterData as MetricQueryData["filterData"],
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getHeader: () => ReactElement = (): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
{props.data?.metricAliasData && (
|
||||
<MetricAlias
|
||||
data={props.data?.metricAliasData || {}}
|
||||
onDataChanged={(data: MetricAliasData) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({ ...props.data, metricAliasData: data });
|
||||
}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{/* Variable badge */}
|
||||
{props.data?.metricAliasData?.metricVariable && (
|
||||
<div className="bg-indigo-500 h-8 w-8 min-w-8 rounded-lg flex items-center justify-center text-sm font-bold text-white shadow-sm">
|
||||
{props.data.metricAliasData.metricVariable}
|
||||
</div>
|
||||
)}
|
||||
{/* Summary info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-gray-900 truncate">
|
||||
{metricName}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
|
||||
{aggregationType}
|
||||
</span>
|
||||
{activeAttributeCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-indigo-50 border border-indigo-200 px-2 py-0.5 text-xs font-medium text-indigo-700">
|
||||
<Icon
|
||||
icon={IconProp.Filter}
|
||||
className="h-3 w-3 text-indigo-500"
|
||||
/>
|
||||
{activeAttributeCount}{" "}
|
||||
{activeAttributeCount === 1 ? "filter" : "filters"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{props.data?.metricAliasData?.title &&
|
||||
props.data.metricAliasData.title !== metricName && (
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{props.data.metricAliasData.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 ml-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center h-7 w-7 rounded-md text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
onClick={() => {
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
isFormula={false}
|
||||
/>
|
||||
)}
|
||||
{props.data?.metricQueryData && (
|
||||
<MetricQuery
|
||||
data={props.data?.metricQueryData || {}}
|
||||
onDataChanged={(data: MetricQueryData) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({ ...props.data, metricQueryData: data });
|
||||
}
|
||||
}}
|
||||
metricTypes={props.metricTypes}
|
||||
telemetryAttributes={props.telemetryAttributes}
|
||||
onAdvancedFiltersToggle={props.onAdvancedFiltersToggle}
|
||||
isAttributesLoading={props.attributesLoading}
|
||||
attributesError={props.attributesError}
|
||||
onAttributesRetry={props.onAttributesRetry}
|
||||
/>
|
||||
)}
|
||||
{props.onRemove && (
|
||||
<div className="-ml-3">
|
||||
<Button
|
||||
title={"Remove"}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
<Icon
|
||||
icon={isExpanded ? IconProp.ChevronUp : IconProp.ChevronDown}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
{props.onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center h-7 w-7 rounded-md text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
onClick={() => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
return props.onRemove?.();
|
||||
}}
|
||||
buttonSize={ButtonSize.Small}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
/>
|
||||
title="Remove query"
|
||||
>
|
||||
<Icon icon={IconProp.Trash} className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getAttributeChips: () => ReactElement | null =
|
||||
(): ReactElement | null => {
|
||||
if (!attributes || activeAttributeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-400 font-medium mr-1">
|
||||
Filtered by:
|
||||
</span>
|
||||
{Object.entries(attributes).map(
|
||||
([key, value]: [string, string | number | boolean]) => {
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-indigo-200 bg-indigo-50 py-0.5 pl-2 pr-1 text-xs text-indigo-700"
|
||||
>
|
||||
<span className="font-medium text-indigo-500">{key}:</span>
|
||||
<span>{String(value)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded text-indigo-400 transition-colors hover:bg-indigo-100 hover:text-indigo-600"
|
||||
onClick={() => {
|
||||
handleRemoveAttribute(key);
|
||||
}}
|
||||
title={`Remove ${key}: ${String(value)}`}
|
||||
>
|
||||
<Icon icon={IconProp.Close} className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{activeAttributeCount > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1.5 py-0.5 text-[11px] font-medium text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
onClick={handleClearAllAttributes}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getContent: () => ReactElement = (): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
{/* Header with summary */}
|
||||
{getHeader()}
|
||||
|
||||
{/* Attribute filter chips - always visible */}
|
||||
{!isExpanded && getAttributeChips()}
|
||||
|
||||
{/* Expandable content */}
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Metric query selection */}
|
||||
{props.data?.metricQueryData && (
|
||||
<MetricQuery
|
||||
data={props.data?.metricQueryData || {}}
|
||||
onDataChanged={(data: MetricQueryData) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
const selectedMetricName: string | undefined =
|
||||
data.filterData?.metricName?.toString();
|
||||
const previousMetricName: string | undefined =
|
||||
props.data?.metricQueryData?.filterData?.metricName?.toString();
|
||||
|
||||
// If metric changed, prefill all alias fields from MetricType
|
||||
if (
|
||||
selectedMetricName &&
|
||||
selectedMetricName !== previousMetricName
|
||||
) {
|
||||
const metricType: MetricType | undefined =
|
||||
props.metricTypes.find((m: MetricType) => {
|
||||
return m.name === selectedMetricName;
|
||||
});
|
||||
|
||||
if (metricType) {
|
||||
const currentAlias: MetricAliasData =
|
||||
props.data.metricAliasData || defaultAliasData;
|
||||
|
||||
props.onChange({
|
||||
...props.data,
|
||||
metricQueryData: data,
|
||||
metricAliasData: {
|
||||
...currentAlias,
|
||||
title: metricType.name || "",
|
||||
description: metricType.description || "",
|
||||
legend: metricType.name || "",
|
||||
legendUnit: metricType.unit || "",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
props.onChange({ ...props.data, metricQueryData: data });
|
||||
}
|
||||
}}
|
||||
metricTypes={props.metricTypes}
|
||||
telemetryAttributes={props.telemetryAttributes}
|
||||
onAdvancedFiltersToggle={props.onAdvancedFiltersToggle}
|
||||
isAttributesLoading={props.attributesLoading}
|
||||
attributesError={props.attributesError}
|
||||
onAttributesRetry={props.onAttributesRetry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Attribute filter chips */}
|
||||
{getAttributeChips()}
|
||||
|
||||
{/* Display Settings - collapsible */}
|
||||
<div className="border-t border-gray-200 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs font-medium text-gray-500 uppercase tracking-wide hover:text-gray-700 transition-colors w-full"
|
||||
onClick={() => {
|
||||
setShowDisplaySettings(!showDisplaySettings);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
showDisplaySettings
|
||||
? IconProp.ChevronDown
|
||||
: IconProp.ChevronRight
|
||||
}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<span>Display Settings</span>
|
||||
{(props.data?.metricAliasData?.title ||
|
||||
props.data?.warningThreshold !== undefined ||
|
||||
props.data?.criticalThreshold !== undefined) && (
|
||||
<span className="inline-flex h-1.5 w-1.5 rounded-full bg-indigo-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showDisplaySettings && (
|
||||
<div className="mt-3 space-y-4">
|
||||
<MetricAlias
|
||||
data={props.data?.metricAliasData || defaultAliasData}
|
||||
onDataChanged={(data: MetricAliasData) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
metricAliasData: data,
|
||||
});
|
||||
}
|
||||
}}
|
||||
isFormula={false}
|
||||
hideVariableBadge={true}
|
||||
/>
|
||||
|
||||
{/* Thresholds */}
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Warning Threshold
|
||||
</label>
|
||||
<Input
|
||||
value={props.data?.warningThreshold?.toString() || ""}
|
||||
type={InputType.NUMBER}
|
||||
onChange={(value: string) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
warningThreshold: value
|
||||
? Number(value)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. 80"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Critical Threshold
|
||||
</label>
|
||||
<Input
|
||||
value={props.data?.criticalThreshold?.toString() || ""}
|
||||
type={InputType.NUMBER}
|
||||
onChange={(value: string) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
criticalThreshold: value
|
||||
? Number(value)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. 95"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.error && (
|
||||
<p data-testid="error-message" className="mt-1 text-sm text-red-400">
|
||||
<p data-testid="error-message" className="mt-3 text-sm text-red-400">
|
||||
{props.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -12,13 +12,11 @@ import Button, {
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import Text from "Common/Types/Text";
|
||||
import HorizontalRule from "Common/UI/Components/HorizontalRule/HorizontalRule";
|
||||
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
|
||||
import StartAndEndDate, {
|
||||
StartAndEndDateType,
|
||||
} from "Common/UI/Components/Date/StartAndEndDate";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import FieldLabelElement from "Common/UI/Components/Forms/Fields/FieldLabel";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
@@ -34,6 +32,7 @@ import MetricCharts from "./MetricCharts";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
|
||||
const getFetchRelevantState: (data: MetricViewData) => unknown = (
|
||||
data: MetricViewData,
|
||||
@@ -305,29 +304,33 @@ const MetricView: FunctionComponent<ComponentProps> = (
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{/* Time range selector */}
|
||||
{!props.hideStartAndEndDate && (
|
||||
<div className="mb-5">
|
||||
<Card>
|
||||
<div className="-mt-5">
|
||||
<FieldLabelElement title="Start and End Time" required={true} />
|
||||
<StartAndEndDate
|
||||
type={StartAndEndDateType.DateTime}
|
||||
value={props.data.startAndEndDate || undefined}
|
||||
onValueChanged={(startAndEndDate: InBetween<Date> | null) => {
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
startAndEndDate: startAndEndDate,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Card>
|
||||
<div className="-mt-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Time Range
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<StartAndEndDate
|
||||
type={StartAndEndDateType.DateTime}
|
||||
value={props.data.startAndEndDate || undefined}
|
||||
onValueChanged={(startAndEndDate: InBetween<Date> | null) => {
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
startAndEndDate: startAndEndDate,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Query configs */}
|
||||
{!props.hideQueryElements && (
|
||||
<div className="space-y-3">
|
||||
{props.data.queryConfigs.map(
|
||||
@@ -382,104 +385,89 @@ const MetricView: FunctionComponent<ComponentProps> = (
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!props.hideQueryElements && (
|
||||
<div className="space-y-3">
|
||||
{/* Formula configs and Add buttons */}
|
||||
{!props.hideQueryElements && (
|
||||
<div className="space-y-3">
|
||||
{props.data.formulaConfigs.map(
|
||||
(formulaConfig: MetricFormulaConfigData, index: number) => {
|
||||
return (
|
||||
<MetricGraphConfig
|
||||
key={index}
|
||||
onDataChanged={(data: MetricFormulaConfigData) => {
|
||||
const newGraphConfigs: Array<MetricFormulaConfigData> = [
|
||||
...props.data.formulaConfigs,
|
||||
];
|
||||
newGraphConfigs[index] = data;
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
formulaConfigs: newGraphConfigs,
|
||||
});
|
||||
}
|
||||
}}
|
||||
data={formulaConfig}
|
||||
onRemove={() => {
|
||||
const newGraphConfigs: Array<MetricFormulaConfigData> = [
|
||||
...props.data.formulaConfigs,
|
||||
];
|
||||
newGraphConfigs.splice(index, 1);
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
formulaConfigs: newGraphConfigs,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex -ml-3 mt-8 justify-between w-full">
|
||||
<div>
|
||||
<Button
|
||||
title="Add Metric"
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
queryConfigs: [
|
||||
...props.data.queryConfigs,
|
||||
getEmptyQueryConfigData(),
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* <Button
|
||||
title="Add Formula"
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
setMetricViewData({
|
||||
...metricViewData,
|
||||
formulaConfigs: [
|
||||
...metricViewData.formulaConfigs,
|
||||
getEmptyFormulaConfigData(),
|
||||
],
|
||||
});
|
||||
}}
|
||||
/> */}
|
||||
{props.data.formulaConfigs.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{props.data.formulaConfigs.map(
|
||||
(formulaConfig: MetricFormulaConfigData, index: number) => {
|
||||
return (
|
||||
<MetricGraphConfig
|
||||
key={index}
|
||||
onDataChanged={(data: MetricFormulaConfigData) => {
|
||||
const newGraphConfigs: Array<MetricFormulaConfigData> =
|
||||
[...props.data.formulaConfigs];
|
||||
newGraphConfigs[index] = data;
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
formulaConfigs: newGraphConfigs,
|
||||
});
|
||||
}
|
||||
}}
|
||||
data={formulaConfig}
|
||||
onRemove={() => {
|
||||
const newGraphConfigs: Array<MetricFormulaConfigData> =
|
||||
[...props.data.formulaConfigs];
|
||||
newGraphConfigs.splice(index, 1);
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
formulaConfigs: newGraphConfigs,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add metric button */}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
title="Add Metric"
|
||||
buttonSize={ButtonSize.Small}
|
||||
buttonStyle={ButtonStyleType.OUTLINE}
|
||||
icon={IconProp.Add}
|
||||
onClick={() => {
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
queryConfigs: [
|
||||
...props.data.queryConfigs,
|
||||
getEmptyQueryConfigData(),
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalRule />
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{isMetricResultsLoading && <ComponentLoader />}
|
||||
{/* Chart results */}
|
||||
{isMetricResultsLoading && <ComponentLoader />}
|
||||
|
||||
{metricResultsError && <ErrorMessage message={metricResultsError} />}
|
||||
{metricResultsError && <ErrorMessage message={metricResultsError} />}
|
||||
|
||||
{!isMetricResultsLoading && !metricResultsError && (
|
||||
<div
|
||||
className={
|
||||
props.hideCardInCharts ? "" : "grid grid-cols-1 gap-4 mt-3"
|
||||
}
|
||||
>
|
||||
{/** charts */}
|
||||
<MetricCharts
|
||||
hideCard={props.hideCardInCharts}
|
||||
metricResults={metricResults}
|
||||
metricTypes={metricTypes}
|
||||
metricViewData={props.data}
|
||||
chartCssClass={props.chartCssClass}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isMetricResultsLoading && !metricResultsError && (
|
||||
<div
|
||||
className={props.hideCardInCharts ? "" : "grid grid-cols-1 gap-4"}
|
||||
>
|
||||
<MetricCharts
|
||||
hideCard={props.hideCardInCharts}
|
||||
metricResults={metricResults}
|
||||
metricTypes={metricTypes}
|
||||
metricViewData={props.data}
|
||||
chartCssClass={props.chartCssClass}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCannotRemoveOneRemainingQueryError ? (
|
||||
<ConfirmModal
|
||||
|
||||
@@ -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,16 +117,25 @@ 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,
|
||||
iconColor: "yellow",
|
||||
category: "Observability",
|
||||
},
|
||||
{
|
||||
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,
|
||||
iconColor: "red",
|
||||
category: "Observability",
|
||||
},
|
||||
{
|
||||
title: "Exceptions",
|
||||
description: "Catch and fix bugs early.",
|
||||
description: "Track and resolve bugs across your services.",
|
||||
route: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS] as Route,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
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 ObjectID from "Common/Types/ObjectID";
|
||||
|
||||
export interface DiffFlamegraphProps {
|
||||
baselineStartTime: Date;
|
||||
baselineEndTime: Date;
|
||||
comparisonStartTime: Date;
|
||||
comparisonEndTime: Date;
|
||||
serviceIds?: Array<ObjectID> | undefined;
|
||||
profileType?: string | undefined;
|
||||
}
|
||||
|
||||
interface DiffFlamegraphNode {
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
baselineValue: number;
|
||||
comparisonValue: number;
|
||||
delta: number;
|
||||
deltaPercent: number;
|
||||
selfBaselineValue: number;
|
||||
selfComparisonValue: number;
|
||||
selfDelta: number;
|
||||
children: DiffFlamegraphNode[];
|
||||
frameType: string;
|
||||
}
|
||||
|
||||
interface TooltipData {
|
||||
name: string;
|
||||
fileName: string;
|
||||
baselineValue: number;
|
||||
comparisonValue: number;
|
||||
delta: number;
|
||||
deltaPercent: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const DiffFlamegraph: FunctionComponent<DiffFlamegraphProps> = (
|
||||
props: DiffFlamegraphProps,
|
||||
): ReactElement => {
|
||||
const [rootNode, setRootNode] = useState<DiffFlamegraphNode | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [zoomStack, setZoomStack] = useState<Array<DiffFlamegraphNode>>([]);
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||
|
||||
const loadDiffFlamegraph: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
"/telemetry/profiles/diff-flamegraph",
|
||||
),
|
||||
data: {
|
||||
baselineStartTime: props.baselineStartTime.toISOString(),
|
||||
baselineEndTime: props.baselineEndTime.toISOString(),
|
||||
comparisonStartTime: props.comparisonStartTime.toISOString(),
|
||||
comparisonEndTime: props.comparisonEndTime.toISOString(),
|
||||
serviceIds: props.serviceIds?.map((id: ObjectID) => {
|
||||
return id.toString();
|
||||
}),
|
||||
profileType: props.profileType,
|
||||
},
|
||||
headers: {
|
||||
...ModelAPI.getCommonHeaders(),
|
||||
},
|
||||
});
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
const data: DiffFlamegraphNode = response.data[
|
||||
"diffFlamegraph"
|
||||
] as unknown as DiffFlamegraphNode;
|
||||
setRootNode(data);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDiffFlamegraph();
|
||||
}, [
|
||||
props.baselineStartTime,
|
||||
props.baselineEndTime,
|
||||
props.comparisonStartTime,
|
||||
props.comparisonEndTime,
|
||||
props.serviceIds,
|
||||
props.profileType,
|
||||
]);
|
||||
|
||||
const activeRoot: DiffFlamegraphNode | null = useMemo(() => {
|
||||
if (zoomStack.length > 0) {
|
||||
return zoomStack[zoomStack.length - 1]!;
|
||||
}
|
||||
return rootNode;
|
||||
}, [rootNode, zoomStack]);
|
||||
|
||||
const handleClickNode: (node: DiffFlamegraphNode) => void = useCallback(
|
||||
(node: DiffFlamegraphNode): void => {
|
||||
if (node.children.length > 0) {
|
||||
setZoomStack((prev: Array<DiffFlamegraphNode>) => {
|
||||
return [...prev, node];
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleZoomOut: () => void = useCallback((): void => {
|
||||
setZoomStack((prev: Array<DiffFlamegraphNode>) => {
|
||||
return prev.slice(0, prev.length - 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleResetZoom: () => void = useCallback((): void => {
|
||||
setZoomStack([]);
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter: (
|
||||
node: DiffFlamegraphNode,
|
||||
event: React.MouseEvent,
|
||||
) => void = useCallback(
|
||||
(node: DiffFlamegraphNode, event: React.MouseEvent): void => {
|
||||
setTooltip({
|
||||
name: node.functionName,
|
||||
fileName: node.fileName,
|
||||
baselineValue: node.baselineValue,
|
||||
comparisonValue: node.comparisonValue,
|
||||
delta: node.delta,
|
||||
deltaPercent: node.deltaPercent,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave: () => void = useCallback((): void => {
|
||||
setTooltip(null);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadDiffFlamegraph();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!activeRoot ||
|
||||
(activeRoot.baselineValue === 0 && activeRoot.comparisonValue === 0)
|
||||
) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No performance data found in the selected time ranges. Try adjusting the
|
||||
time periods.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getDeltaColor: (deltaPercent: number) => string = (
|
||||
deltaPercent: number,
|
||||
): string => {
|
||||
if (deltaPercent > 50) {
|
||||
return "bg-red-600";
|
||||
}
|
||||
if (deltaPercent > 20) {
|
||||
return "bg-red-500";
|
||||
}
|
||||
if (deltaPercent > 5) {
|
||||
return "bg-red-400";
|
||||
}
|
||||
if (deltaPercent > 0) {
|
||||
return "bg-red-300";
|
||||
}
|
||||
if (deltaPercent < -50) {
|
||||
return "bg-green-600";
|
||||
}
|
||||
if (deltaPercent < -20) {
|
||||
return "bg-green-500";
|
||||
}
|
||||
if (deltaPercent < -5) {
|
||||
return "bg-green-400";
|
||||
}
|
||||
if (deltaPercent < 0) {
|
||||
return "bg-green-300";
|
||||
}
|
||||
return "bg-gray-400";
|
||||
};
|
||||
|
||||
const renderNode: (
|
||||
node: DiffFlamegraphNode,
|
||||
_parentMax: number,
|
||||
depth: number,
|
||||
offsetFraction: number,
|
||||
widthFraction: number,
|
||||
) => ReactElement | null = (
|
||||
node: DiffFlamegraphNode,
|
||||
_parentMax: number,
|
||||
depth: number,
|
||||
offsetFraction: number,
|
||||
widthFraction: number,
|
||||
): ReactElement | null => {
|
||||
if (widthFraction < 0.005) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bgColor: string = getDeltaColor(node.deltaPercent);
|
||||
const maxValue: number = Math.max(node.baselineValue, node.comparisonValue);
|
||||
|
||||
let childOffset: number = 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${node.functionName}-${depth}-${offsetFraction}`}>
|
||||
<div
|
||||
className={`absolute h-6 border border-white/30 cursor-pointer overflow-hidden text-xs text-white leading-6 px-1 truncate ${bgColor} hover:opacity-80`}
|
||||
style={{
|
||||
left: `${offsetFraction * 100}%`,
|
||||
width: `${widthFraction * 100}%`,
|
||||
top: `${depth * 26}px`,
|
||||
}}
|
||||
onClick={() => {
|
||||
handleClickNode(node);
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent) => {
|
||||
handleMouseEnter(node, e);
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title={`${node.functionName} (${node.deltaPercent >= 0 ? "+" : ""}${node.deltaPercent.toFixed(1)}%)`}
|
||||
>
|
||||
{widthFraction > 0.03 ? node.functionName : ""}
|
||||
</div>
|
||||
{node.children.map((child: DiffFlamegraphNode) => {
|
||||
const childMax: number = Math.max(
|
||||
child.baselineValue,
|
||||
child.comparisonValue,
|
||||
);
|
||||
const childWidth: number =
|
||||
maxValue > 0 ? (childMax / maxValue) * widthFraction : 0;
|
||||
const currentOffset: number = offsetFraction + childOffset;
|
||||
childOffset += childWidth;
|
||||
|
||||
return renderNode(
|
||||
child,
|
||||
maxValue,
|
||||
depth + 1,
|
||||
currentOffset,
|
||||
childWidth,
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const getMaxDepth: (node: DiffFlamegraphNode, depth: number) => number = (
|
||||
node: DiffFlamegraphNode,
|
||||
depth: number,
|
||||
): number => {
|
||||
let max: number = depth;
|
||||
for (const child of node.children) {
|
||||
const childDepth: number = getMaxDepth(child, depth + 1);
|
||||
if (childDepth > max) {
|
||||
max = childDepth;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
const maxDepth: number = getMaxDepth(activeRoot, 0);
|
||||
const height: number = (maxDepth + 1) * 26 + 10;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{zoomStack.length > 0 && (
|
||||
<div className="mb-3 flex items-center space-x-2">
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-300"
|
||||
onClick={handleZoomOut}
|
||||
>
|
||||
Zoom Out
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-300"
|
||||
onClick={handleResetZoom}
|
||||
>
|
||||
Reset Zoom
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Zoomed into: {activeRoot.functionName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center space-x-4 text-xs text-gray-600">
|
||||
<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>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>Got faster</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className="inline-block w-3 h-3 rounded bg-gray-400" />
|
||||
<span>No change</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative w-full overflow-x-auto border border-gray-200 rounded bg-white"
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
{renderNode(
|
||||
activeRoot,
|
||||
Math.max(activeRoot.baselineValue, activeRoot.comparisonValue),
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
className="fixed z-50 bg-gray-900 text-white text-xs rounded px-3 py-2 pointer-events-none shadow-lg"
|
||||
style={{
|
||||
left: `${tooltip.x + 12}px`,
|
||||
top: `${tooltip.y + 12}px`,
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold">{tooltip.name}</div>
|
||||
{tooltip.fileName && (
|
||||
<div className="text-gray-300">{tooltip.fileName}</div>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
Before: {tooltip.baselineValue.toLocaleString()}
|
||||
</div>
|
||||
<div>After: {tooltip.comparisonValue.toLocaleString()}</div>
|
||||
<div
|
||||
className={
|
||||
tooltip.delta > 0
|
||||
? "text-red-300"
|
||||
: tooltip.delta < 0
|
||||
? "text-green-300"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
Change: {tooltip.delta > 0 ? "+" : ""}
|
||||
{tooltip.delta.toLocaleString()} (
|
||||
{tooltip.deltaPercent >= 0 ? "+" : ""}
|
||||
{tooltip.deltaPercent.toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffFlamegraph;
|
||||
@@ -0,0 +1,379 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import ProfileSample from "Common/Models/AnalyticsModels/ProfileSample";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
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 ProfileUtil, { ParsedStackFrame } from "../../Utils/ProfileUtil";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
|
||||
export interface ProfileFlamegraphProps {
|
||||
profileId: string;
|
||||
profileType?: string | undefined;
|
||||
}
|
||||
|
||||
interface FlamegraphNode {
|
||||
name: string;
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
frameType: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
children: Map<string, FlamegraphNode>;
|
||||
}
|
||||
|
||||
interface TooltipData {
|
||||
name: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const ProfileFlamegraph: FunctionComponent<ProfileFlamegraphProps> = (
|
||||
props: ProfileFlamegraphProps,
|
||||
): ReactElement => {
|
||||
const [samples, setSamples] = useState<Array<ProfileSample>>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [zoomStack, setZoomStack] = useState<Array<FlamegraphNode>>([]);
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||
|
||||
const loadSamples: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const result: ListResult<ProfileSample> = await AnalyticsModelAPI.getList(
|
||||
{
|
||||
modelType: ProfileSample,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
profileId: props.profileId,
|
||||
...(props.profileType ? { profileType: props.profileType } : {}),
|
||||
},
|
||||
select: {
|
||||
stacktrace: true,
|
||||
frameTypes: true,
|
||||
value: true,
|
||||
profileType: true,
|
||||
},
|
||||
limit: 10000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
value: SortOrder.Descending,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setSamples(result.data || []);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSamples();
|
||||
}, [props.profileId, props.profileType]);
|
||||
|
||||
const rootNode: FlamegraphNode = useMemo(() => {
|
||||
const root: FlamegraphNode = {
|
||||
name: "root",
|
||||
fileName: "",
|
||||
lineNumber: 0,
|
||||
frameType: "",
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
children: new Map<string, FlamegraphNode>(),
|
||||
};
|
||||
|
||||
for (const sample of samples) {
|
||||
const stacktrace: Array<string> = sample.stacktrace || [];
|
||||
const frameTypes: Array<string> = sample.frameTypes || [];
|
||||
const value: number = sample.value || 0;
|
||||
|
||||
let currentNode: FlamegraphNode = root;
|
||||
root.totalValue += value;
|
||||
|
||||
// Walk from root to leaf (stacktrace is ordered root-to-leaf)
|
||||
for (let i: number = 0; i < stacktrace.length; i++) {
|
||||
const frame: string = stacktrace[i]!;
|
||||
const frameType: string =
|
||||
i < frameTypes.length ? frameTypes[i]! : "unknown";
|
||||
|
||||
let child: FlamegraphNode | undefined = currentNode.children.get(frame);
|
||||
|
||||
if (!child) {
|
||||
const parsed: ParsedStackFrame = ProfileUtil.parseStackFrame(frame);
|
||||
child = {
|
||||
name: parsed.functionName,
|
||||
fileName: parsed.fileName,
|
||||
lineNumber: parsed.lineNumber,
|
||||
frameType,
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
children: new Map<string, FlamegraphNode>(),
|
||||
};
|
||||
currentNode.children.set(frame, child);
|
||||
}
|
||||
|
||||
child.totalValue += value;
|
||||
|
||||
// Last frame in the stack is the leaf -- add self value
|
||||
if (i === stacktrace.length - 1) {
|
||||
child.selfValue += value;
|
||||
}
|
||||
|
||||
currentNode = child;
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}, [samples]);
|
||||
|
||||
const activeRoot: FlamegraphNode = useMemo(() => {
|
||||
if (zoomStack.length > 0) {
|
||||
return zoomStack[zoomStack.length - 1]!;
|
||||
}
|
||||
return rootNode;
|
||||
}, [rootNode, zoomStack]);
|
||||
|
||||
const handleClickNode: (node: FlamegraphNode) => void = useCallback(
|
||||
(node: FlamegraphNode): void => {
|
||||
if (node.children.size > 0) {
|
||||
setZoomStack((prev: Array<FlamegraphNode>) => {
|
||||
return [...prev, node];
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleZoomOut: () => void = useCallback((): void => {
|
||||
setZoomStack((prev: Array<FlamegraphNode>) => {
|
||||
return prev.slice(0, prev.length - 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleResetZoom: () => void = useCallback((): void => {
|
||||
setZoomStack([]);
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter: (
|
||||
node: FlamegraphNode,
|
||||
event: React.MouseEvent,
|
||||
) => void = useCallback(
|
||||
(node: FlamegraphNode, event: React.MouseEvent): void => {
|
||||
setTooltip({
|
||||
name: node.name,
|
||||
fileName: node.fileName,
|
||||
selfValue: node.selfValue,
|
||||
totalValue: node.totalValue,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave: () => void = useCallback((): void => {
|
||||
setTooltip(null);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadSamples();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (samples.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No performance data found for this profile. This can happen if the
|
||||
profile was recently captured and data is still being processed.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderNode: (
|
||||
node: FlamegraphNode,
|
||||
parentTotal: number,
|
||||
depth: number,
|
||||
offsetFraction: number,
|
||||
widthFraction: number,
|
||||
) => ReactElement | null = (
|
||||
node: FlamegraphNode,
|
||||
parentTotal: number,
|
||||
depth: number,
|
||||
offsetFraction: number,
|
||||
widthFraction: number,
|
||||
): ReactElement | null => {
|
||||
if (widthFraction < 0.005) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bgColor: string = ProfileUtil.getFrameTypeColor(node.frameType);
|
||||
const percentage: number =
|
||||
parentTotal > 0 ? (node.totalValue / parentTotal) * 100 : 0;
|
||||
|
||||
const children: Array<FlamegraphNode> = Array.from(
|
||||
node.children.values(),
|
||||
).sort((a: FlamegraphNode, b: FlamegraphNode) => {
|
||||
return b.totalValue - a.totalValue;
|
||||
});
|
||||
|
||||
let childOffset: number = 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${node.name}-${depth}-${offsetFraction}`}>
|
||||
<div
|
||||
className={`absolute h-6 border border-white/30 cursor-pointer overflow-hidden text-xs text-white leading-6 px-1 truncate ${bgColor} hover:opacity-80`}
|
||||
style={{
|
||||
left: `${offsetFraction * 100}%`,
|
||||
width: `${widthFraction * 100}%`,
|
||||
top: `${depth * 26}px`,
|
||||
}}
|
||||
onClick={() => {
|
||||
handleClickNode(node);
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent) => {
|
||||
handleMouseEnter(node, e);
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title={`${node.name} (${percentage.toFixed(1)}%)`}
|
||||
>
|
||||
{widthFraction > 0.03 ? node.name : ""}
|
||||
</div>
|
||||
{children.map((child: FlamegraphNode) => {
|
||||
const childWidth: number =
|
||||
node.totalValue > 0
|
||||
? (child.totalValue / node.totalValue) * widthFraction
|
||||
: 0;
|
||||
const currentOffset: number = offsetFraction + childOffset;
|
||||
childOffset += childWidth;
|
||||
|
||||
return renderNode(
|
||||
child,
|
||||
node.totalValue,
|
||||
depth + 1,
|
||||
currentOffset,
|
||||
childWidth,
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const getMaxDepth: (node: FlamegraphNode, depth: number) => number = (
|
||||
node: FlamegraphNode,
|
||||
depth: number,
|
||||
): number => {
|
||||
let max: number = depth;
|
||||
for (const child of node.children.values()) {
|
||||
const childDepth: number = getMaxDepth(child, depth + 1);
|
||||
if (childDepth > max) {
|
||||
max = childDepth;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
const maxDepth: number = getMaxDepth(activeRoot, 0);
|
||||
const height: number = (maxDepth + 1) * 26 + 10;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{zoomStack.length > 0 && (
|
||||
<div className="mb-3 flex items-center space-x-2">
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-300"
|
||||
onClick={handleZoomOut}
|
||||
>
|
||||
Zoom Out
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-300"
|
||||
onClick={handleResetZoom}
|
||||
>
|
||||
Reset Zoom
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Zoomed into: {activeRoot.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center space-x-4 text-xs text-gray-600">
|
||||
<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
|
||||
className="relative w-full overflow-x-auto border border-gray-200 rounded bg-white"
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
{renderNode(activeRoot, activeRoot.totalValue, 0, 0, 1)}
|
||||
</div>
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
className="fixed z-50 bg-gray-900 text-white text-xs rounded px-3 py-2 pointer-events-none shadow-lg"
|
||||
style={{
|
||||
left: `${tooltip.x + 12}px`,
|
||||
top: `${tooltip.y + 12}px`,
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold">{tooltip.name}</div>
|
||||
{tooltip.fileName && (
|
||||
<div className="text-gray-300">{tooltip.fileName}</div>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
Own Time: {tooltip.selfValue.toLocaleString()}
|
||||
</div>
|
||||
<div>Total Time: {tooltip.totalValue.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileFlamegraph;
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import ProfileSample from "Common/Models/AnalyticsModels/ProfileSample";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
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 ProfileUtil, { ParsedStackFrame } from "../../Utils/ProfileUtil";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
|
||||
export interface ProfileFunctionListProps {
|
||||
profileId: string;
|
||||
profileType?: string | undefined;
|
||||
}
|
||||
|
||||
interface FunctionRow {
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
type SortField =
|
||||
| "functionName"
|
||||
| "fileName"
|
||||
| "selfValue"
|
||||
| "totalValue"
|
||||
| "sampleCount";
|
||||
|
||||
const ProfileFunctionList: FunctionComponent<ProfileFunctionListProps> = (
|
||||
props: ProfileFunctionListProps,
|
||||
): ReactElement => {
|
||||
const [samples, setSamples] = useState<Array<ProfileSample>>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [sortField, setSortField] = useState<SortField>("selfValue");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const loadSamples: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const result: ListResult<ProfileSample> = await AnalyticsModelAPI.getList(
|
||||
{
|
||||
modelType: ProfileSample,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
profileId: props.profileId,
|
||||
...(props.profileType ? { profileType: props.profileType } : {}),
|
||||
},
|
||||
select: {
|
||||
stacktrace: true,
|
||||
frameTypes: true,
|
||||
value: true,
|
||||
profileType: true,
|
||||
},
|
||||
limit: 10000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
value: SortOrder.Descending,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setSamples(result.data || []);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSamples();
|
||||
}, [props.profileId, props.profileType]);
|
||||
|
||||
const functionRows: Array<FunctionRow> = useMemo(() => {
|
||||
const functionMap: Map<
|
||||
string,
|
||||
{
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
for (const sample of samples) {
|
||||
const stacktrace: Array<string> = sample.stacktrace || [];
|
||||
const value: number = sample.value || 0;
|
||||
|
||||
const seenInThisSample: Set<string> = new Set<string>();
|
||||
|
||||
for (let i: number = 0; i < stacktrace.length; i++) {
|
||||
const frame: string = stacktrace[i]!;
|
||||
const parsed: ParsedStackFrame = ProfileUtil.parseStackFrame(frame);
|
||||
const key: string = `${parsed.functionName}@${parsed.fileName}`;
|
||||
|
||||
let entry: FunctionRow | undefined = functionMap.get(key);
|
||||
|
||||
if (!entry) {
|
||||
entry = {
|
||||
functionName: parsed.functionName,
|
||||
fileName: parsed.fileName,
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
sampleCount: 0,
|
||||
};
|
||||
functionMap.set(key, entry);
|
||||
}
|
||||
|
||||
// Only add total value once per sample (avoid double-counting recursive calls)
|
||||
if (!seenInThisSample.has(key)) {
|
||||
entry.totalValue += value;
|
||||
entry.sampleCount += 1;
|
||||
seenInThisSample.add(key);
|
||||
}
|
||||
|
||||
// Self value is only for the leaf frame
|
||||
if (i === stacktrace.length - 1) {
|
||||
entry.selfValue += value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(functionMap.values());
|
||||
}, [samples]);
|
||||
|
||||
const sortedRows: Array<FunctionRow> = useMemo(() => {
|
||||
const rows: Array<FunctionRow> = [...functionRows];
|
||||
|
||||
rows.sort((a: FunctionRow, b: FunctionRow) => {
|
||||
let aVal: string | number = a[sortField];
|
||||
let bVal: string | number = b[sortField];
|
||||
|
||||
if (typeof aVal === "string") {
|
||||
aVal = aVal.toLowerCase();
|
||||
bVal = (bVal as string).toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) {
|
||||
return sortDirection === "asc" ? -1 : 1;
|
||||
}
|
||||
if (aVal > bVal) {
|
||||
return sortDirection === "asc" ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}, [functionRows, sortField, sortDirection]);
|
||||
|
||||
const handleSort: (field: SortField) => void = useCallback(
|
||||
(field: SortField): void => {
|
||||
if (field === sortField) {
|
||||
setSortDirection((prev: "asc" | "desc") => {
|
||||
return prev === "asc" ? "desc" : "asc";
|
||||
});
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("desc");
|
||||
}
|
||||
},
|
||||
[sortField],
|
||||
);
|
||||
|
||||
const getSortIndicator: (field: SortField) => string = useCallback(
|
||||
(field: SortField): string => {
|
||||
if (field !== sortField) {
|
||||
return "";
|
||||
}
|
||||
return sortDirection === "asc" ? " \u2191" : " \u2193";
|
||||
},
|
||||
[sortField, sortDirection],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadSamples();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (samples.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No performance data found for this profile.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full text-sm text-left border border-gray-200 rounded">
|
||||
<thead className="bg-gray-50 text-gray-700 font-medium">
|
||||
<tr>
|
||||
<th
|
||||
className="px-4 py-3 cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("functionName");
|
||||
}}
|
||||
>
|
||||
Function{getSortIndicator("functionName")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("fileName");
|
||||
}}
|
||||
>
|
||||
Source File{getSortIndicator("fileName")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("selfValue");
|
||||
}}
|
||||
>
|
||||
Own Time{getSortIndicator("selfValue")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("totalValue");
|
||||
}}
|
||||
>
|
||||
Total Time{getSortIndicator("totalValue")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("sampleCount");
|
||||
}}
|
||||
>
|
||||
Occurrences{getSortIndicator("sampleCount")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row: FunctionRow, index: number) => {
|
||||
return (
|
||||
<tr
|
||||
key={`${row.functionName}-${row.fileName}-${index}`}
|
||||
className="border-t border-gray-200 hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-4 py-2 font-mono text-xs truncate max-w-xs">
|
||||
{row.functionName}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500 text-xs truncate max-w-xs">
|
||||
{row.fileName || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-xs">
|
||||
{row.selfValue.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-xs">
|
||||
{row.totalValue.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-xs">
|
||||
{row.sampleCount.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileFunctionList;
|
||||
@@ -0,0 +1,350 @@
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import AnalyticsModelTable from "Common/UI/Components/ModelTable/AnalyticsModelTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Profile from "Common/Models/AnalyticsModels/Profile";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import API from "Common/Utils/API";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
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;
|
||||
profileQuery?: Query<Profile> | undefined;
|
||||
isMinimalTable?: boolean | undefined;
|
||||
noItemsMessage?: string | undefined;
|
||||
}
|
||||
|
||||
const ProfileTable: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const modelId: ObjectID | undefined = props.modelId;
|
||||
|
||||
const [attributes, setAttributes] = React.useState<Array<string>>([]);
|
||||
const [attributesLoaded, setAttributesLoaded] =
|
||||
React.useState<boolean>(false);
|
||||
const [attributesLoading, setAttributesLoading] =
|
||||
React.useState<boolean>(false);
|
||||
const [attributesError, setAttributesError] = React.useState<string>("");
|
||||
|
||||
const [isPageLoading, setIsPageLoading] = React.useState<boolean>(true);
|
||||
const [pageError, setPageError] = React.useState<string>("");
|
||||
|
||||
const [telemetryServices, setServices] = React.useState<Array<Service>>([]);
|
||||
|
||||
const [areAdvancedFiltersVisible, setAreAdvancedFiltersVisible] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const query: Query<Profile> = React.useMemo(() => {
|
||||
const baseQuery: Query<Profile> = {
|
||||
...(props.profileQuery || {}),
|
||||
};
|
||||
|
||||
const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
|
||||
|
||||
if (projectId) {
|
||||
baseQuery.projectId = projectId;
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
baseQuery.serviceId = modelId;
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}, [props.profileQuery, modelId]);
|
||||
|
||||
const loadServices: PromiseVoidFunction = async (): Promise<void> => {
|
||||
try {
|
||||
setIsPageLoading(true);
|
||||
setPageError("");
|
||||
|
||||
const telemetryServicesResponse: ListResult<Service> =
|
||||
await ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
});
|
||||
|
||||
setServices(telemetryServicesResponse.data || []);
|
||||
} catch (err) {
|
||||
setPageError(API.getFriendlyErrorMessage(err as Error));
|
||||
} finally {
|
||||
setIsPageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAttributes: PromiseVoidFunction = async (): Promise<void> => {
|
||||
if (attributesLoading || attributesLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAttributesLoading(true);
|
||||
setAttributesError("");
|
||||
|
||||
const attributeResponse: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
"/telemetry/profiles/get-attributes",
|
||||
),
|
||||
data: {},
|
||||
headers: {
|
||||
...ModelAPI.getCommonHeaders(),
|
||||
},
|
||||
});
|
||||
|
||||
if (attributeResponse instanceof HTTPErrorResponse) {
|
||||
throw attributeResponse;
|
||||
}
|
||||
|
||||
const fetchedAttributes: Array<string> = (attributeResponse.data[
|
||||
"attributes"
|
||||
] || []) as Array<string>;
|
||||
setAttributes(fetchedAttributes);
|
||||
setAttributesLoaded(true);
|
||||
} catch (err) {
|
||||
setAttributes([]);
|
||||
setAttributesLoaded(false);
|
||||
setAttributesError(API.getFriendlyErrorMessage(err as Error));
|
||||
} finally {
|
||||
setAttributesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadServices().catch((err: Error) => {
|
||||
setPageError(API.getFriendlyErrorMessage(err as Error));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAdvancedFiltersToggle: (show: boolean) => void = (
|
||||
show: boolean,
|
||||
): void => {
|
||||
setAreAdvancedFiltersVisible(show);
|
||||
|
||||
if (show && !attributesLoaded && !attributesLoading) {
|
||||
void loadAttributes();
|
||||
}
|
||||
};
|
||||
|
||||
if (isPageLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{pageError && (
|
||||
<div className="mb-4">
|
||||
<ErrorMessage
|
||||
message={`We couldn't load telemetry services. ${pageError}`}
|
||||
onRefreshClick={() => {
|
||||
void loadServices();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{areAdvancedFiltersVisible && attributesError && (
|
||||
<div className="mb-4">
|
||||
<ErrorMessage
|
||||
message={`We couldn't load profile attributes. ${attributesError}`}
|
||||
onRefreshClick={() => {
|
||||
setAttributesLoaded(false);
|
||||
void loadAttributes();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded">
|
||||
<AnalyticsModelTable<Profile>
|
||||
userPreferencesKey="profile-table"
|
||||
disablePagination={props.isMinimalTable}
|
||||
modelType={Profile}
|
||||
id="profiles-table"
|
||||
isDeleteable={false}
|
||||
isEditable={false}
|
||||
isCreateable={false}
|
||||
singularName="Performance Profile"
|
||||
pluralName="Performance Profiles"
|
||||
name="Performance Profiles"
|
||||
isViewable={true}
|
||||
cardProps={
|
||||
props.isMinimalTable
|
||||
? undefined
|
||||
: {
|
||||
title: "Performance Profiles",
|
||||
description:
|
||||
"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 performance profiles found. Once your services start sending profiling data, they will appear here."
|
||||
}
|
||||
showRefreshButton={true}
|
||||
sortBy="startTime"
|
||||
sortOrder={SortOrder.Descending}
|
||||
onViewPage={(profile: Profile) => {
|
||||
return Promise.resolve(
|
||||
RouteUtil.populateRouteParams(RouteMap[PageMap.PROFILE_VIEW]!, {
|
||||
modelId: profile.profileId!,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
serviceId: true,
|
||||
},
|
||||
type: FieldType.MultiSelectDropdown,
|
||||
filterDropdownOptions: telemetryServices.map(
|
||||
(service: Service) => {
|
||||
return {
|
||||
label: service.name!,
|
||||
value: service.id!.toString(),
|
||||
};
|
||||
},
|
||||
),
|
||||
title: "Service",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
profileType: true,
|
||||
},
|
||||
type: FieldType.Text,
|
||||
title: "Type",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
traceId: true,
|
||||
},
|
||||
type: FieldType.Text,
|
||||
title: "Trace ID",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
startTime: true,
|
||||
},
|
||||
type: FieldType.DateTime,
|
||||
title: "Captured At",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
attributes: true,
|
||||
},
|
||||
type: FieldType.JSON,
|
||||
title: "Attributes",
|
||||
jsonKeys: attributes,
|
||||
isAdvancedFilter: true,
|
||||
},
|
||||
]}
|
||||
onAdvancedFiltersToggle={handleAdvancedFiltersToggle}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
serviceId: true,
|
||||
},
|
||||
title: "Service",
|
||||
type: FieldType.Element,
|
||||
getElement: (profile: Profile): ReactElement => {
|
||||
const telemetryService: Service | undefined =
|
||||
telemetryServices.find((service: Service) => {
|
||||
return (
|
||||
service.id?.toString() === profile.serviceId?.toString()
|
||||
);
|
||||
});
|
||||
|
||||
if (!telemetryService) {
|
||||
return <p>Unknown</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ServiceElement service={telemetryService} />
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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: "Data Points",
|
||||
type: FieldType.Number,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
startTime: true,
|
||||
},
|
||||
title: "Captured At",
|
||||
type: FieldType.DateTime,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileTable;
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import Profile from "Common/Models/AnalyticsModels/Profile";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
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 ObjectID from "Common/Types/ObjectID";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
|
||||
export interface ProfileTimelineProps {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
serviceIds?: Array<ObjectID> | undefined;
|
||||
profileType?: string | undefined;
|
||||
onTimeRangeSelect?: ((start: Date, end: Date) => void) | undefined;
|
||||
}
|
||||
|
||||
interface TimelineBucket {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const BUCKET_COUNT: number = 50;
|
||||
|
||||
const ProfileTimeline: FunctionComponent<ProfileTimelineProps> = (
|
||||
props: ProfileTimelineProps,
|
||||
): ReactElement => {
|
||||
const [profiles, setProfiles] = useState<Array<Profile>>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loadProfiles: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const query: Record<string, unknown> = {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
startTime: new InBetween(props.startTime, props.endTime),
|
||||
};
|
||||
|
||||
if (
|
||||
props.serviceIds &&
|
||||
props.serviceIds.length > 0 &&
|
||||
props.serviceIds[0]
|
||||
) {
|
||||
query["serviceId"] = props.serviceIds[0];
|
||||
}
|
||||
|
||||
if (props.profileType) {
|
||||
query["profileType"] = props.profileType;
|
||||
}
|
||||
|
||||
const result: ListResult<Profile> = await AnalyticsModelAPI.getList({
|
||||
modelType: Profile,
|
||||
query: query,
|
||||
select: {
|
||||
startTime: true,
|
||||
profileId: true,
|
||||
},
|
||||
limit: 5000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
startTime: SortOrder.Ascending,
|
||||
},
|
||||
});
|
||||
|
||||
setProfiles(result.data || []);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfiles();
|
||||
}, [props.startTime, props.endTime, props.serviceIds, props.profileType]);
|
||||
|
||||
const buckets: Array<TimelineBucket> = useMemo(() => {
|
||||
const start: number = props.startTime.getTime();
|
||||
const end: number = props.endTime.getTime();
|
||||
const bucketWidth: number = (end - start) / BUCKET_COUNT;
|
||||
|
||||
const result: Array<TimelineBucket> = [];
|
||||
|
||||
for (let i: number = 0; i < BUCKET_COUNT; i++) {
|
||||
result.push({
|
||||
startTime: new Date(start + i * bucketWidth),
|
||||
endTime: new Date(start + (i + 1) * bucketWidth),
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const profile of profiles) {
|
||||
const profileTime: number = new Date(
|
||||
profile.startTime || new Date(),
|
||||
).getTime();
|
||||
const bucketIndex: number = Math.min(
|
||||
Math.floor(((profileTime - start) / (end - start)) * BUCKET_COUNT),
|
||||
BUCKET_COUNT - 1,
|
||||
);
|
||||
|
||||
if (bucketIndex >= 0 && bucketIndex < result.length) {
|
||||
result[bucketIndex]!.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [profiles, props.startTime, props.endTime]);
|
||||
|
||||
const maxCount: number = useMemo(() => {
|
||||
let max: number = 0;
|
||||
for (const bucket of buckets) {
|
||||
if (bucket.count > max) {
|
||||
max = bucket.count;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}, [buckets]);
|
||||
|
||||
const handleBucketClick: (bucket: TimelineBucket) => void = useCallback(
|
||||
(bucket: TimelineBucket): void => {
|
||||
if (props.onTimeRangeSelect) {
|
||||
props.onTimeRangeSelect(bucket.startTime, bucket.endTime);
|
||||
}
|
||||
},
|
||||
[props.onTimeRangeSelect],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadProfiles();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (profiles.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
No profiles found in this time range.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
Activity ({profiles.length} profiles captured)
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(props.startTime, true)} —{" "}
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(props.endTime, true)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-end space-x-0.5 h-16 border border-gray-200 rounded bg-white p-1">
|
||||
{buckets.map((bucket: TimelineBucket, index: number) => {
|
||||
const heightPercent: number =
|
||||
maxCount > 0 ? (bucket.count / maxCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex-1 rounded-t cursor-pointer transition-colors ${
|
||||
bucket.count > 0
|
||||
? "bg-blue-400 hover:bg-blue-500"
|
||||
: "bg-gray-100 hover:bg-gray-200"
|
||||
}`}
|
||||
style={{
|
||||
height: `${Math.max(heightPercent, bucket.count > 0 ? 8 : 2)}%`,
|
||||
}}
|
||||
title={`${bucket.count} profiles\n${OneUptimeDate.getDateAsLocalFormattedString(bucket.startTime, true)}`}
|
||||
onClick={() => {
|
||||
handleBucketClick(bucket);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileTimeline;
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface ProfileTypeSelectorProps {
|
||||
selectedProfileType: string | undefined;
|
||||
onChange: (profileType: string | undefined) => void;
|
||||
}
|
||||
|
||||
interface ProfileTypeOption {
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
}
|
||||
|
||||
const profileTypeOptions: Array<ProfileTypeOption> = [
|
||||
{ label: "All Types", value: undefined },
|
||||
{ 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> = (
|
||||
props: ProfileTypeSelectorProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<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 || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value: string = e.target.value;
|
||||
props.onChange(value === "" ? undefined : value);
|
||||
}}
|
||||
>
|
||||
{profileTypeOptions.map((option: ProfileTypeOption, index: number) => {
|
||||
return (
|
||||
<option key={index} value={option.value || ""}>
|
||||
{option.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileTypeSelector;
|
||||
@@ -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;
|
||||
@@ -23,7 +23,12 @@ import Dropdown, {
|
||||
} from "Common/UI/Components/Dropdown/Dropdown";
|
||||
import Protocol from "Common/Types/API/Protocol";
|
||||
|
||||
export type TelemetryType = "logs" | "metrics" | "traces" | "exceptions";
|
||||
export type TelemetryType =
|
||||
| "logs"
|
||||
| "metrics"
|
||||
| "traces"
|
||||
| "exceptions"
|
||||
| "profiles";
|
||||
|
||||
export interface ComponentProps {
|
||||
telemetryType?: TelemetryType | undefined;
|
||||
@@ -67,7 +72,7 @@ const languages: Array<LanguageOption> = [
|
||||
{ key: "angular", label: "Angular (Browser)", shortLabel: "Angular" },
|
||||
];
|
||||
|
||||
type IntegrationMethod = "opentelemetry" | "fluentbit" | "fluentd";
|
||||
type IntegrationMethod = "opentelemetry" | "fluentbit" | "fluentd" | "alloy";
|
||||
|
||||
interface IntegrationOption {
|
||||
key: IntegrationMethod;
|
||||
@@ -81,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);
|
||||
}
|
||||
@@ -256,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>' },
|
||||
})
|
||||
),
|
||||
@@ -298,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>"},
|
||||
)
|
||||
)
|
||||
@@ -308,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>"},
|
||||
)
|
||||
)
|
||||
@@ -358,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 \\
|
||||
@@ -388,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;
|
||||
})
|
||||
@@ -397,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;
|
||||
})
|
||||
@@ -407,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;
|
||||
});
|
||||
@@ -435,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()
|
||||
@@ -466,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>']
|
||||
);
|
||||
@@ -498,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>' }
|
||||
)
|
||||
)
|
||||
@@ -518,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:
|
||||
@@ -540,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);
|
||||
@@ -568,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>")]
|
||||
)
|
||||
@@ -609,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>' },
|
||||
})
|
||||
)
|
||||
@@ -659,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>' },
|
||||
})
|
||||
)
|
||||
@@ -693,11 +700,305 @@ 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"`;
|
||||
}
|
||||
|
||||
// --- Profile-specific snippets ---
|
||||
|
||||
const profileLanguages: Array<Language> = [
|
||||
"node",
|
||||
"python",
|
||||
"go",
|
||||
"java",
|
||||
"dotnet",
|
||||
"ruby",
|
||||
"rust",
|
||||
];
|
||||
|
||||
function getProfileInstallSnippet(lang: Language): {
|
||||
code: string;
|
||||
language: string;
|
||||
} {
|
||||
switch (lang) {
|
||||
case "node":
|
||||
return {
|
||||
code: `npm install @pyroscope/nodejs`,
|
||||
language: "bash",
|
||||
};
|
||||
case "python":
|
||||
return {
|
||||
code: `pip install pyroscope-io`,
|
||||
language: "bash",
|
||||
};
|
||||
case "go":
|
||||
return {
|
||||
code: `go get github.com/grafana/pyroscope-go`,
|
||||
language: "bash",
|
||||
};
|
||||
case "java":
|
||||
return {
|
||||
code: `<!-- Add to pom.xml -->
|
||||
<dependency>
|
||||
<groupId>io.pyroscope</groupId>
|
||||
<artifactId>agent</artifactId>
|
||||
<version>2.1.2</version>
|
||||
</dependency>
|
||||
|
||||
# Or download the Java agent JAR:
|
||||
curl -L -o pyroscope.jar \\
|
||||
https://github.com/grafana/pyroscope-java/releases/latest/download/pyroscope.jar`,
|
||||
language: "bash",
|
||||
};
|
||||
case "dotnet":
|
||||
return {
|
||||
code: `dotnet add package Pyroscope
|
||||
|
||||
# Download the native profiler library:
|
||||
curl -s -L https://github.com/grafana/pyroscope-dotnet/releases/download/v0.13.0-pyroscope/pyroscope.0.13.0-glibc-x86_64.tar.gz | tar xvz -C .`,
|
||||
language: "bash",
|
||||
};
|
||||
case "ruby":
|
||||
return {
|
||||
code: `bundle add pyroscope`,
|
||||
language: "bash",
|
||||
};
|
||||
case "rust":
|
||||
return {
|
||||
code: `cargo add pyroscope pyroscope_pprofrs`,
|
||||
language: "bash",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
code: `# Profiling SDK not available for this language.\n# Use Grafana Alloy (eBPF) for zero-code profiling instead.`,
|
||||
language: "bash",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getProfileConfigSnippet(lang: Language): {
|
||||
code: string;
|
||||
language: string;
|
||||
} {
|
||||
switch (lang) {
|
||||
case "node":
|
||||
return {
|
||||
code: `const Pyroscope = require('@pyroscope/nodejs');
|
||||
|
||||
Pyroscope.init({
|
||||
serverAddress: '<YOUR_ONEUPTIME_PYROSCOPE_URL>',
|
||||
appName: 'my-service',
|
||||
tags: {
|
||||
region: process.env.REGION || 'default',
|
||||
},
|
||||
authToken: '<YOUR_ONEUPTIME_TOKEN>',
|
||||
});
|
||||
|
||||
Pyroscope.start();`,
|
||||
language: "javascript",
|
||||
};
|
||||
case "python":
|
||||
return {
|
||||
code: `import pyroscope
|
||||
|
||||
pyroscope.configure(
|
||||
application_name="my-service",
|
||||
server_address="<YOUR_ONEUPTIME_PYROSCOPE_URL>",
|
||||
sample_rate=100,
|
||||
tags={
|
||||
"region": "us-east-1",
|
||||
},
|
||||
auth_token="<YOUR_ONEUPTIME_TOKEN>",
|
||||
)`,
|
||||
language: "python",
|
||||
};
|
||||
case "go":
|
||||
return {
|
||||
code: `package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/grafana/pyroscope-go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Enable mutex and block profiling
|
||||
runtime.SetMutexProfileFraction(5)
|
||||
runtime.SetBlockProfileRate(5)
|
||||
|
||||
pyroscope.Start(pyroscope.Config{
|
||||
ApplicationName: "my-service",
|
||||
ServerAddress: "<YOUR_ONEUPTIME_PYROSCOPE_URL>",
|
||||
AuthToken: os.Getenv("ONEUPTIME_TOKEN"),
|
||||
Tags: map[string]string{"hostname": os.Getenv("HOSTNAME")},
|
||||
ProfileTypes: []pyroscope.ProfileType{
|
||||
pyroscope.ProfileCPU,
|
||||
pyroscope.ProfileAllocObjects,
|
||||
pyroscope.ProfileAllocSpace,
|
||||
pyroscope.ProfileInuseObjects,
|
||||
pyroscope.ProfileInuseSpace,
|
||||
pyroscope.ProfileGoroutines,
|
||||
pyroscope.ProfileMutexCount,
|
||||
pyroscope.ProfileMutexDuration,
|
||||
pyroscope.ProfileBlockCount,
|
||||
pyroscope.ProfileBlockDuration,
|
||||
},
|
||||
})
|
||||
|
||||
// Your application code here
|
||||
}`,
|
||||
language: "go",
|
||||
};
|
||||
case "java":
|
||||
return {
|
||||
code: `// Option 1: Start from code
|
||||
import io.pyroscope.javaagent.PyroscopeAgent;
|
||||
import io.pyroscope.javaagent.config.Config;
|
||||
import io.pyroscope.javaagent.EventType;
|
||||
import io.pyroscope.http.Format;
|
||||
|
||||
PyroscopeAgent.start(
|
||||
new Config.Builder()
|
||||
.setApplicationName("my-service")
|
||||
.setProfilingEvent(EventType.ITIMER)
|
||||
.setFormat(Format.JFR)
|
||||
.setServerAddress("<YOUR_ONEUPTIME_PYROSCOPE_URL>")
|
||||
.setAuthToken("<YOUR_ONEUPTIME_TOKEN>")
|
||||
.build()
|
||||
);
|
||||
|
||||
// Option 2: Attach as Java agent (no code changes)
|
||||
// java -javaagent:pyroscope.jar \\
|
||||
// -Dpyroscope.application.name=my-service \\
|
||||
// -Dpyroscope.server.address=<YOUR_ONEUPTIME_PYROSCOPE_URL> \\
|
||||
// -Dpyroscope.auth.token=<YOUR_ONEUPTIME_TOKEN> \\
|
||||
// -jar my-app.jar`,
|
||||
language: "java",
|
||||
};
|
||||
case "dotnet":
|
||||
return {
|
||||
code: `# Set environment variables before running your .NET application:
|
||||
export PYROSCOPE_APPLICATION_NAME=my-service
|
||||
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
|
||||
export CORECLR_PROFILER={BD1A650D-AC5D-4896-B64F-D6FA25D6B26A}
|
||||
export CORECLR_PROFILER_PATH=./Pyroscope.Profiler.Native.so
|
||||
export LD_PRELOAD=./Pyroscope.Linux.ApiWrapper.x64.so
|
||||
|
||||
# Then run your application:
|
||||
dotnet run`,
|
||||
language: "bash",
|
||||
};
|
||||
case "ruby":
|
||||
return {
|
||||
code: `# config/initializers/pyroscope.rb
|
||||
require 'pyroscope'
|
||||
|
||||
Pyroscope.configure do |config|
|
||||
config.application_name = "my-service"
|
||||
config.server_address = "<YOUR_ONEUPTIME_PYROSCOPE_URL>"
|
||||
config.auth_token = "<YOUR_ONEUPTIME_TOKEN>"
|
||||
config.tags = {
|
||||
"hostname" => ENV["HOSTNAME"],
|
||||
"region" => ENV.fetch("REGION", "default"),
|
||||
}
|
||||
end`,
|
||||
language: "ruby",
|
||||
};
|
||||
case "rust":
|
||||
return {
|
||||
code: `use pyroscope::PyroscopeAgent;
|
||||
use pyroscope_pprofrs::{pprof_backend, PprofConfig};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pprof_config = PprofConfig::new().sample_rate(100);
|
||||
let backend_impl = pprof_backend(pprof_config);
|
||||
|
||||
let agent = PyroscopeAgent::builder(
|
||||
"<YOUR_ONEUPTIME_PYROSCOPE_URL>", "my-service"
|
||||
)
|
||||
.backend(backend_impl)
|
||||
.auth_token("<YOUR_ONEUPTIME_TOKEN>".to_string())
|
||||
.tags([("hostname", "localhost")].to_vec())
|
||||
.build()?;
|
||||
|
||||
let agent_running = agent.start()?;
|
||||
|
||||
// Your application code here
|
||||
|
||||
let agent_ready = agent_running.stop()?;
|
||||
agent_ready.shutdown();
|
||||
|
||||
Ok(())
|
||||
}`,
|
||||
language: "rust",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
code: `# Profiling SDK not available for this language.\n# Use Grafana Alloy (eBPF) for zero-code profiling instead.`,
|
||||
language: "bash",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getAlloyEbpfSnippet(): string {
|
||||
return `# alloy-config.alloy
|
||||
# Grafana Alloy eBPF-based profiling — no code changes required.
|
||||
# Supports: Go, Rust, C/C++, Java, Python, Ruby, PHP, Node.js, .NET
|
||||
|
||||
discovery.process "all" {
|
||||
refresh_interval = "60s"
|
||||
}
|
||||
|
||||
discovery.relabel "alloy_profiles" {
|
||||
targets = discovery.process.all.targets
|
||||
|
||||
rule {
|
||||
action = "replace"
|
||||
source_labels = ["__meta_process_exe"]
|
||||
target_label = "service_name"
|
||||
}
|
||||
}
|
||||
|
||||
pyroscope.ebpf "default" {
|
||||
targets = discovery.relabel.alloy_profiles.output
|
||||
forward_to = [pyroscope.write.oneuptime.receiver]
|
||||
|
||||
collect_interval = "15s"
|
||||
sample_rate = 97
|
||||
}
|
||||
|
||||
pyroscope.write "oneuptime" {
|
||||
endpoint {
|
||||
url = "<YOUR_ONEUPTIME_PYROSCOPE_URL>"
|
||||
headers = {
|
||||
"x-oneuptime-token" = "<YOUR_ONEUPTIME_TOKEN>",
|
||||
}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
function getAlloyDockerSnippet(): string {
|
||||
return `# docker-compose.yml
|
||||
services:
|
||||
alloy:
|
||||
image: grafana/alloy:latest
|
||||
privileged: true
|
||||
pid: host
|
||||
volumes:
|
||||
- ./alloy-config.alloy:/etc/alloy/config.alloy
|
||||
- /proc:/proc:ro
|
||||
- /sys:/sys:ro
|
||||
command:
|
||||
- run
|
||||
- /etc/alloy/config.alloy`;
|
||||
}
|
||||
|
||||
// --- FluentBit snippets ---
|
||||
|
||||
function getFluentBitSnippet(): string {
|
||||
@@ -787,8 +1088,9 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<Language>("node");
|
||||
const [selectedMethod, setSelectedMethod] =
|
||||
useState<IntegrationMethod>("opentelemetry");
|
||||
const [selectedMethod, setSelectedMethod] = useState<IntegrationMethod>(
|
||||
props.telemetryType === "profiles" ? "alloy" : "opentelemetry",
|
||||
);
|
||||
|
||||
// Token management state
|
||||
const [ingestionKeys, setIngestionKeys] = useState<
|
||||
@@ -802,6 +1104,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
const telemetryType: TelemetryType = props.telemetryType || "logs";
|
||||
|
||||
const showLogCollectors: boolean = telemetryType === "logs";
|
||||
const isProfiles: boolean = telemetryType === "profiles";
|
||||
|
||||
// Compute OTLP URL and host
|
||||
const httpProtocol: string =
|
||||
@@ -809,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(() => {
|
||||
@@ -864,6 +1170,23 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
const otlpHostValue: string = otlpHost;
|
||||
|
||||
const integrationMethods: Array<IntegrationOption> = useMemo(() => {
|
||||
if (isProfiles) {
|
||||
return [
|
||||
{
|
||||
key: "alloy" as IntegrationMethod,
|
||||
label: "Grafana Alloy (eBPF)",
|
||||
description:
|
||||
"Recommended. Zero-code profiling for all languages on Linux using eBPF.",
|
||||
},
|
||||
{
|
||||
key: "opentelemetry" as IntegrationMethod,
|
||||
label: "Language SDK",
|
||||
description:
|
||||
"In-process profiling using Pyroscope SDKs for fine-grained control.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const methods: Array<IntegrationOption> = [
|
||||
{
|
||||
key: "opentelemetry",
|
||||
@@ -886,13 +1209,14 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
|
||||
return methods;
|
||||
}, [showLogCollectors]);
|
||||
}, [showLogCollectors, isProfiles]);
|
||||
|
||||
const titleForType: Record<TelemetryType, string> = {
|
||||
logs: "Log Ingestion Setup",
|
||||
metrics: "Metrics Ingestion Setup",
|
||||
traces: "Trace Ingestion Setup",
|
||||
exceptions: "Exception Tracking Setup",
|
||||
profiles: "Profiles Ingestion Setup",
|
||||
};
|
||||
|
||||
const descriptionForType: Record<TelemetryType, string> = {
|
||||
@@ -903,15 +1227,23 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
"Send distributed traces from your application to OneUptime using OpenTelemetry SDKs.",
|
||||
exceptions:
|
||||
"Capture and track exceptions from your application using OpenTelemetry SDKs.",
|
||||
profiles:
|
||||
"Send continuous profiling data from your application to OneUptime using OpenTelemetry SDKs.",
|
||||
};
|
||||
|
||||
const installSnippet: { code: string; language: string } = useMemo(() => {
|
||||
if (isProfiles) {
|
||||
return getProfileInstallSnippet(selectedLanguage);
|
||||
}
|
||||
return getOtelInstallSnippet(selectedLanguage);
|
||||
}, [selectedLanguage]);
|
||||
}, [selectedLanguage, isProfiles]);
|
||||
|
||||
const configSnippet: { code: string; language: string } = useMemo(() => {
|
||||
if (isProfiles) {
|
||||
return getProfileConfigSnippet(selectedLanguage);
|
||||
}
|
||||
return getOtelConfigSnippet(selectedLanguage);
|
||||
}, [selectedLanguage]);
|
||||
}, [selectedLanguage, isProfiles]);
|
||||
|
||||
const handleLanguageSelect: (lang: Language) => void = useCallback(
|
||||
(lang: Language) => {
|
||||
@@ -1106,13 +1438,19 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
|
||||
// Language selector
|
||||
const renderLanguageSelector: () => ReactElement = (): ReactElement => {
|
||||
const availableLanguages: Array<LanguageOption> = isProfiles
|
||||
? languages.filter((l: LanguageOption) => {
|
||||
return profileLanguages.includes(l.key);
|
||||
})
|
||||
: languages;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
Select Language
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{languages.map((lang: LanguageOption) => {
|
||||
{availableLanguages.map((lang: LanguageOption) => {
|
||||
const isSelected: boolean = selectedLanguage === lang.key;
|
||||
return (
|
||||
<button
|
||||
@@ -1196,11 +1534,17 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
{renderStep(
|
||||
2,
|
||||
"Install Dependencies",
|
||||
`Install the OpenTelemetry SDK and exporters for ${
|
||||
languages.find((l: LanguageOption) => {
|
||||
return l.key === selectedLanguage;
|
||||
})?.label || selectedLanguage
|
||||
}.`,
|
||||
isProfiles
|
||||
? `Install the Pyroscope profiling SDK for ${
|
||||
languages.find((l: LanguageOption) => {
|
||||
return l.key === selectedLanguage;
|
||||
})?.label || selectedLanguage
|
||||
}.`
|
||||
: `Install the OpenTelemetry SDK and exporters for ${
|
||||
languages.find((l: LanguageOption) => {
|
||||
return l.key === selectedLanguage;
|
||||
})?.label || selectedLanguage
|
||||
}.`,
|
||||
<CodeBlock
|
||||
code={installSnippet.code}
|
||||
language={installSnippet.language}
|
||||
@@ -1209,34 +1553,40 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
|
||||
{renderStep(
|
||||
3,
|
||||
"Configure the SDK",
|
||||
"Initialize OpenTelemetry with the OTLP exporter pointing to your OneUptime instance.",
|
||||
isProfiles ? "Configure the Profiler" : "Configure the SDK",
|
||||
isProfiles
|
||||
? "Initialize the Pyroscope profiling SDK and point it to your OneUptime instance. Profiles will be continuously captured and sent."
|
||||
: "Initialize OpenTelemetry with the OTLP exporter pointing to your OneUptime instance.",
|
||||
<CodeBlock
|
||||
code={replacePlaceholders(
|
||||
configSnippet.code,
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language={configSnippet.language}
|
||||
/>,
|
||||
Boolean(isProfiles),
|
||||
)}
|
||||
|
||||
{renderStep(
|
||||
4,
|
||||
"Set Environment Variables (Alternative)",
|
||||
"You can also configure OpenTelemetry via environment variables instead of code.",
|
||||
<CodeBlock
|
||||
code={replacePlaceholders(
|
||||
getEnvVarSnippet(),
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
)}
|
||||
language="bash"
|
||||
/>,
|
||||
true,
|
||||
)}
|
||||
{!isProfiles &&
|
||||
renderStep(
|
||||
4,
|
||||
"Set Environment Variables (Alternative)",
|
||||
"You can also configure OpenTelemetry via environment variables instead of code.",
|
||||
<CodeBlock
|
||||
code={replacePlaceholders(
|
||||
getEnvVarSnippet(),
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="bash"
|
||||
/>,
|
||||
true,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1264,6 +1614,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="yaml"
|
||||
/>,
|
||||
@@ -1279,6 +1630,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="yaml"
|
||||
/>,
|
||||
@@ -1318,6 +1670,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="yaml"
|
||||
/>,
|
||||
@@ -1333,6 +1686,7 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="yaml"
|
||||
/>,
|
||||
@@ -1350,12 +1704,70 @@ const TelemetryDocumentation: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
};
|
||||
|
||||
// Grafana Alloy eBPF content (for profiles)
|
||||
const renderAlloyContent: () => ReactElement = (): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-2">
|
||||
{renderStep(
|
||||
1,
|
||||
"Get Your Ingestion Credentials",
|
||||
"Select an existing ingestion key or create a new one. These credentials authenticate your profiling data.",
|
||||
renderTokenStepContent(),
|
||||
)}
|
||||
|
||||
{renderStep(
|
||||
2,
|
||||
"Create Alloy Configuration",
|
||||
"Create an Alloy configuration file that uses eBPF to collect CPU profiles from all processes on your Linux host — no code changes required. Supports Go, Rust, C/C++, Java, Python, Ruby, PHP, Node.js, and .NET.",
|
||||
<CodeBlock
|
||||
code={replacePlaceholders(
|
||||
getAlloyEbpfSnippet(),
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="nginx"
|
||||
/>,
|
||||
)}
|
||||
|
||||
{renderStep(
|
||||
3,
|
||||
"Run with Docker",
|
||||
"Run Grafana Alloy as a privileged Docker container with access to the host PID namespace.",
|
||||
<CodeBlock
|
||||
code={replacePlaceholders(
|
||||
getAlloyDockerSnippet(),
|
||||
otlpUrlValue,
|
||||
otlpHostValue,
|
||||
tokenValue,
|
||||
pyroscopeUrl,
|
||||
)}
|
||||
language="yaml"
|
||||
/>,
|
||||
)}
|
||||
|
||||
{renderStep(
|
||||
4,
|
||||
"Run Alloy",
|
||||
"Or run Alloy directly on the host.",
|
||||
<CodeBlock code="alloy run alloy-config.alloy" language="bash" />,
|
||||
true,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderActiveContent: () => ReactElement = (): ReactElement => {
|
||||
switch (selectedMethod) {
|
||||
case "fluentbit":
|
||||
return renderFluentBitContent();
|
||||
case "fluentd":
|
||||
return renderFluentdContent();
|
||||
case "alloy":
|
||||
return renderAlloyContent();
|
||||
default:
|
||||
return renderOpenTelemetryContent();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import SpanViewer from "../Span/SpanViewer";
|
||||
import FlameGraph from "./FlameGraph";
|
||||
import TraceServiceMap from "./TraceServiceMap";
|
||||
import ServiceElement from "..//Service/ServiceElement";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import SpanUtil, {
|
||||
DivisibilityFactor,
|
||||
@@ -347,6 +351,22 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-2 border-t border-gray-200">
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 text-[11px] font-medium"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const profilesRoute: Route = RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.PROFILES] as Route,
|
||||
);
|
||||
Navigation.navigate(profilesRoute, {
|
||||
openInNewTab: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
View Profiles for this Trace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -11,10 +11,39 @@ import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import Label from "Common/Models/DatabaseModels/Label";
|
||||
import Dashboard from "Common/Models/DatabaseModels/Dashboard";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import DashboardElement from "../../Components/Dashboard/DashboardElement";
|
||||
import DashboardTemplateCard from "../../Components/Dashboard/DashboardTemplateCard";
|
||||
import {
|
||||
DashboardTemplates,
|
||||
DashboardTemplateType,
|
||||
getTemplateConfig,
|
||||
DashboardTemplate,
|
||||
} from "Common/Types/Dashboard/DashboardTemplates";
|
||||
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import Modal, { ModalWidth } from "Common/UI/Components/Modal/Modal";
|
||||
|
||||
const Dashboards: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
const [selectedTemplate, setSelectedTemplate] =
|
||||
useState<DashboardTemplateType | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState<boolean>(false);
|
||||
const [showTemplateModal, setShowTemplateModal] = useState<boolean>(false);
|
||||
|
||||
const handleTemplateClick: (type: DashboardTemplateType) => void =
|
||||
useCallback((type: DashboardTemplateType): void => {
|
||||
setSelectedTemplate(type);
|
||||
setShowTemplateModal(false);
|
||||
setShowCreateForm(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Page
|
||||
title={"Dashboards"}
|
||||
@@ -31,6 +60,37 @@ const Dashboards: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
},
|
||||
]}
|
||||
>
|
||||
{showTemplateModal ? (
|
||||
<Modal
|
||||
title="Create from Template"
|
||||
description="Choose a template to quickly get started with a pre-configured dashboard."
|
||||
onClose={() => {
|
||||
setShowTemplateModal(false);
|
||||
}}
|
||||
modalWidth={ModalWidth.Large}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{DashboardTemplates.map(
|
||||
(template: DashboardTemplate): ReactElement => {
|
||||
return (
|
||||
<DashboardTemplateCard
|
||||
key={template.type}
|
||||
title={template.name}
|
||||
description={template.description}
|
||||
icon={template.icon}
|
||||
onClick={() => {
|
||||
handleTemplateClick(template.type);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<ModelTable<Dashboard>
|
||||
modelType={Dashboard}
|
||||
id="dashboard-table"
|
||||
@@ -40,9 +100,20 @@ const Dashboards: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
isCreateable={true}
|
||||
name="Dashboards"
|
||||
isViewable={true}
|
||||
showCreateForm={showCreateForm}
|
||||
cardProps={{
|
||||
title: "Dashboards",
|
||||
description: "Here is a list of dashboards for this project.",
|
||||
buttons: [
|
||||
{
|
||||
title: "Create from Template",
|
||||
buttonStyle: ButtonStyleType.OUTLINE,
|
||||
onClick: () => {
|
||||
setShowTemplateModal(true);
|
||||
},
|
||||
icon: IconProp.Add,
|
||||
},
|
||||
],
|
||||
}}
|
||||
showViewIdButton={true}
|
||||
noItemsMessage={"No dashboards found."}
|
||||
@@ -69,6 +140,24 @@ const Dashboards: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
placeholder: "Description",
|
||||
},
|
||||
]}
|
||||
onBeforeCreate={async (
|
||||
item: Dashboard,
|
||||
_miscDataProps: JSONObject,
|
||||
): Promise<Dashboard> => {
|
||||
if (
|
||||
selectedTemplate &&
|
||||
selectedTemplate !== DashboardTemplateType.Blank
|
||||
) {
|
||||
const templateConfig: DashboardViewConfig | null =
|
||||
getTemplateConfig(selectedTemplate);
|
||||
if (templateConfig) {
|
||||
item.dashboardViewConfig = templateConfig;
|
||||
}
|
||||
}
|
||||
setSelectedTemplate(null);
|
||||
setShowCreateForm(false);
|
||||
return item;
|
||||
}}
|
||||
saveFilterProps={{
|
||||
tableId: "all-dashboards-table",
|
||||
}}
|
||||
|
||||
@@ -5,12 +5,28 @@ import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import Dashboard from "Common/Models/DatabaseModels/Dashboard";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import DashboardPreviewLink from "./DashboardPreviewLink";
|
||||
import ModelFormModal from "Common/UI/Components/ModelFormModal/ModelFormModal";
|
||||
import { FormType } from "Common/UI/Components/Forms/ModelForm";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
|
||||
const DashboardAuthenticationSettings: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
const [isPublicDashboard, setIsPublicDashboard] = useState<boolean>(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState<boolean>(false);
|
||||
const [refreshMasterPassword, setRefreshMasterPassword] =
|
||||
useState<boolean>(false);
|
||||
const [isMasterPasswordSet, setIsMasterPasswordSet] =
|
||||
useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -47,112 +63,166 @@ const DashboardAuthenticationSettings: FunctionComponent<
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
onItemLoaded: (item: Dashboard) => {
|
||||
setIsPublicDashboard(Boolean(item.isPublicDashboard));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > Master Password"
|
||||
cardProps={{
|
||||
title: "Master Password",
|
||||
description:
|
||||
"Rotate the password required to unlock a private dashboard. This value is stored as a secure hash and cannot be retrieved.",
|
||||
}}
|
||||
editButtonText="Update Master Password"
|
||||
isEditable={true}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
enableMasterPassword: true,
|
||||
},
|
||||
title: "Require Master Password",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
description:
|
||||
"When enabled, visitors must enter the master password before viewing a private dashboard.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
masterPassword: true,
|
||||
},
|
||||
title: "Master Password",
|
||||
fieldType: FormFieldSchemaType.Password,
|
||||
required: false,
|
||||
placeholder: "Enter a new master password",
|
||||
description:
|
||||
"Updating this value immediately replaces the existing master password.",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: Dashboard,
|
||||
id: "model-detail-dashboard-master-password",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
enableMasterPassword: true,
|
||||
},
|
||||
fieldType: FieldType.Boolean,
|
||||
title: "Require Master Password",
|
||||
placeholder: "No",
|
||||
},
|
||||
{
|
||||
{isPublicDashboard && (
|
||||
<>
|
||||
<DashboardPreviewLink modelId={modelId} />
|
||||
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > Master Password"
|
||||
cardProps={{
|
||||
title: "Master Password",
|
||||
fieldType: FieldType.Element,
|
||||
placeholder: "Hidden",
|
||||
getElement: (): ReactElement => {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
For security reasons, the current master password is never
|
||||
displayed. Use the update button to set a new password at
|
||||
any time.
|
||||
</p>
|
||||
);
|
||||
description:
|
||||
"When enabled, visitors must enter the master password before viewing this public dashboard. This value is stored as a secure hash and cannot be retrieved.",
|
||||
buttons: [
|
||||
{
|
||||
title: isMasterPasswordSet
|
||||
? "Update Master Password"
|
||||
: "Set Master Password",
|
||||
buttonStyle: ButtonStyleType.NORMAL,
|
||||
onClick: () => {
|
||||
setShowPasswordModal(true);
|
||||
},
|
||||
icon: IconProp.Lock,
|
||||
},
|
||||
],
|
||||
}}
|
||||
editButtonText="Edit Settings"
|
||||
isEditable={true}
|
||||
refresher={refreshMasterPassword}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
enableMasterPassword: true,
|
||||
},
|
||||
title: "Require Master Password",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
description:
|
||||
"When enabled, visitors must enter the master password before viewing this public dashboard.",
|
||||
},
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: Dashboard,
|
||||
id: "model-detail-dashboard-enable-master-password",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
enableMasterPassword: true,
|
||||
},
|
||||
fieldType: FieldType.Boolean,
|
||||
title: "Require Master Password",
|
||||
placeholder: "No",
|
||||
},
|
||||
{
|
||||
title: "Master Password",
|
||||
fieldType: FieldType.Element,
|
||||
getElement: (): ReactElement => {
|
||||
return (
|
||||
<p>
|
||||
{isMasterPasswordSet ? "Password is set." : "Not set."}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
onItemLoaded: (item: Dashboard) => {
|
||||
setIsMasterPasswordSet(Boolean(item.masterPassword));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > IP Whitelist"
|
||||
cardProps={{
|
||||
title: "IP Whitelist",
|
||||
description:
|
||||
"IP Whitelist for this dashboard. If the dashboard is public then only IP addresses in this whitelist will be able to access the dashboard. If the dashboard is not public then only users who have access from the IP addresses in this whitelist will be able to access the dashboard.",
|
||||
}}
|
||||
editButtonText="Edit IP Whitelist"
|
||||
isEditable={true}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
ipWhitelist: true,
|
||||
},
|
||||
title: "IP Whitelist",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
placeholder:
|
||||
"Please enter the IP addresses or CIDR ranges to whitelist. One per line. This can be IPv4 or IPv6 addresses.",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: Dashboard,
|
||||
id: "model-detail-dashboard-ip-whitelist",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
ipWhitelist: true,
|
||||
},
|
||||
fieldType: FieldType.LongText,
|
||||
{showPasswordModal && (
|
||||
<ModelFormModal<Dashboard>
|
||||
title={
|
||||
isMasterPasswordSet
|
||||
? "Update Master Password"
|
||||
: "Set Master Password"
|
||||
}
|
||||
onClose={() => {
|
||||
setShowPasswordModal(false);
|
||||
}}
|
||||
submitButtonText="Save"
|
||||
onSuccess={() => {
|
||||
setShowPasswordModal(false);
|
||||
setRefreshMasterPassword(!refreshMasterPassword);
|
||||
setIsMasterPasswordSet(true);
|
||||
}}
|
||||
name="Dashboard > Master Password"
|
||||
modelType={Dashboard}
|
||||
formProps={{
|
||||
id: "edit-dashboard-master-password-from",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
masterPassword: true,
|
||||
},
|
||||
title: "Master Password",
|
||||
fieldType: FormFieldSchemaType.Password,
|
||||
required: true,
|
||||
placeholder: "Enter a new master password",
|
||||
description:
|
||||
"Updating this value immediately replaces the existing master password.",
|
||||
},
|
||||
],
|
||||
name: "Dashboard > Master Password",
|
||||
formType: FormType.Update,
|
||||
modelType: Dashboard,
|
||||
steps: [],
|
||||
doNotFetchExistingModel: true,
|
||||
}}
|
||||
modelIdToEdit={modelId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > IP Whitelist"
|
||||
cardProps={{
|
||||
title: "IP Whitelist",
|
||||
placeholder:
|
||||
"No IP addresses or CIDR ranges whitelisted. This will allow all IP addresses to access the dashboard.",
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
description:
|
||||
"IP Whitelist for this dashboard. Only IP addresses in this whitelist will be able to access the public dashboard.",
|
||||
}}
|
||||
editButtonText="Edit IP Whitelist"
|
||||
isEditable={true}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
ipWhitelist: true,
|
||||
},
|
||||
title: "IP Whitelist",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
placeholder:
|
||||
"Please enter the IP addresses or CIDR ranges to whitelist. One per line. This can be IPv4 or IPv6 addresses.",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: Dashboard,
|
||||
id: "model-detail-dashboard-ip-whitelist",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
ipWhitelist: true,
|
||||
},
|
||||
fieldType: FieldType.LongText,
|
||||
title: "IP Whitelist",
|
||||
placeholder:
|
||||
"No IP addresses or CIDR ranges whitelisted. This will allow all IP addresses to access the dashboard.",
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
154
App/FeatureSet/Dashboard/src/Pages/Dashboards/View/Branding.tsx
Normal file
154
App/FeatureSet/Dashboard/src/Pages/Dashboards/View/Branding.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
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 Dashboard from "Common/Models/DatabaseModels/Dashboard";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
|
||||
const DashboardBranding: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > Branding > Title and Description"
|
||||
cardProps={{
|
||||
title: "Title and Description",
|
||||
description: "This will also be used for SEO.",
|
||||
}}
|
||||
editButtonText={"Edit"}
|
||||
isEditable={true}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
pageTitle: true,
|
||||
},
|
||||
title: "Page Title",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: false,
|
||||
placeholder: "Please enter page title here.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
pageDescription: true,
|
||||
},
|
||||
title: "Page Description",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
placeholder: "Please enter page description here.",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: Dashboard,
|
||||
id: "model-detail-dashboard-branding",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
pageTitle: true,
|
||||
},
|
||||
fieldType: FieldType.Text,
|
||||
title: "Page Title",
|
||||
placeholder: "No page title entered so far.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
pageDescription: true,
|
||||
},
|
||||
fieldType: FieldType.Text,
|
||||
title: "Page Description",
|
||||
placeholder: "No page description entered so far.",
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > Branding > Logo"
|
||||
cardProps={{
|
||||
title: "Logo",
|
||||
description: "Logo will be displayed on the public dashboard header.",
|
||||
}}
|
||||
isEditable={true}
|
||||
editButtonText={"Edit Logo"}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
logoFile: true,
|
||||
},
|
||||
title: "Logo",
|
||||
fieldType: FormFieldSchemaType.ImageFile,
|
||||
required: false,
|
||||
placeholder: "Upload Logo.",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: Dashboard,
|
||||
id: "model-detail-dashboard-logo",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
logoFile: {
|
||||
file: true,
|
||||
fileType: true,
|
||||
},
|
||||
},
|
||||
fieldType: FieldType.ImageFile,
|
||||
title: "Logo",
|
||||
placeholder: "No logo uploaded.",
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > Branding > Favicon"
|
||||
cardProps={{
|
||||
title: "Favicon",
|
||||
description: "Favicon will be used for SEO.",
|
||||
}}
|
||||
isEditable={true}
|
||||
editButtonText={"Edit Favicon"}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
faviconFile: true,
|
||||
},
|
||||
title: "Favicon",
|
||||
fieldType: FormFieldSchemaType.ImageFile,
|
||||
required: false,
|
||||
placeholder: "Upload Favicon.",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: Dashboard,
|
||||
id: "model-detail-dashboard-favicon",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
faviconFile: {
|
||||
file: true,
|
||||
fileType: true,
|
||||
},
|
||||
},
|
||||
fieldType: FieldType.ImageFile,
|
||||
title: "Favicon",
|
||||
placeholder: "No favicon uploaded.",
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardBranding;
|
||||
@@ -8,7 +8,6 @@ import Navigation from "Common/UI/Utils/Navigation";
|
||||
import Label from "Common/Models/DatabaseModels/Label";
|
||||
import Dashboard from "Common/Models/DatabaseModels/Dashboard";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import DashboardPreviewLink from "./DashboardPreviewLink";
|
||||
|
||||
const DashboardView: FunctionComponent<
|
||||
PageComponentProps
|
||||
@@ -17,7 +16,6 @@ const DashboardView: FunctionComponent<
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<DashboardPreviewLink modelId={modelId} />
|
||||
{/* Dashboard View */}
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > Dashboard Details"
|
||||
|
||||
@@ -41,7 +41,18 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
<SideMenuSection title="Custom Domains">
|
||||
<SideMenuSection title="Branding">
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Branding",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.DASHBOARD_VIEW_BRANDING] as Route,
|
||||
{ modelId: props.modelId },
|
||||
),
|
||||
}}
|
||||
icon={IconProp.Image}
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Custom Domains",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -880,6 +880,11 @@ const KubernetesClusterOverview: FunctionComponent<
|
||||
<span className="text-sm font-medium text-gray-900 truncate group-hover:text-indigo-700">
|
||||
{pod.name}
|
||||
</span>
|
||||
{pod.namespace && (
|
||||
<span className="flex-shrink-0 inline-flex px-1.5 py-0.5 text-xs font-medium rounded bg-indigo-50 text-indigo-600">
|
||||
{pod.namespace}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-sm font-semibold text-gray-700 tabular-nums ml-2">
|
||||
{KubernetesResourceUtils.formatCpuValue(
|
||||
@@ -887,13 +892,8 @@ const KubernetesClusterOverview: FunctionComponent<
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-6">
|
||||
{pod.namespace && (
|
||||
<span className="flex-shrink-0 inline-flex px-1.5 py-0.5 text-xs font-medium rounded bg-indigo-50 text-indigo-600">
|
||||
{pod.namespace}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-1.5">
|
||||
<div className="pl-6">
|
||||
<div className="w-full bg-gray-100 rounded-full h-1.5">
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${
|
||||
pct > 80
|
||||
@@ -970,6 +970,11 @@ const KubernetesClusterOverview: FunctionComponent<
|
||||
<span className="text-sm font-medium text-gray-900 truncate group-hover:text-indigo-700">
|
||||
{pod.name}
|
||||
</span>
|
||||
{pod.namespace && (
|
||||
<span className="flex-shrink-0 inline-flex px-1.5 py-0.5 text-xs font-medium rounded bg-indigo-50 text-indigo-600">
|
||||
{pod.namespace}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-sm font-semibold text-gray-700 tabular-nums ml-2">
|
||||
{KubernetesResourceUtils.formatMemoryValue(
|
||||
@@ -977,13 +982,8 @@ const KubernetesClusterOverview: FunctionComponent<
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-6">
|
||||
{pod.namespace && (
|
||||
<span className="flex-shrink-0 inline-flex px-1.5 py-0.5 text-xs font-medium rounded bg-indigo-50 text-indigo-600">
|
||||
{pod.namespace}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-1.5">
|
||||
<div className="pl-6">
|
||||
<div className="w-full bg-gray-100 rounded-full h-1.5">
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${
|
||||
memPercent > 85
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import TelemetryDocumentation from "../../Components/Telemetry/Documentation";
|
||||
|
||||
const ProfilesDocumentationPage: FunctionComponent<PageComponentProps> = (
|
||||
_props: PageComponentProps,
|
||||
): ReactElement => {
|
||||
return <TelemetryDocumentation telemetryType="profiles" />;
|
||||
};
|
||||
|
||||
export default ProfilesDocumentationPage;
|
||||
68
App/FeatureSet/Dashboard/src/Pages/Profiles/Index.tsx
Normal file
68
App/FeatureSet/Dashboard/src/Pages/Profiles/Index.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 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";
|
||||
import ProfilesDashboard from "../../Components/Profiles/ProfilesDashboard";
|
||||
|
||||
const ProfilesPage: 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="profiles" />;
|
||||
}
|
||||
|
||||
return <ProfilesDashboard />;
|
||||
};
|
||||
|
||||
export default ProfilesPage;
|
||||
26
App/FeatureSet/Dashboard/src/Pages/Profiles/Layout.tsx
Normal file
26
App/FeatureSet/Dashboard/src/Pages/Profiles/Layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getProfilesBreadcrumbs } from "../../Utils/Breadcrumbs";
|
||||
import { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import SideMenu from "./SideMenu";
|
||||
import Page from "Common/UI/Components/Page/Page";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
const ProfilesLayout: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const path: string = Navigation.getRoutePath(RouteUtil.getRoutes());
|
||||
|
||||
return (
|
||||
<Page
|
||||
title="Performance Profiles"
|
||||
breadcrumbLinks={getProfilesBreadcrumbs(path)}
|
||||
sideMenu={<SideMenu />}
|
||||
>
|
||||
<Outlet />
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilesLayout;
|
||||
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;
|
||||
54
App/FeatureSet/Dashboard/src/Pages/Profiles/SideMenu.tsx
Normal file
54
App/FeatureSet/Dashboard/src/Pages/Profiles/SideMenu.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import SideMenu, {
|
||||
SideMenuSectionProps,
|
||||
} from "Common/UI/Components/SideMenu/SideMenu";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
const DashboardSideMenu: FunctionComponent = (): ReactElement => {
|
||||
const sections: SideMenuSectionProps[] = [
|
||||
{
|
||||
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_LIST] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.Fire,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
items: [
|
||||
{
|
||||
link: {
|
||||
title: "Setup Guide",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.PROFILES_DOCUMENTATION] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.Book,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return <SideMenu sections={sections} />;
|
||||
};
|
||||
|
||||
export default DashboardSideMenu;
|
||||
81
App/FeatureSet/Dashboard/src/Pages/Profiles/View/Index.tsx
Normal file
81
App/FeatureSet/Dashboard/src/Pages/Profiles/View/Index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import Tabs from "Common/UI/Components/Tabs/Tabs";
|
||||
import { Tab } from "Common/UI/Components/Tabs/Tab";
|
||||
import ProfileFlamegraph from "../../../Components/Profiles/ProfileFlamegraph";
|
||||
import ProfileFunctionList from "../../../Components/Profiles/ProfileFunctionList";
|
||||
import ProfileTypeSelector from "../../../Components/Profiles/ProfileTypeSelector";
|
||||
import DiffFlamegraph from "../../../Components/Profiles/DiffFlamegraph";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
|
||||
const ProfileViewPage: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const profileId: string = Navigation.getLastParamAsString(0);
|
||||
const [selectedProfileType, setSelectedProfileType] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
const oneHourAgo: Date = OneUptimeDate.addRemoveHours(now, -1);
|
||||
const twoHoursAgo: Date = OneUptimeDate.addRemoveHours(now, -2);
|
||||
|
||||
const tabs: Array<Tab> = [
|
||||
{
|
||||
name: "Performance Map",
|
||||
children: (
|
||||
<ProfileFlamegraph
|
||||
profileId={profileId}
|
||||
profileType={selectedProfileType}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Hotspots",
|
||||
children: (
|
||||
<ProfileFunctionList
|
||||
profileId={profileId}
|
||||
profileType={selectedProfileType}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Compare",
|
||||
children: (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
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}
|
||||
baselineEndTime={oneHourAgo}
|
||||
comparisonStartTime={oneHourAgo}
|
||||
comparisonEndTime={now}
|
||||
profileType={selectedProfileType}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleTabChange: (tab: Tab) => void = (_tab: Tab): void => {
|
||||
// Tab content is rendered by the Tabs component via children
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<ProfileTypeSelector
|
||||
selectedProfileType={selectedProfileType}
|
||||
onChange={setSelectedProfileType}
|
||||
/>
|
||||
</div>
|
||||
<Tabs tabs={tabs} onTabChange={handleTabChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileViewPage;
|
||||
23
App/FeatureSet/Dashboard/src/Pages/Profiles/View/Layout.tsx
Normal file
23
App/FeatureSet/Dashboard/src/Pages/Profiles/View/Layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getProfilesBreadcrumbs } from "../../../Utils/Breadcrumbs";
|
||||
import { RouteUtil } from "../../../Utils/RouteMap";
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import Page from "Common/UI/Components/Page/Page";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
const ProfilesViewLayout: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const path: string = Navigation.getRoutePath(RouteUtil.getRoutes());
|
||||
return (
|
||||
<Page
|
||||
title="Profile Details"
|
||||
breadcrumbLinks={getProfilesBreadcrumbs(path)}
|
||||
>
|
||||
<Outlet />
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilesViewLayout;
|
||||
@@ -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;
|
||||
22
App/FeatureSet/Dashboard/src/Pages/Service/View/Profiles.tsx
Normal file
22
App/FeatureSet/Dashboard/src/Pages/Service/View/Profiles.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import ProfileTable from "../../../Components/Profiles/ProfileTable";
|
||||
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 ServiceProfiles: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ProfileTable
|
||||
modelId={modelId}
|
||||
noItemsMessage="No profiles found for this service."
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceProfiles;
|
||||
@@ -133,6 +133,28 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
}}
|
||||
icon={IconProp.Graph}
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Performance Profiles",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_PROFILES] as Route,
|
||||
{ modelId: props.modelId },
|
||||
),
|
||||
}}
|
||||
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">
|
||||
|
||||
@@ -5,12 +5,26 @@ import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import ModelFormModal from "Common/UI/Components/ModelFormModal/ModelFormModal";
|
||||
import { FormType } from "Common/UI/Components/Forms/ModelForm";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
|
||||
const StatusPageDelete: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState<boolean>(false);
|
||||
const [refreshMasterPassword, setRefreshMasterPassword] =
|
||||
useState<boolean>(false);
|
||||
const [isMasterPasswordSet, setIsMasterPasswordSet] =
|
||||
useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -55,10 +69,23 @@ const StatusPageDelete: FunctionComponent<
|
||||
cardProps={{
|
||||
title: "Master Password",
|
||||
description:
|
||||
"Rotate the password required to unlock a private status page. This value is stored as a secure hash and cannot be retrieved. When master password is enabled, SSO/SCIM and Email + Password authentication are disabled.",
|
||||
"When enabled, visitors must enter the master password before viewing a private status page. When master password is enabled, SSO/SCIM and Email + Password authentication are disabled. This value is stored as a secure hash and cannot be retrieved.",
|
||||
buttons: [
|
||||
{
|
||||
title: isMasterPasswordSet
|
||||
? "Update Master Password"
|
||||
: "Set Master Password",
|
||||
buttonStyle: ButtonStyleType.NORMAL,
|
||||
onClick: () => {
|
||||
setShowPasswordModal(true);
|
||||
},
|
||||
icon: IconProp.Lock,
|
||||
},
|
||||
],
|
||||
}}
|
||||
editButtonText="Update Master Password"
|
||||
editButtonText="Edit Settings"
|
||||
isEditable={true}
|
||||
refresher={refreshMasterPassword}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
@@ -70,22 +97,11 @@ const StatusPageDelete: FunctionComponent<
|
||||
description:
|
||||
"When enabled, visitors must enter the master password before viewing a private status page.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
masterPassword: true,
|
||||
},
|
||||
title: "Master Password",
|
||||
fieldType: FormFieldSchemaType.Password,
|
||||
required: false,
|
||||
placeholder: "Enter a new master password",
|
||||
description:
|
||||
"Updating this value immediately replaces the existing master password.",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: StatusPage,
|
||||
id: "model-detail-status-page-master-password",
|
||||
id: "model-detail-status-page-enable-master-password",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
@@ -98,22 +114,63 @@ const StatusPageDelete: FunctionComponent<
|
||||
{
|
||||
title: "Master Password",
|
||||
fieldType: FieldType.Element,
|
||||
placeholder: "Hidden",
|
||||
getElement: (): ReactElement => {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
For security reasons, the current master password is never
|
||||
displayed. Use the update button to set a new password at
|
||||
any time.
|
||||
</p>
|
||||
<p>{isMasterPasswordSet ? "Password is set." : "Not set."}</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
onItemLoaded: (item: StatusPage) => {
|
||||
setIsMasterPasswordSet(Boolean(item.masterPassword));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{showPasswordModal && (
|
||||
<ModelFormModal<StatusPage>
|
||||
title={
|
||||
isMasterPasswordSet
|
||||
? "Update Master Password"
|
||||
: "Set Master Password"
|
||||
}
|
||||
onClose={() => {
|
||||
setShowPasswordModal(false);
|
||||
}}
|
||||
submitButtonText="Save"
|
||||
onSuccess={() => {
|
||||
setShowPasswordModal(false);
|
||||
setRefreshMasterPassword(!refreshMasterPassword);
|
||||
setIsMasterPasswordSet(true);
|
||||
}}
|
||||
name="Status Page > Master Password"
|
||||
modelType={StatusPage}
|
||||
formProps={{
|
||||
id: "edit-status-page-master-password-from",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
masterPassword: true,
|
||||
},
|
||||
title: "Master Password",
|
||||
fieldType: FormFieldSchemaType.Password,
|
||||
required: true,
|
||||
placeholder: "Enter a new master password",
|
||||
description:
|
||||
"Updating this value immediately replaces the existing master password.",
|
||||
},
|
||||
],
|
||||
name: "Status Page > Master Password",
|
||||
formType: FormType.Update,
|
||||
modelType: StatusPage,
|
||||
steps: [],
|
||||
doNotFetchExistingModel: true,
|
||||
}}
|
||||
modelIdToEdit={modelId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardModelDetail<StatusPage>
|
||||
name="Status Page > IP Whitelist"
|
||||
cardProps={{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,6 +3,7 @@ export { default as LogsRoutes } from "./LogsRoutes";
|
||||
export { default as MetricsRoutes } from "./MetricsRoutes";
|
||||
export { default as TracesRoutes } from "./TracesRoutes";
|
||||
export { default as ExceptionsRoutes } from "./ExceptionsRoutes";
|
||||
export { default as ProfilesRoutes } from "./ProfilesRoutes";
|
||||
|
||||
// Incident management
|
||||
export { default as IncidentsRoutes } from "./IncidentsRoutes";
|
||||
|
||||
@@ -18,6 +18,8 @@ import DashboardViewSettings from "../Pages/Dashboards/View/Settings";
|
||||
|
||||
import DashboardViewAuthenticationSettings from "../Pages/Dashboards/View/AuthenticationSettings";
|
||||
|
||||
import DashboardViewBranding from "../Pages/Dashboards/View/Branding";
|
||||
|
||||
import DashboardViewCustomDomains from "../Pages/Dashboards/View/CustomDomains";
|
||||
|
||||
const DashboardsRoutes: FunctionComponent<ComponentProps> = (
|
||||
@@ -79,6 +81,16 @@ const DashboardsRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.DASHBOARD_VIEW_BRANDING)}
|
||||
element={
|
||||
<DashboardViewBranding
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.DASHBOARD_VIEW_BRANDING] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(
|
||||
PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS,
|
||||
|
||||
@@ -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={
|
||||
|
||||
80
App/FeatureSet/Dashboard/src/Routes/ProfilesRoutes.tsx
Normal file
80
App/FeatureSet/Dashboard/src/Routes/ProfilesRoutes.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import ComponentProps from "../Pages/PageComponentProps";
|
||||
import ProfilesLayout from "../Pages/Profiles/Layout";
|
||||
import ProfilesViewLayout from "../Pages/Profiles/View/Layout";
|
||||
import PageMap from "../Utils/PageMap";
|
||||
import RouteMap, { RouteUtil, ProfilesRoutePath } from "../Utils/RouteMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
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";
|
||||
|
||||
const ProfilesRoutes: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<Routes>
|
||||
<PageRoute path="/" element={<ProfilesLayout {...props} />}>
|
||||
<PageRoute
|
||||
index
|
||||
element={
|
||||
<ProfilesPage
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.PROFILES] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PageRoute
|
||||
path={ProfilesRoutePath[PageMap.PROFILES_LIST] || ""}
|
||||
element={
|
||||
<ProfilesListPage
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.PROFILES_LIST] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PageRoute
|
||||
path={ProfilesRoutePath[PageMap.PROFILES_DOCUMENTATION] || ""}
|
||||
element={
|
||||
<ProfilesDocumentationPage
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.PROFILES_DOCUMENTATION] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageRoute>
|
||||
|
||||
{/* Profile View */}
|
||||
<PageRoute
|
||||
path={ProfilesRoutePath[PageMap.PROFILE_VIEW] || ""}
|
||||
element={<ProfilesViewLayout {...props} />}
|
||||
>
|
||||
<PageRoute
|
||||
index
|
||||
element={
|
||||
<ProfileViewPage
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.PROFILE_VIEW] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.PROFILE_VIEW)}
|
||||
element={
|
||||
<ProfileViewPage
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.PROFILE_VIEW] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageRoute>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilesRoutes;
|
||||
@@ -24,6 +24,10 @@ import ServiceViewTraces from "../Pages/Service/View/Traces";
|
||||
|
||||
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";
|
||||
@@ -154,6 +158,26 @@ const ServiceRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.SERVICE_VIEW_PROFILES)}
|
||||
element={
|
||||
<ServiceViewProfiles
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.SERVICE_VIEW_PROFILES] as Route}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<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={
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user