Compare commits

...

186 Commits

Author SHA1 Message Date
Nawaz Dhandala
dd37b8a05e fix: update component search tests to handle split titles due to highlighting 2026-04-05 20:07:58 +01:00
Nawaz Dhandala
23f5ffc840 fix: add waitUntil option for navigation in CreateMonitor and CreateProject tests 2026-04-05 20:00:03 +01:00
Nawaz Dhandala
875dbccad3 fix: update search placeholder text for clarity in ComponentsModal tests 2026-04-05 19:57:10 +01:00
Nawaz Dhandala
fb8fa899b0 fix: update comments for clarity on telemetry route mounting during feature-set initialization 2026-04-03 20:46:05 +01:00
Nawaz Dhandala
4bad603db2 fix: update Dockerfile to use Start.dev.sh for development environment 2026-04-03 20:24:41 +01:00
Nawaz Dhandala
720399c8b8 fix: reorganize telemetry route mounting for better clarity and maintainability 2026-04-03 20:14:11 +01:00
Nawaz Dhandala
37e4f28e57 fix: update project dashboard URL regex to include optional home path 2026-04-03 16:33:55 +01:00
Nawaz Dhandala
0502eb5ebe fix: enforce resource request requirements for CPU and memory utilization thresholds in app configuration 2026-04-03 14:37:12 +01:00
Nawaz Dhandala
191569eb3d fix: add Locator type for modal submit button and plan option in project and monitor creation tests 2026-04-03 14:33:17 +01:00
Nawaz Dhandala
2770f9a515 fix: standardize project dashboard URL regex and improve modal button handling in project and monitor creation tests 2026-04-03 14:30:22 +01:00
Nawaz Dhandala
788eeae500 fix: remove duplicate project selection callback in DashboardProjectPicker 2026-04-03 14:23:04 +01:00
Nawaz Dhandala
a8497c497c Push changes 2026-04-03 12:06:42 +01:00
Nawaz Dhandala
b7a4214fa4 fix: add missing declaration for CSS module in TypeScript definitions 2026-04-03 12:01:11 +01:00
Nawaz Dhandala
5e9034dd76 refactor: simplify arrow function syntax and improve readability in dashboard components 2026-04-03 11:43:37 +01:00
Nawaz Dhandala
26bcc69fa2 Enhance Traces Dashboard with latency percentiles and service health metrics
- Added global P50, P95, and P99 latency calculations to the TracesDashboard component.
- Introduced a new formatDuration function to format latency values for display.
- Updated service summaries to include per-service P50 and P95 latencies.
- Improved the layout of the dashboard to display overall requests, error rates, and latency metrics.
- Refactored service health indicators to visually represent error rates with color coding.
- Adjusted the display of recent errors and slow requests for better user experience.
- Updated E2E tests for admin and public dashboards to include response type checks.
2026-04-03 11:34:06 +01:00
Nawaz Dhandala
577d8d2fba feat: improve search functionality in ComponentsModal with scoring and highlighting 2026-04-03 09:59:54 +01:00
Nawaz Dhandala
2b9aaa9929 feat: enhance search functionality in ComponentsModal with improved UI and state management 2026-04-03 09:49:30 +01:00
Nawaz Dhandala
cf166da6de Merge branch 'master' into release 2026-04-03 09:38:46 +01:00
Nawaz Dhandala
92a48f1e17 feat: add status check tests for Admin and Public dashboards, remove obsolete Telemetry status checks 2026-04-03 09:37:35 +01:00
Nawaz Dhandala
f0d0d81a9b fix: update status-check script and README for endpoint consistency 2026-04-03 09:35:33 +01:00
Nawaz Dhandala
a2dc9bf1c8 Merge branch 'master' of https://github.com/OneUptime/oneuptime into codex/fix-ci-10-0-45 2026-04-03 09:22:07 +01:00
Nawaz Dhandala
263d745d0a fix: update GoReleaser action to v6.1.0 and clean up MetricsDashboard component 2026-04-03 09:22:00 +01:00
Simon Larsen
d108cd484e Merge pull request #2384 from OneUptime/chore/npm-audit-fix
chore: npm audit fix
2026-04-03 09:09:57 +01:00
simlarsen
148813786a chore: npm audit fix 2026-04-03 02:38:34 +00:00
Nawaz Dhandala
8101f4a459 feat: add parseBody middleware for handling gzip and protobuf requests in OpenTelemetry routes 2026-04-02 23:46:57 +01:00
Nawaz Dhandala
46a698b4be fix: update nodemon and start scripts to include --no-node-snapshot option; add prepare-native-deps script for isolated-vm management 2026-04-02 23:24:12 +01:00
Nawaz Dhandala
8d07271aa1 chore: update package dependencies and enhance frontend script for missing installations 2026-04-02 23:16:06 +01:00
Nawaz Dhandala
f5ef80e544 refactor: remove unnecessary blank lines in app initialization 2026-04-02 22:00:54 +01:00
Nawaz Dhandala
292a37397d refactor: streamline database connection logic in app initialization 2026-04-02 21:50:12 +01:00
Nawaz Dhandala
abb3942c44 refactor: clean up code formatting and remove unused imports in Metrics components 2026-04-02 21:38:14 +01:00
Nawaz Dhandala
10d09ac4af chore: update version to 10.0.45 2026-04-02 21:19:49 +01:00
Nawaz Dhandala
64c31e9e7a Merge branch 'release' of https://github.com/OneUptime/oneuptime into release 2026-04-02 21:00:09 +01:00
Nawaz Dhandala
d64194c18e fix: correct local time formatting in OneUptimeDate class 2026-04-02 20:59:45 +01:00
Simon Larsen
2d13a52287 Merge pull request #2383 from OneUptime/master
Release
2026-04-02 20:58:21 +01:00
Nawaz Dhandala
a54234609f feat: implement MetricsDashboard and MetricsListPage components, update routing and descriptions for improved clarity 2026-04-02 19:10:10 +01:00
Nawaz Dhandala
214c9e013c feat: enhance empty state visualization in TracesDashboard with improved SVG graphics 2026-04-02 18:35:11 +01:00
Nawaz Dhandala
b0c9de4d82 feat: add Exceptions page and integrate with routing and side menu 2026-04-02 18:20:36 +01:00
Nawaz Dhandala
e98b424168 refactor: improve code readability by formatting and using optional chaining for req.body 2026-04-02 18:11:42 +01:00
Nawaz Dhandala
7521fe218d feat: remove unused OneUptimeDate import from ExceptionsDashboard component 2026-04-02 17:53:42 +01:00
Nawaz Dhandala
1f3d85d7a1 feat: add PublicDashboard volume to docker-compose for development 2026-04-02 17:39:35 +01:00
Nawaz Dhandala
058c52f79d feat: update protobufjs version and add gRPC dependencies 2026-04-02 17:35:08 +01:00
Nawaz Dhandala
8af6e48d70 feat: add ExceptionsOverview page and dashboard components, update routing and breadcrumbs for exceptions 2026-04-02 17:28:01 +01:00
Nawaz Dhandala
7569a50c56 feat: add TracesDashboard and TracesListPage components, update routing and breadcrumbs for traces 2026-04-02 17:24:19 +01:00
Nawaz Dhandala
20f314512d feat: add ProfilesDashboard and ProfilesListPage components, update routing and breadcrumbs for profiles 2026-04-02 17:15:42 +01:00
Nawaz Dhandala
cdbbcdfe27 refactor: update proto file paths to use relative directory resolution 2026-04-02 15:23:13 +01:00
Nawaz Dhandala
4e2ca87752 refactor: improve readability of messages and code formatting in various components 2026-04-02 14:33:46 +01:00
Nawaz Dhandala
54a79a8100 feat: implement combined queue size metrics for KEDA autoscaling 2026-04-02 14:29:39 +01:00
Nawaz Dhandala
eb4010dfa5 feat: add CPU and memory utilization metrics for KEDA autoscaling 2026-04-02 14:23:39 +01:00
Nawaz Dhandala
407d4e3687 feat: add KEDA autoscaling configuration for worker and telemetry queue metrics 2026-04-02 14:20:57 +01:00
Nawaz Dhandala
6f7907102b refactor: remove telemetry hostname references and update backend proxy settings 2026-04-02 14:09:26 +01:00
Nawaz Dhandala
5f398bdb31 Add utility classes for telemetry: Monitor, StackTrace, and Syslog parsing
- Implemented MonitorUtil for managing monitor secrets and populating them in monitor steps and tests.
- Created StackTraceParser to parse and structure stack traces from various programming languages.
- Developed SyslogParser to handle and parse syslog messages in both RFC 5424 and RFC 3164 formats.
2026-04-02 14:04:13 +01:00
Nawaz Dhandala
69c6b332c1 Merge branch 'master' of https://github.com/OneUptime/oneuptime 2026-04-02 12:37:07 +01:00
Nawaz Dhandala
e15a934b3f refactor: update terminology and improve messaging for performance profiling components 2026-04-02 12:37:03 +01:00
Simon Larsen
3a62729c03 Merge pull request #2381 from OneUptime/merge-workflow
Merge workflow
2026-04-02 12:31:31 +01:00
Nawaz Dhandala
23da31b50c fix: update iOS code signing parameters in release workflow for improved build stability 2026-04-02 12:24:30 +01:00
Nawaz Dhandala
4e33cd7c1b fix: ensure page stability after project creation by waiting for network to be idle 2026-04-02 09:02:44 +01:00
Simon Larsen
d97f17b1cf Merge pull request #2382 from OneUptime/master
Release
2026-04-01 22:22:40 +01:00
Nawaz Dhandala
4bdf9943e4 feat: add data-testid attribute to radio button labels for improved testing 2026-04-01 22:21:43 +01:00
Nawaz Dhandala
a4c5be8665 feat: Integrate GoReleaser for Terraform provider build and publish process; enhance publish script for better asset management 2026-04-01 22:10:13 +01:00
Nawaz Dhandala
ea71c8bd75 feat: Implement Workflow API and Queue Management
- Added ManualAPI for manually triggering workflows via GET and POST requests.
- Introduced WorkflowAPI for updating workflows with authorization checks.
- Created documentation for JavaScript and Webhook components.
- Established WorkflowFeatureSet to initialize routing and job processing.
- Developed QueueWorkflow service for managing workflow queue operations.
- Implemented RunWorkflow service to execute workflows with error handling and logging.
- Added utility for loading component metadata dynamically.
2026-04-01 22:05:19 +01:00
Nawaz Dhandala
043707d0cb fix: update blog repo clone command to use --depth 1 for faster cloning 2026-04-01 19:52:32 +01:00
Nawaz Dhandala
991916b2de chore: bump version to 10.0.44 2026-04-01 18:54:28 +01:00
Nawaz Dhandala
5d3885c8a5 feat: enhance MonitorCustomMetrics to include ListResult type for API response; improve query documentation 2026-04-01 18:54:11 +01:00
Nawaz Dhandala
da44cd34f8 feat: update MonitorCustomMetrics to fetch custom metrics from AnalyticsModelAPI; enhance metric name extraction logic 2026-04-01 18:49:57 +01:00
Nawaz Dhandala
ffa2d3f008 refactor: clean up code formatting and improve readability across multiple components 2026-04-01 18:18:20 +01:00
Nawaz Dhandala
d8aea2627b feat: add MonitorCustomMetrics component for displaying custom metrics; enhance MonitorMetrics to include custom metrics tab 2026-04-01 15:35:11 +01:00
Nawaz Dhandala
9756f5a117 feat: update ChartGroup button styles for improved accessibility and visual consistency 2026-04-01 15:11:47 +01:00
Nawaz Dhandala
c8cd97437e feat: update UptimeBarTooltip styles with adjusted font sizes and weights for improved readability 2026-04-01 15:08:43 +01:00
Nawaz Dhandala
249241dfd4 feat: enhance UptimeBarTooltip with improved layout, color adjustments, and incident display; optimize status breakdown and tooltip styles 2026-04-01 15:07:20 +01:00
Nawaz Dhandala
16e2c2cb39 feat: enhance UptimeBarTooltip with improved color handling, layout adjustments, and incident display; optimize status breakdown and tooltip styles 2026-04-01 15:03:24 +01:00
Nawaz Dhandala
ecbca3208f feat: add onIncidentClick handler to various components for incident navigation; enhance Tooltip with animation support 2026-04-01 14:58:58 +01:00
Nawaz Dhandala
505c143ddf feat: enhance MetricCharts and ChartGroup with metric info handling and modal display; update UptimeBarTooltip styles and Tooltip theme 2026-04-01 14:45:21 +01:00
Nawaz Dhandala
c4aab31056 feat: enhance DayUptimeGraph and UptimeBarTooltip with status duration handling and improved tooltip display 2026-04-01 14:40:34 +01:00
Nawaz Dhandala
cdb63031d8 feat: add custom metrics capturing functionality in custom code and synthetic monitors 2026-04-01 14:34:05 +01:00
Nawaz Dhandala
464455eff3 feat: implement captured metrics handling in custom code and synthetic monitors 2026-04-01 14:30:01 +01:00
Nawaz Dhandala
c7cfd7aa67 feat: update isolated-vm dependency to version 6.1.2 2026-04-01 14:23:31 +01:00
Nawaz Dhandala
832b87e6d5 feat: implement incident handling in uptime graphs with tooltips and modals for better user experience 2026-04-01 12:42:00 +01:00
Nawaz Dhandala
678e9614bf feat: add NoDataMessage component and integrate it into AreaChart, BarChart, and LineChart for improved no data handling 2026-04-01 12:14:27 +01:00
Nawaz Dhandala
ac6c53ad85 feat: refactor AreaChart, BarChart, and LineChart components to handle no data state more effectively 2026-04-01 12:12:25 +01:00
Nawaz Dhandala
22bf4de6fd feat: add no data available message to AreaChart, BarChart, and LineChart components 2026-04-01 12:06:42 +01:00
Nawaz Dhandala
dacf71a75d feat: add chartType property to MetricQueryConfigData for MonitorAlertMetrics and MonitorIncidentMetrics 2026-04-01 11:44:58 +01:00
Nawaz Dhandala
213c755f97 feat: add support for DateTime64 type in value parsing for improved data handling 2026-04-01 11:27:04 +01:00
Nawaz Dhandala
ac39602ef6 feat: enhance Metrics and Incident services with Search integration for improved data handling 2026-04-01 09:31:05 +01:00
Nawaz Dhandala
848fd2c30b fix: correct order of sentences in AGENTS.md for clarity 2026-04-01 09:09:26 +01:00
Nawaz Dhandala
63dd84339e feat: update Pyroscope endpoints and documentation for improved profiling integration 2026-03-31 22:50:13 +01:00
Nawaz Dhandala
e3ca08c69f Implement feature X to enhance user experience and optimize performance 2026-03-31 20:33:28 +01:00
Nawaz Dhandala
3276ab3641 refactor: remove DisableTelemetry check from Profiling initialization 2026-03-31 20:18:35 +01:00
Nawaz Dhandala
675cfa4682 feat: add enableProfiling flag to multiple components for enhanced profiling support 2026-03-31 20:00:33 +01:00
Nawaz Dhandala
f28306ce68 refactor: remove unused telemetry disable flags from configuration 2026-03-31 15:58:43 +01:00
Nawaz Dhandala
9b9ac62c77 feat: add id prop to BasicForm container for improved accessibility 2026-03-31 14:25:53 +01:00
Nawaz Dhandala
574cac7d64 refactor: clean up code formatting and improve readability across multiple files 2026-03-31 14:06:05 +01:00
Nawaz Dhandala
414f7cebc7 feat: update telemetry documentation and replace OTLP URLs with OneUptime base URL 2026-03-31 14:02:50 +01:00
Nawaz Dhandala
e30f2587e8 feat: integrate Pyroscope for performance profiling
- Added @pyroscope/nodejs dependency to package.json and package-lock.json.
- Implemented Pyroscope API routes in Telemetry/Index.ts.
- Created new Pyroscope API handler in Telemetry/API/Pyroscope.ts to manage profile ingestion.
- Defined pprof protocol buffer schema in Telemetry/ProtoFiles/pprof/profile.proto.
- Developed PyroscopeIngestService for processing and converting pprof profiles to OTLP format.
- Enhanced error handling and request validation in the Pyroscope ingestion process.
2026-03-31 13:55:14 +01:00
Nawaz Dhandala
d7a339b9aa feat: Add profiling support across services and implement new metrics
- Integrated profiling initialization in Probe, Telemetry, TestServer, and Worker services.
- Added environment variables for enabling profiling in various services.
- Created Profiling utility to handle CPU profiling and send data to OTLP endpoint.
- Introduced new metric types for exceptions, spans, and dashboards.
- Developed utility classes for handling alert and incident metrics.
- Added new React components for displaying alert and incident metrics in the dashboard.
2026-03-31 13:44:59 +01:00
Nawaz Dhandala
fe5329a1aa feat: add new dashboard templates for Metrics, Trace, Exception, and Profiles 2026-03-31 12:38:13 +01:00
Nawaz Dhandala
043ddebc6c feat: add webhook secret key functionality to workflows and update related components 2026-03-31 12:22:17 +01:00
Nawaz Dhandala
67b9d245ec feat: enhance resource display with dynamic table headers in Kubernetes analysis 2026-03-31 12:07:55 +01:00
Nawaz Dhandala
856e1f4715 chore: update version to 10.0.43 2026-03-31 11:55:06 +01:00
Nawaz Dhandala
72da710326 feat: add provisioning profile UUID to environment for iOS build 2026-03-31 11:44:47 +01:00
Nawaz Dhandala
9fc6871a1f feat: add user authorization middleware to workflow run endpoints 2026-03-31 11:39:31 +01:00
Nawaz Dhandala
7add10642f feat: add E2E tests for monitor and project creation workflows
chore: update ClickHouse config to disable no_password authentication
2026-03-31 11:20:58 +01:00
Nawaz Dhandala
34b6c198cb chore: update version to 10.0.42 2026-03-31 08:16:08 +01:00
Nawaz Dhandala
3dda45d2cc feat: add profile data ingestion to metered plan reporting 2026-03-31 08:15:28 +01:00
Nawaz Dhandala
2fd7ede52f feat: add validation for SAML Assertion length in response handling 2026-03-30 21:52:52 +01:00
Nawaz Dhandala
599e8dda1d feat: add system log retention configuration for ClickHouse with TTL settings 2026-03-30 21:27:40 +01:00
Nawaz Dhandala
21062dab44 feat: enhance cleanup CronJob configuration with concurrency and history limits 2026-03-30 19:23:31 +01:00
Nawaz Dhandala
3477593e11 feat: add ServiceAccount for cleanup CronJob 2026-03-30 19:18:22 +01:00
Nawaz Dhandala
d8ec86adb3 Refactor code structure for improved readability and maintainability 2026-03-30 17:19:21 +01:00
Nawaz Dhandala
64f21ac8b1 feat: add PublicDashboard feature set and update ComponentsModal test for improved readability 2026-03-30 17:18:00 +01:00
Nawaz Dhandala
e953b33703 feat: add PublicDashboard feature set and update placeholder text in ComponentsModal tests 2026-03-30 17:08:32 +01:00
Nawaz Dhandala
ffafada55b refactor: improve code readability and consistency across dashboard components 2026-03-30 16:17:16 +01:00
Nawaz Dhandala
4caed413a3 feat: add metric query configuration and enhance dashboard components with metric support 2026-03-30 15:55:25 +01:00
Nawaz Dhandala
594c5a7fc3 feat: enhance dashboard templates with detailed descriptions and add new components for metrics visualization 2026-03-30 15:49:12 +01:00
Nawaz Dhandala
2845177743 feat: update icon for Monitor Dashboard template to Heartbeat 2026-03-30 15:40:12 +01:00
Nawaz Dhandala
75b2d63353 feat: update button style and icon for template creation in Dashboards 2026-03-30 15:38:52 +01:00
Nawaz Dhandala
b5a5cf8b40 feat: update layout of DashboardTemplateCard for improved icon display and alignment 2026-03-30 15:38:32 +01:00
Nawaz Dhandala
cc68ea4539 feat: implement modal for template selection in Dashboards and update service to handle default dashboard config 2026-03-30 15:36:11 +01:00
Nawaz Dhandala
02c0c02760 feat: update BlankDashboardUnit styling to conditionally apply border based on edit mode 2026-03-30 15:25:31 +01:00
Nawaz Dhandala
ae230589c5 feat: simplify styling for BlankCanvas and BlankDashboardUnit components by removing edit mode specific styles 2026-03-30 15:24:29 +01:00
Nawaz Dhandala
a0577b0175 feat: update DashboardChartComponent styling for better layout and overflow handling 2026-03-30 15:22:22 +01:00
Nawaz Dhandala
472ebed3be feat: update chart height calculation to include widget header and adjust per-chart overhead 2026-03-30 15:03:04 +01:00
Nawaz Dhandala
796c52da4d feat: Add dashboard template selection and creation functionality
- Introduced DashboardTemplateCard component for displaying dashboard templates.
- Added DashboardTemplates enum and DashboardTemplate interface to define available templates.
- Implemented template selection in the Dashboards page to allow users to create dashboards from predefined templates.
- Enhanced MetricQueryConfig to manage metric attributes and display settings more effectively.
- Updated MetricView to improve loading states and error handling for metric results.
- Refactored DashboardChartComponent to streamline metric alias data handling and improve UI presentation.
2026-03-30 14:54:54 +01:00
Nawaz Dhandala
3a19e600d5 feat: update chart height calculation to account for overhead and improve minimum height constraint 2026-03-30 14:45:02 +01:00
Nawaz Dhandala
b847d3a0b9 feat: adjust chart height calculation for multiple charts and update overflow behavior 2026-03-30 14:38:12 +01:00
Nawaz Dhandala
9f09eacf25 feat: add reference line support to charts and implement value formatting utility 2026-03-30 14:25:37 +01:00
Nawaz Dhandala
809a85c91d feat: enhance resolveQueryConfigs to support combining primary and multiple queries 2026-03-30 14:02:04 +01:00
Nawaz Dhandala
38ff1ae0c7 feat: prefill legend and unit in MetricGraphConfig on metric change 2026-03-30 13:56:02 +01:00
Nawaz Dhandala
194bb87b45 feat: enhance MetricQueryConfig with warning and critical threshold inputs and improve layout in ArgumentsForm and MetricAlias components 2026-03-30 13:50:04 +01:00
Nawaz Dhandala
26c402928e feat: add warning and critical threshold inputs to MetricGraphConfig and update MetricQueryConfigData interface 2026-03-30 13:45:59 +01:00
Nawaz Dhandala
e0fe6e9827 feat: add Gauge icon and update related components for enhanced dashboard functionality 2026-03-30 13:33:21 +01:00
Nawaz Dhandala
0269593326 fix: update button size to normal for improved usability in BaseModelTable 2026-03-30 13:25:45 +01:00
Nawaz Dhandala
13d33b6df3 fix: update button style to normal for improved usability in BaseModelTable 2026-03-30 13:24:27 +01:00
Nawaz Dhandala
2c7a560aee fix: update button style CSS class for hover state to enhance visual consistency 2026-03-30 13:21:43 +01:00
Nawaz Dhandala
b8e0f0de91 fix: update button style CSS class for improved appearance on hover 2026-03-30 13:20:38 +01:00
Nawaz Dhandala
27ad3d6b99 refactor: improve code formatting and readability in various files 2026-03-30 12:07:18 +01:00
Nawaz Dhandala
e655385c4d feat: add permission checks to phone number API routes 2026-03-30 12:04:05 +01:00
Nawaz Dhandala
9adbd04538 feat: add user authentication middleware to notification API routes 2026-03-30 09:50:40 +01:00
Nawaz Dhandala
6ef8cc6db6 feat: restructure feature grid into categorized sections for better organization 2026-03-30 09:32:49 +01:00
Nawaz Dhandala
1c12f516ff Merge branch 'master' of https://github.com/OneUptime/oneuptime 2026-03-30 09:08:22 +01:00
Nawaz Dhandala
9e9c7743f4 feat: update type handling and add ProfileMonitorResponse support in telemetry monitor 2026-03-30 09:08:21 +01:00
Simon Larsen
5bfd6ebd3d Merge pull request #2377 from AndyLCQ/doc-fix-get-version
docs: Update API endpoint in terraform registry documentation
2026-03-29 21:27:50 +01:00
Andy
78d608a6cf docs: Update API endpoint in terraform registry documentation 2026-03-28 14:09:48 +01:00
Nawaz Dhandala
5155858f67 Add monitoring documentation for various types of monitors
- Created DNS Monitor documentation to outline DNS health checks and configuration options.
- Added Domain Monitor documentation for tracking domain registration status and expiration.
- Introduced Exceptions Monitor documentation for monitoring application exceptions and error patterns.
- Added Incoming Request Monitor documentation for heartbeat monitoring via service pings.
- Created IP Monitor documentation for monitoring the availability of IPv4 and IPv6 addresses.
- Added Kubernetes Monitor documentation for monitoring cluster health and performance metrics.
- Introduced Logs Monitor documentation for monitoring application logs and triggering alerts.
- Created Manual Monitor documentation for manually managed monitor statuses.
- Added Metrics Monitor documentation for monitoring custom application and infrastructure metrics.
- Introduced Ping Monitor documentation for monitoring host availability via ICMP requests.
- Created Port Monitor documentation for monitoring specific TCP/UDP port availability.
- Added Profiles Monitor documentation for monitoring continuous profiling data from applications.
- Introduced SSL Certificate Monitor documentation for monitoring SSL/TLS certificate validity and expiration.
- Created Traces Monitor documentation for monitoring distributed traces and alerting on span patterns.
2026-03-27 16:14:35 +00:00
Nawaz Dhandala
862682388e feat: add Server / VM Monitor documentation and navigation links 2026-03-27 16:08:45 +00:00
Nawaz Dhandala
308bade79e feat: add AI Agent card to hero feature grid and remove AI Copilot and Auto-Fix references 2026-03-27 14:22:32 +00:00
Nawaz Dhandala
a41dfa8980 Add AI Auto-Fix and AI Copilot hero cards
- Introduced a new hero card for AI Auto-Fix with a fuchsia theme, linking to the AI agent product page.
- Added a hero card for AI Copilot with a violet theme, also linking to the AI agent product page, utilizing an icon for visual representation.
2026-03-27 14:20:03 +00:00
Nawaz Dhandala
1a8fee15b8 feat: add Kubernetes and Profiles sections, update Scheduled Maintenance references to Maintenance 2026-03-27 13:50:57 +00:00
Nawaz Dhandala
7e4efeaeaa feat: add Scheduled Maintenance feature and update product showcase
- Updated product showcase to include a new feature card for Scheduled Maintenance with relevant details and icons.
- Modified product tabs to include a new tab for Scheduled Maintenance, ensuring it integrates with existing navigation.
- Enhanced features table to accommodate the Scheduled Maintenance feature card, expanding the grid layout for better visibility.
2026-03-27 13:45:43 +00:00
Nawaz Dhandala
2d007b8676 feat: Enhance product showcase and tabs with Kubernetes and Profiles sections, update footer links, and improve Bitcoin logo animation 2026-03-27 13:36:01 +00:00
Nawaz Dhandala
0ba3a70a4b feat: Add ProfileService and ProfileSampleService to AnalyticsServices 2026-03-27 13:18:47 +00:00
Nawaz Dhandala
8672f442db chore: clean up empty code change sections in the changes log 2026-03-27 13:13:13 +00:00
Nawaz Dhandala
e0f1da768b Refactor and clean up code across multiple components and services
- Simplified useEffect dependencies in ProfileTimeline component.
- Streamlined option rendering in ProfileTypeSelector component.
- Reformatted TelemetryDocumentation component for better readability.
- Improved formatting and consistency in AuthenticationSettings and Branding pages.
- Removed unused imports in ProfileViewPage.
- Enhanced code readability in StatusPageDelete component.
- Consolidated RouteMap definitions for better clarity.
- Cleaned up API response handling in PublicDashboard and DashboardViewPage.
- Refactored ProfileSample and DashboardAPI for improved code structure.
- Updated migration scripts for better readability and consistency.
- Enhanced incident and profile aggregation services for better clarity.
- Cleaned up telemetry ingestion service for improved readability.
- Improved test cases for OtelProfilesIngestService for better clarity.
2026-03-27 13:01:32 +00:00
Nawaz Dhandala
71b8891232 feat: Add profile monitoring functionality and related components
- Implemented ProfileMonitor in MonitorTelemetryMonitor.ts to handle profile monitoring.
- Created DiffFlamegraph component for visualizing profile differences.
- Added ProfileTimeline component to display profile data over time.
- Introduced ProfileTypeSelector for selecting profile types.
- Developed ServiceProfiles page to display profiles for a specific service.
- Added ProfileMonitorCriteria for evaluating profile monitoring criteria.
- Implemented PprofEncoder for encoding profile data into pprof-compatible format.
- Defined ProfileMonitorResponse interface for structured profile monitoring responses.
2026-03-27 12:53:44 +00:00
Nawaz Dhandala
a48e8a2710 Refactor Dashboard Canvas and Units for Improved Layout and Performance
- Updated BlankCanvas.tsx to utilize new dashboard size utilities for dynamic unit sizing and spacing.
- Enhanced BlankDashboardUnit.tsx to simplify unit rendering and remove unnecessary props.
- Modified BlankRow.tsx to eliminate redundant props and streamline unit rendering.
- Refactored Index.tsx to implement grid layout using CSS properties for better responsiveness.
- Improved DashboardBaseComponent.tsx by removing unused pixel helpers and optimizing drag/resize logic.
- Updated OpenTelemetry profiles roadmap documentation to reflect completed phases and remaining tasks.
2026-03-27 12:26:59 +00:00
Nawaz Dhandala
465cc798ec feat: Integrate ProfileSampleService into BaseAPIFeatureSet for enhanced profile analytics 2026-03-27 12:13:17 +00:00
Nawaz Dhandala
0130a850ca feat: Integrate ProfileService into BaseAPIFeatureSet and update AutoRefreshDropdown label for clarity 2026-03-27 12:11:43 +00:00
Nawaz Dhandala
526eb756b1 feat: Add buttonSize property to CardButtonSchema and update button styles in BaseModelTable for consistency 2026-03-27 12:07:04 +00:00
Nawaz Dhandala
59a9636870 feat: Update component styles for improved UI consistency and add Alloy integration for profiling 2026-03-27 12:02:58 +00:00
Nawaz Dhandala
a994c7b7b8 feat: Enhance AutoRefreshDropdown and MoreMenu components for improved UI interaction 2026-03-27 11:55:46 +00:00
Nawaz Dhandala
dc44e92867 feat: Implement AutoRefreshDropdown component for enhanced auto-refresh settings in DashboardToolbar 2026-03-27 11:48:39 +00:00
Nawaz Dhandala
4a0151243f feat: Add CountdownCircle component for auto-refresh functionality in DashboardToolbar 2026-03-27 11:45:35 +00:00
Nawaz Dhandala
e06b9a95ce feat: Add dashboard description state and display in toolbar 2026-03-27 11:44:01 +00:00
Nawaz Dhandala
3fd22cd3fb feat: Update Button component style for improved transparency in icon buttons 2026-03-27 11:39:15 +00:00
Nawaz Dhandala
3c8dc1eee1 feat: Add ProfileTable component for displaying profiling data
- Implemented ProfileTable component to visualize profiles with advanced filtering options.
- Integrated telemetry services and attributes loading for dynamic filtering.
- Added error handling and loading states for improved user experience.

feat: Create ProfileUtil for stack frame parsing and formatting

- Introduced ProfileUtil class with methods for frame type color coding and stack frame parsing.
- Added utility functions for formatting profile values based on units.

docs: Add documentation for telemetry profiles integration

- Created comprehensive guide on sending continuous profiling data to OneUptime.
- Included supported profile types, setup instructions, and instrumentation examples.

feat: Implement ProfileAggregationService for flamegraph and function list retrieval

- Developed ProfileAggregationService to aggregate profile samples and generate flamegraphs.
- Added functionality to retrieve top functions based on various metrics.

feat: Define MonitorStepProfileMonitor interface for profile monitoring

- Created MonitorStepProfileMonitor interface and utility for building queries based on monitoring parameters.

test: Add example OTLP profiles payload for testing

- Included example JSON payload for OTLP profiles to assist in testing and integration.
2026-03-27 11:11:33 +00:00
Nawaz Dhandala
c91c653d9c feat: Add metrics for postmortem completion time and severity changes in IncidentService 2026-03-27 10:49:35 +00:00
Nawaz Dhandala
086f01617c feat: Enhance loading states in dashboard components with improved visibility and initial load handling 2026-03-27 10:43:51 +00:00
Nawaz Dhandala
1d78ec8922 feat: Add Profiles feature with routing, documentation, and UI components
- Implemented ProfilesRoutes for navigating to profiles-related pages.
- Created Profiles page with service count and documentation display.
- Added Profiles documentation page for ingestion setup.
- Developed Profile view page for detailed profiling information.
- Introduced Profiles layout and side menu for navigation.
- Enhanced breadcrumbs for profiles navigation.
- Updated telemetry type to include profiles.
- Refactored ArgumentsForm to improve UI for additional queries.
- Adjusted DashboardToolbar and DashboardView styles for consistency.
2026-03-27 10:38:13 +00:00
Nawaz Dhandala
5ecf8ce881 feat: Update background gradient in BlankCanvas and modify border style in BlankDashboardUnit 2026-03-27 10:19:50 +00:00
Nawaz Dhandala
147ff47aa2 feat: Add ProfilesService and ingestion service for OpenTelemetry profiles
- Introduced `profiles_service.proto` to define the ProfilesService for exporting resource profiles.
- Implemented `OtelProfilesIngestService` to handle ingestion of profiles, including processing and flushing to the database.
- Created `ProfilesQueueService` to manage profile ingestion jobs.
- Added comprehensive tests for `OtelProfilesIngestService`, covering stack frame resolution, timestamp parsing, and row building for profiles and samples.
2026-03-27 10:15:55 +00:00
Nawaz Dhandala
a1122ed241 feat: Organize component arguments into sections and enhance dashboard components with new features 2026-03-26 21:59:16 +00:00
Nawaz Dhandala
72a796c03d feat: Update dashboard canvas background color and refactor file handling in DashboardAPI 2026-03-26 21:41:22 +00:00
Nawaz Dhandala
bec1c760ca feat: Enhance DashboardBaseComponent with drag-and-drop functionality and interaction mode management
feat: Update AuthenticationSettings to prevent fetching existing model on edit
feat: Modify StatusPageDelete to display master password status and prevent fetching existing model on edit
feat: Expand OpenTelemetry Profiles roadmap with detailed implementation steps and considerations
2026-03-26 21:32:11 +00:00
Nawaz Dhandala
b939b4ebf0 feat: Implement master password functionality for dashboards and status pages
- Added modal for setting and updating master passwords in DashboardAuthenticationSettings and StatusPageDelete components.
- Updated UI elements to reflect the master password status and provide appropriate actions.
- Enhanced descriptions to clarify the security implications of the master password feature.
- Refactored API calls in DashboardAPI to simplify logo and favicon file handling.
- Updated OpenTelemetry profiles documentation to include new message structures and important migration notes.
2026-03-26 21:28:59 +00:00
Nawaz Dhandala
50717e5167 feat: Implement OpenTelemetry Profiles roadmap documentation 2026-03-26 21:22:15 +00:00
Nawaz Dhandala
4b339f07ec feat: Update master password settings in Authentication and Status Page components 2026-03-26 21:14:12 +00:00
Nawaz Dhandala
e9be1c0898 refactor: Rearrange settings nav item position in DashboardNavbar 2026-03-26 21:09:52 +00:00
Nawaz Dhandala
b4dc6f1f02 refactor: Remove redundant SideMenuSection for Custom Domains in DashboardSideMenu 2026-03-26 21:07:04 +00:00
Nawaz Dhandala
ad6ac1a480 feat: Add migration for dashboard constraints and page description updates 2026-03-26 21:05:17 +00:00
Nawaz Dhandala
af3004394e feat: Add branding features to dashboard including title, description, logo, and favicon 2026-03-26 20:12:48 +00:00
Nawaz Dhandala
028212731f feat: Add public view-config endpoint for dashboard with access control 2026-03-26 19:55:16 +00:00
Nawaz Dhandala
7419ff4437 feat: Enhance public dashboard functionality with access control and UI updates 2026-03-26 19:41:48 +00:00
Nawaz Dhandala
5b579fa55c refactor: remove public-dashboard location block for cleaner configuration 2026-03-26 17:01:23 +00:00
Nawaz Dhandala
f0ed6ae29f Merge branch 'new-dash' 2026-03-26 16:51:44 +00:00
Simon Larsen
16e1d5ccf3 Merge pull request #2373 from OneUptime/new-dash
New dash
2026-03-26 16:51:21 +00:00
Simon Larsen
8e671a9a41 Merge pull request #2372 from OneUptime/chore/npm-audit-fix
chore: npm audit fix
2026-03-26 07:45:23 +00:00
simlarsen
02e7506f89 chore: npm audit fix 2026-03-26 02:38:46 +00:00
566 changed files with 28469 additions and 19159 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

@@ -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={() => {

View File

@@ -53,6 +53,12 @@ export default class ComponentInputTypeToFormFieldType {
};
}
if (componentInputType === ComponentInputType.MetricsQueryConfigs) {
return {
fieldType: FormFieldSchemaType.CustomComponent,
};
}
if (componentInputType === ComponentInputType.Dropdown) {
return {
fieldType: FormFieldSchemaType.Dropdown,

View File

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

View File

@@ -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={() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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