Compare commits

...

546 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
Nawaz Dhandala
98efe09cc1 feat: Add startsAt parameter to markMonitorsActiveForMonitoring and MonitorStatusTimelineService for improved timeline tracking 2026-03-26 16:49:35 +00:00
Nawaz Dhandala
b1c2fdc33f feat: Update Sparkline component to use props for data and dimensions; enhance CriteriaFilter with MetricsViewConfig type 2026-03-26 16:45:27 +00:00
Nawaz Dhandala
d9c7259356 Refactor code for improved readability and consistency
- Simplified arrow function syntax in MasterPassword.tsx and DashboardAPI.ts
- Consolidated logger.debug statements in PublicDashboard.ts and DashboardDomainAPI.ts
- Reformatted multi-line statements for better clarity in various files
- Updated migration files for consistent naming conventions and formatting
- Enhanced code structure in DashboardDomainService.ts and MonitorTelemetryMonitor.ts
- Incremented version number to 10.0.40
2026-03-26 16:41:11 +00:00
Nawaz Dhandala
b5bf1d6dd1 feat: Introduce DashboardCNameRecord for custom domain handling in dashboards 2026-03-26 16:38:06 +00:00
Nawaz Dhandala
c92e259978 feat: Enhance DashboardLogStreamComponent with attribute filtering and update CustomDomains description 2026-03-26 16:35:37 +00:00
Nawaz Dhandala
5601cc5591 feat: Add PublicDashboard frontend configuration and custom domain handling 2026-03-26 16:27:04 +00:00
Nawaz Dhandala
c0fe56f54d feat: Implement message block batching for Microsoft Teams and Slack to adhere to payload limits 2026-03-26 16:21:36 +00:00
Nawaz Dhandala
d316c1dceb feat: Update uptime percentage and status history descriptions to reflect configurable settings 2026-03-26 16:04:52 +00:00
Nawaz Dhandala
7cb70278d5 feat: Add MigrationName1774524742178 to schema migrations 2026-03-26 16:02:53 +00:00
Nawaz Dhandala
f4e9bfbca1 feat(migration): add showUptimeHistoryInDays column to StatusPage table 2026-03-26 16:02:33 +00:00
Nawaz Dhandala
4be33e6a0a feat: Add showUptimeHistoryInDays property with access control and default value to StatusPage model 2026-03-26 15:59:52 +00:00
Nawaz Dhandala
2da9ea272f feat: Add "All" option to WorkspaceNotificationSummaryItem and update filtering logic in WorkspaceSummaryTable 2026-03-26 15:57:39 +00:00
Nawaz Dhandala
f4fb951757 feat: Enhance acknowledgment logic to use resolve time if not explicitly acknowledged 2026-03-26 15:53:22 +00:00
Nawaz Dhandala
969d435447 feat: Add DashboardPreviewLink component for public dashboard preview functionality 2026-03-26 15:44:34 +00:00
Nawaz Dhandala
0edc50ae9f feat: Enhance DashboardViewPage with NavBar and footer for improved navigation and branding 2026-03-26 15:00:51 +00:00
Nawaz Dhandala
87e34b0abf feat: Add Public Dashboard FeatureSet with core components and pages
- Implemented DashboardCanvas component for rendering dashboard.
- Created Index.tsx for application entry point with routing setup.
- Developed AllPages.tsx to export main pages: DashboardView, MasterPassword, NotFound, and Forbidden.
- Added DashboardVariableSelector for managing dashboard variables.
- Built DashboardViewPage to display the dashboard with variable selection and auto-refresh functionality.
- Created Forbidden and NotFound pages for access control and error handling.
- Implemented MasterPassword page for secure access to dashboards.
- Added server-side utility for fetching public dashboard data.
- Established API utility for handling requests specific to the public dashboard.
- Configured routing and page mapping for public dashboard navigation.
- Set up TypeScript configuration for the PublicDashboard FeatureSet.
- Created index.ejs for rendering the public dashboard application.
2026-03-26 14:48:45 +00:00
Nawaz Dhandala
1caff6844e refactor: Update Node component styles for improved layout and visual consistency 2026-03-26 14:18:38 +00:00
Nawaz Dhandala
2c12fcaa0f feat: Add DashboardDomain migration with table creation and constraints 2026-03-26 11:33:48 +00:00
Nawaz Dhandala
a62ba231be feat: Add DashboardDomain model and associated API services
- Introduced DashboardDomain model with comprehensive fields for managing custom domains for dashboards.
- Implemented DashboardDomainAPI for handling CNAME verification and SSL provisioning.
- Created DashboardDomainService to manage domain-related operations, including SSL ordering and CNAME validation.
- Added master password handling in DashboardAPI for enhanced security.
- Defined constants for master password messages and cookie management.
2026-03-26 11:32:06 +00:00
Nawaz Dhandala
46c150f6df feat: implement background email sending with improved user feedback 2026-03-26 08:09:35 +00:00
Nawaz Dhandala
9f8891de88 refactor: Update Component Port and Return Value Viewers for improved styling and accessibility
- Enhanced styling for ComponentPortViewer and ComponentReturnValueViewer components, including updated typography and layout.
- Replaced ErrorMessage with inline messages for better user experience when no ports or return values are present.
- Improved the visual hierarchy and spacing in the ComponentSettingsModal, adding sections for Identity, Documentation, Configuration, Connections, and Output.
- Refactored ComponentsModal to streamline component selection and improve search functionality with better UI elements.
- Updated Workflow component styles for a more modern look, including adjustments to edge styles and background settings.
2026-03-26 07:47:44 +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
Nawaz Dhandala
7fac485049 feat: add Log Stream and Trace List components to dashboard with configuration options 2026-03-25 22:54:52 +00:00
Nawaz Dhandala
467921e899 improve ui 2026-03-25 22:35:36 +00:00
Nawaz Dhandala
31e1290ecb feat: enhance dashboard UI with improved styling and error handling for components 2026-03-25 22:22:29 +00:00
Nawaz Dhandala
4cc78175ef feat: implement new dashboard widgets and features including Gauge, Table, and enhanced chart capabilities 2026-03-25 22:07:55 +00:00
Nawaz Dhandala
feb86357e8 refactor: remove redundant fetchAggregatedResults calls in dashboard components 2026-03-25 22:02:33 +00:00
Nawaz Dhandala
fb3c767056 fix: update pod phase attribute key in Kubernetes alert template 2026-03-25 21:49:07 +00:00
Nawaz Dhandala
be90693ad8 feat: Add Kubernetes resource breakdown to MetricMonitorResponse
- Introduced KubernetesAffectedResource and KubernetesResourceBreakdown interfaces to enhance metric monitoring capabilities.
- Updated MetricMonitorResponse to include kubernetesResourceBreakdown.
- Enhanced AreaChart, BarChart, and LineChart components with onValueChange prop for better interactivity.
- Added new Dashboard components: Gauge and Table, with respective configuration arguments.
- Implemented DashboardVariableSelector for dynamic variable selection in dashboards.
- Added warning and critical thresholds to DashboardValueComponent and DashboardChartComponent for improved data visualization.
- Updated DashboardChartComponentUtil to support additional queries and chart types.
- Enhanced error handling and loading states in Dashboard components.
2026-03-25 21:38:41 +00:00
Nawaz Dhandala
d0ef353993 feat: enhance dashboard capabilities with unified query plugin interface and support for Perses/Grafana import/export 2026-03-25 21:17:48 +00:00
Nawaz Dhandala
f2c0b8461f chore: bump version to 10.0.39 2026-03-25 20:29:41 +00:00
Nawaz Dhandala
4fe8aea655 refactor: improve code readability and comments in IfElse and Condition components 2026-03-25 20:29:24 +00:00
Nawaz Dhandala
923e020e6e feat: enhance condition handling with input type support and value coercion 2026-03-25 20:25:19 +00:00
Nawaz Dhandala
178fa45dd8 feat: add SSO documentation for identity providers 2026-03-25 20:05:11 +00:00
Nawaz Dhandala
19b5bc8348 refactor: update comments for clarity in signup route 2026-03-25 19:50:51 +00:00
Nawaz Dhandala
7f30600c71 feat: allow signup for invited users even when signup is disabled 2026-03-25 19:45:55 +00:00
Nawaz Dhandala
93291858f9 feat: disable home feature in values.yaml 2026-03-25 19:36:59 +00:00
Nawaz Dhandala
14a925e98c chore: bump version to 10.0.38 2026-03-25 19:26:21 +00:00
Nawaz Dhandala
d5a136a662 refactor: clean up code formatting and improve readability across multiple components 2026-03-25 19:08:11 +00:00
Nawaz Dhandala
e0ae2701ba feat: enable Markdown formatting for announcement email messages 2026-03-25 19:04:22 +00:00
Nawaz Dhandala
fa68e3961e feat: add common headers to API request in WorkspaceSummaryTable 2026-03-25 19:00:44 +00:00
Nawaz Dhandala
2e9118e123 feat: improve filter visibility logic by conditionally displaying advanced filters based on loading state 2026-03-25 15:57:24 +00:00
Nawaz Dhandala
f2f53b7cea feat: convert newline characters to HTML line breaks in email messages 2026-03-25 13:51:48 +00:00
Nawaz Dhandala
5cb48400a2 feat: enhance Kubernetes service mesh metrics view with Istio and Linkerd support
- Introduced new metric query specifications for Istio and Linkerd.
- Refactored metric data fetching to utilize a unified query builder.
- Added a global time range picker for metrics visualization.
- Created reusable MeshSection component for displaying metrics.
- Updated UI to present Istio and Linkerd metrics in organized sections.
2026-03-25 13:19:33 +00:00
Nawaz Dhandala
006e54535a feat(charts): update XAxis precision thresholds for improved readability 2026-03-25 13:09:08 +00:00
Nawaz Dhandala
845b45cceb feat(charts): enhance ChartGroup to conditionally render charts with or without cards 2026-03-25 12:57:49 +00:00
Nawaz Dhandala
208e8b1fdb feat: replace RangeStartAndEndDateEdit with RangeStartAndEndDateView and wrap metrics in Card component in KubernetesMetricsTab and MonitorMetrics 2026-03-25 12:54:15 +00:00
Nawaz Dhandala
d03056be2d feat: replace time range selection with RangeStartAndEndDateEdit component in KubernetesMetricsTab and MonitorMetrics 2026-03-25 12:33:03 +00:00
Nawaz Dhandala
5a6193c4e0 feat(charts): add AreaChart component and update chart types
- Introduced AreaChart component for area visualization.
- Updated MetricCharts to use AREA chart type instead of LINE for non-bar charts.
- Enhanced ChartGroup to support AreaChart alongside existing Line and Bar charts.
- Modified ChartColors utility to include hex values for color mapping.
- Adjusted LineChart to utilize dynamic curve types based on props.
- Refactored KubernetesYamlTab for improved styling and responsiveness.
- Updated tooltip and legend functionalities for better user interaction.
2026-03-25 12:24:04 +00:00
Nawaz Dhandala
4e30a863b2 fix: correct SQL syntax for attribute extraction in LogAggregationService 2026-03-25 12:08:16 +00:00
Nawaz Dhandala
2561117445 feat: add support for log query attributes and enhance active filter management in LogsViewer 2026-03-25 12:00:37 +00:00
Nawaz Dhandala
27e65caef2 feat: add refresh functionality to Kubernetes resource tables 2026-03-25 11:47:40 +00:00
Nawaz Dhandala
0eb096ca8f feat: enhance Kubernetes components with sorting and status management for containers and environment variables 2026-03-25 11:44:14 +00:00
Nawaz Dhandala
25cde457a5 refactor: clean up code formatting and improve readability in various components 2026-03-24 23:52:33 +00:00
Nawaz Dhandala
cd36071311 feat: implement cleanK8sObject function to remove noisy internal fields from Kubernetes objects 2026-03-24 22:29:13 +00:00
Nawaz Dhandala
b4ce1e0c55 feat: refactor DictionaryOfStringsViewer to use a flex layout instead of a table for better responsiveness 2026-03-24 22:18:52 +00:00
Nawaz Dhandala
fc582bc547 feat: deduplicate recent warning events in Kubernetes cluster overview 2026-03-24 22:08:17 +00:00
Nawaz Dhandala
5a0cf0f988 feat: update icon in Kubernetes cluster overview to represent CPU usage 2026-03-24 22:01:14 +00:00
Nawaz Dhandala
d283be898f feat: enhance Kubernetes resource listings by adding missing cronjobs, daemonsets, deployments, and jobs from k8s objects 2026-03-24 21:57:44 +00:00
Nawaz Dhandala
d2385a83cf feat: enhance Kubernetes cluster overview with fallback counts for resources and improve stateful set handling 2026-03-24 21:46:18 +00:00
Nawaz Dhandala
6b7b27be00 feat: enhance Kubernetes cluster status display with connection indicators 2026-03-24 20:39:45 +00:00
Nawaz Dhandala
a1aceec9ec feat: remove 'Provider' field from Kubernetes cluster views and settings 2026-03-24 20:36:33 +00:00
Nawaz Dhandala
a8988346f7 feat: refactor KubernetesClusterEvents to use new table component and enhance filtering capabilities 2026-03-24 20:21:26 +00:00
Nawaz Dhandala
d3865d94a6 feat: add additional resource counts and health metrics to KubernetesClusterOverview 2026-03-24 20:16:06 +00:00
Nawaz Dhandala
c470d66725 feat: enhance handling of camelCase and snake_case in Kubernetes object parsing and telemetry 2026-03-24 20:11:42 +00:00
Nawaz Dhandala
e12e3cfc08 feat: enhance Kubernetes monitoring plan with comprehensive infrastructure details and implementation phases 2026-03-24 18:44:57 +00:00
Nawaz Dhandala
24db673926 fix: improve type annotation for barRef in GanttChartBar component 2026-03-24 16:45:32 +00:00
Nawaz Dhandala
3a65405401 fix: improve type annotation for barRef in GanttChartBar component 2026-03-24 15:41:17 +00:00
Nawaz Dhandala
caf533d0c0 feat: update tooltip rendering in GanttChartBar and increment version to 10.0.37 2026-03-24 15:39:06 +00:00
Nawaz Dhandala
323d9993bf feat: add tooltip support for GanttChartBar with portal rendering 2026-03-24 15:36:00 +00:00
Nawaz Dhandala
cbcda7a36f feat: enhance SpanViewer integration in TraceExplorer with improved span selection handling 2026-03-24 15:34:03 +00:00
Nawaz Dhandala
b434f1fef8 feat: enhance workspace notification summary handling with improved error logging and retry logic 2026-03-24 14:31:29 +00:00
Nawaz Dhandala
20d21a40b7 feat: add debug and error logging for ACME challenge validation and certificate ordering 2026-03-24 14:18:49 +00:00
Nawaz Dhandala
633ffde611 refactor: improve type definitions and error handling in various components 2026-03-24 14:00:24 +00:00
Nawaz Dhandala
fe668d808d refactor: streamline code formatting and improve readability across multiple files 2026-03-24 13:36:06 +00:00
Nawaz Dhandala
c10cefb4e1 fix: update optional properties in TimelineData interface and refactor alert/incident state references 2026-03-24 13:34:28 +00:00
Nawaz Dhandala
f93964b71b feat: enhance workspace notification summary service with filtering and formatting improvements
- Added support for notification rule conditions and filters in incident and alert summaries.
- Introduced utility functions for bold text and links in markdown formatting.
- Updated incident and alert value builders to include labels and descriptions.
- Enhanced summary blocks with improved formatting for total counts, severity, and state breakdowns.
- Refactored detailed incident and alert listings to use new formatting utilities.
- Improved overall readability and consistency of markdown output for notifications.
2026-03-24 13:27:10 +00:00
Nawaz Dhandala
66a43d322c refactor: remove sendFirstReportAt from service data structure 2026-03-24 13:03:51 +00:00
Nawaz Dhandala
6c4d283761 feat: Add sendFirstReportAt column and update defaults for OnCallDutyPolicyScheduleLayer
- Introduced a new column `sendFirstReportAt` to the `WorkspaceNotificationSummary` table.
- Updated default values for `rotation` and `restrictionTimes` columns in the `OnCallDutyPolicyScheduleLayer` table.
- Adjusted the logic in SendSummary.ts to calculate the next send time based on the recurring interval before updating the nextSendAt field.
2026-03-24 13:03:16 +00:00
Nawaz Dhandala
7695c08d1a feat(email): update API routes for sending test and broadcast emails to admin 2026-03-24 12:41:27 +00:00
Nawaz Dhandala
7d39a36526 refactor(Email): hide submit button for sending announcement email 2026-03-24 12:29:35 +00:00
Nawaz Dhandala
9806425721 feat: Add Workspace Notification Summary API and Service
- Implemented WorkspaceNotificationSummaryAPI to handle notification summary requests.
- Created WorkspaceNotificationSummaryService for business logic related to notification summaries.
- Added enums for WorkspaceNotificationSummaryItem and WorkspaceNotificationSummaryType to define summary items and types.
- Developed a cron job to send workspace notification summaries at regular intervals.
- Enhanced error handling and logging for summary sending process.
2026-03-24 12:25:51 +00:00
Nawaz Dhandala
29c2bbbf57 feat(email): add Send Email page and integrate with navigation 2026-03-24 09:05:33 +00:00
Nawaz Dhandala
296dc9c81e feat(schema): add serviceMesh configuration for observability with Istio and Linkerd 2026-03-24 09:04:37 +00:00
Nawaz Dhandala
758b6c0b5b Merge branch 'master' of https://github.com/OneUptime/oneuptime 2026-03-23 11:45:04 +00:00
Nawaz Dhandala
8ed94c0172 refactor(Kubernetes): improve regex usage for resource value formatting and YAML conversion 2026-03-23 11:37:37 +00:00
Nawaz Dhandala
660d4cb155 fix(tabs): update hasInitialized type to MutableRefObject for consistency
fix(telemetry): add KubernetesResourceFilters import and type annotation
2026-03-23 11:34:05 +00:00
Nawaz Dhandala
442622c2be chore: update version to 10.0.36 and refactor various components for improved type safety and consistency 2026-03-23 11:31:45 +00:00
Nawaz Dhandala
6d15cc8e8b Refactor and clean up Kubernetes view components
- Improved formatting and consistency in various Kubernetes view files, including HPADetail, HPAs, Index, Jobs, Layout, Namespaces, NodeDetail, Nodes, PersistentVolumes, Pods, ServiceMesh, SideMenu, StatefulSets, VPADetail, and VPAs.
- Removed unnecessary line breaks and adjusted indentation for better readability.
- Simplified conditional checks and array methods for clarity.
- Enhanced the overall structure of the code to follow best practices.
2026-03-23 11:24:21 +00:00
Nawaz Dhandala
61ea40a23a Merge branch 'master' of https://github.com/OneUptime/oneuptime 2026-03-23 10:13:44 +00:00
Nawaz Dhandala
910d7d0066 fix(cron): update condition for enabling e2e cron job 2026-03-23 10:11:25 +00:00
Nawaz Dhandala
5656ad2e62 Merge branch 'master' of https://github.com/OneUptime/oneuptime 2026-03-22 10:14:22 +00:00
Nawaz Dhandala
e383a32e6e fix(docs): update OneUptime Helm repository URL in installation scripts 2026-03-22 10:14:20 +00:00
Nawaz Dhandala
da5cc1877d refactor(monitor): remove unused Kubernetes form mode state and related logic 2026-03-20 19:00:45 +00:00
Nawaz Dhandala
d421caff2e feat(clickhouse): add max_suspicious_broken_parts configuration to ClickHouse 2026-03-20 18:51:59 +00:00
Nawaz Dhandala
4f8b4593a7 feat(clickhouse): add init container for dynamic configuration generation 2026-03-20 18:43:46 +00:00
Nawaz Dhandala
16c6776675 Merge branch 'master' of https://github.com/OneUptime/oneuptime 2026-03-20 14:49:34 +00:00
Nawaz Dhandala
4d7bbb323e fix: Remove unnecessary annotations from postgresql StatefulSet 2026-03-20 14:49:31 +00:00
Nawaz Dhandala
fa771f73f5 feat(kubernetes): streamline on-call policy dropdowns for alerts and incidents 2026-03-20 13:55:19 +00:00
Nawaz Dhandala
d62da19308 feat(kubernetes): add on-call policy dropdown options to Kubernetes criteria form 2026-03-20 13:45:10 +00:00
Nawaz Dhandala
d47b43f7b2 feat(dropdown): optimize getDropdownOptionsFromArray to ensure unique values 2026-03-20 13:39:11 +00:00
Nawaz Dhandala
01d2b7d0a3 feat(tabs): refactor tab state management to use tab names for improved clarity 2026-03-20 13:34:02 +00:00
Nawaz Dhandala
582b464623 feat(kubernetes): add simplified criteria form and mode change handling 2026-03-20 12:22:56 +00:00
Nawaz Dhandala
f2d138d0d7 feat: Enhance Kubernetes Monitor Configuration with Template and Metric Selection
- Added KubernetesTemplatePicker and KubernetesMetricPicker components for selecting alert templates and metrics.
- Updated KubernetesMonitorStepForm to support quick setup, custom metrics, and advanced configurations.
- Integrated query parameters for pre-filling template and cluster selections.
- Improved user experience with tabs for different setup modes (Quick, Custom, Advanced).
- Updated descriptions and functionality in MonitorStep and Alerts pages to reflect new features.
- Introduced KubernetesMetricCatalog for managing Kubernetes metrics and their properties.
2026-03-20 12:07:33 +00:00
Nawaz Dhandala
70e6924cdd feat(kubernetes): add service mesh and VPA management features
- Implemented KubernetesClusterServiceMesh component to display Istio and Linkerd metrics.
- Created KubernetesClusterVPADetail component for detailed views of Vertical Pod Autoscalers (VPA).
- Added KubernetesClusterVPAs component to list all VPAs in a cluster.
- Introduced KubernetesAlertTemplates for monitoring various Kubernetes conditions, including CrashLoopBackOff, Pod Pending, Node Not Ready, and more.
- Developed MonitorStepKubernetesMonitor interface for Kubernetes monitoring configurations.
2026-03-20 11:08:47 +00:00
Nawaz Dhandala
81eb16c1b7 feat: Add KubernetesResourceLink component for improved resource navigation in Kubernetes views 2026-03-20 10:28:04 +00:00
Nawaz Dhandala
c6534fb515 feat: Enhance resource value formatting in KubernetesContainersTab for better readability 2026-03-20 10:22:17 +00:00
Nawaz Dhandala
ac3d169eef feat: Add Kubernetes Volume Mounts Tab for enhanced container volume management 2026-03-20 10:18:32 +00:00
Nawaz Dhandala
a854db7564 feat: Enhance Kubernetes Dashboard with new LocalTable components for better data presentation 2026-03-20 10:09:36 +00:00
Nawaz Dhandala
e54c3effd1 Merge branch 'master' of https://github.com/OneUptime/oneuptime 2026-03-20 09:57:41 +00:00
Nawaz Dhandala
2278843667 feat: Enhance Kubernetes Dashboard with Status Badges and Environment Variables Tab
- Added StatusBadge component to display the status of Kubernetes namespaces, nodes, PVCs, PVs, and StatefulSets with appropriate color coding.
- Implemented a new KubernetesEnvVarsTab component to show environment variables for containers, including search functionality and handling of secret values.
- Refactored existing code in NamespaceDetail, NodeDetail, PVCDetail, PVDetail, PodDetail, and StatefulSetDetail to utilize StatusBadge for improved status representation.
- Updated Pods component to correctly parse memory limits from container resources.
2026-03-20 09:57:36 +00:00
Simon Larsen
3d0c9ff1be Merge pull request #2364 from OneUptime/chore/npm-audit-fix
chore: npm audit fix
2026-03-20 08:51:26 +00:00
Nawaz Dhandala
2ef7988598 feat: Refactor Kubernetes Dashboard components with new UI elements
- Added StatusBadge component for consistent status representation across the dashboard.
- Introduced AlertBanner component for displaying cluster health and node pressure alerts.
- Implemented FilterButtons component for improved event filtering options.
- Created ResourceUsageBar for visualizing resource usage in pods.
- Developed StackedProgressBar for displaying pod health breakdown.
- Added ConditionsTable for displaying conditions with improved styling and functionality.
- Enhanced existing components (DeploymentDetail, Events, Index, PodDetail) to utilize new UI components and improve overall user experience.
2026-03-20 08:20:03 +00:00
simlarsen
f97c753a72 chore: npm audit fix 2026-03-20 02:26:23 +00:00
Nawaz Dhandala
4f67228eaf feat: Enhance Kubernetes Dashboard with resource consumption insights and YAML views
- Added top CPU and memory pod consumers to the Kubernetes cluster overview.
- Implemented node pressure indicators for memory, disk, and PID pressure.
- Introduced recent warning events section in the cluster overview.
- Added YAML tab for viewing resource specifications in Job, Namespace, Node, Pod, PVC, PV, and StatefulSet details.
- Updated side menu to display resource counts for various Kubernetes objects.
- Enhanced node detail view with roles, internal IP, and pressure conditions.
- Improved error handling and loading states in the YAML tab component.
2026-03-19 22:33:41 +00:00
Nawaz Dhandala
b10d2f458e fix(security): sanitize markdown HTML output to prevent XSS (GHSA-h5pr-pg74-99m2)
Replace inadequate <script> tag regex with proper sanitization:
- Server-side: override marked's html renderer to escape raw HTML tokens
- Client-side: sanitize preview output with DOMPurify before rendering
2026-03-19 22:16:56 +00:00
Nawaz Dhandala
8a54e2beac feat(kubernetes): add filter button to KubernetesResourceTable and enhance data filtering logic 2026-03-19 20:17:04 +00:00
Nawaz Dhandala
a0f5a5bc5a feat(kubernetes): implement client-side filtering for Kubernetes resources; add filter modal and dynamic filter options 2026-03-19 19:59:57 +00:00
Nawaz Dhandala
cef15e5938 feat(kubernetes): add PVC and PV routes to KubernetesRoutes component; update service name in configmaps 2026-03-19 19:29:54 +00:00
Nawaz Dhandala
ff0a2e9c91 feat(kubernetes): add support for Persistent Volume Claims (PVCs) and Persistent Volumes (PVs)
- Implemented KubernetesClusterPVCDetail and KubernetesClusterPVCs components to display PVC details and list.
- Created KubernetesClusterPVs component to list all Persistent Volumes in the cluster.
- Updated routing and page mapping for PVCs and PVs.
- Enhanced existing Kubernetes components to utilize new utility functions for better data handling.
- Removed unused InfoCard components for a cleaner UI.
- Added yAxisValueFormatter to metric queries for better chart representation.
- Updated Helm chart to include PVC and PV data collection.
2026-03-19 19:27:57 +00:00
Nawaz Dhandala
db1ce405f5 fix: Remove hardcoded event domain from fetchLatestK8sObject attributes 2026-03-19 18:51:38 +00:00
Nawaz Dhandala
b721c1ba80 fix: Fetch and display dynamic node, pod, and namespace counts in Kubernetes cluster overview 2026-03-19 18:41:13 +00:00
Nawaz Dhandala
7e98e6d7ae fix: Update endpoint in kubeletstats to use NODE_IP and add NODE_IP environment variable 2026-03-19 18:34:28 +00:00
Nawaz Dhandala
fb8126d5d6 fix: Update iOS provisioning profile handling and add error check for IOS_TEAM_ID 2026-03-19 18:33:05 +00:00
Nawaz Dhandala
17e786f88e feat: Add CLAUDE.md for local development server instructions 2026-03-19 18:16:08 +00:00
Nawaz Dhandala
1d186c2f49 fix: Refactor merge_docker_manifests.sh to streamline multi-arch manifest creation 2026-03-19 14:06:47 +00:00
Nawaz Dhandala
537ac1eb2e fix: Update divisibilityFactor type to use DivisibilityFactor from SpanUtil 2026-03-19 12:35:51 +00:00
Nawaz Dhandala
6fba944b11 fix: Correct migration ordering so KubernetesCluster table is created before ALTER
TypeORM sorts migrations by the timestamp in the last 13 chars of the
class name. Migration 1773761409952 (ALTER TABLE) had a lower timestamp
than 1774000000000 (CREATE TABLE), causing it to run first on a
non-existent table and crash the app during startup.

Renamed 1773761409952 → 1774000000001 so the CREATE TABLE migration
executes first.
2026-03-19 10:37:34 +00:00
Nawaz Dhandala
a4787121b3 chore: Bump version to 10.0.35 2026-03-19 09:45:13 +00:00
Nawaz Dhandala
2aab01bde6 Merge branch 'master' of https://github.com/OneUptime/oneuptime 2026-03-19 09:34:42 +00:00
Nawaz Dhandala
e8e4ee3ff0 feat: Enhance BLOCKED_SANDBOX_PROPERTIES with additional browserType aliases for improved clarity 2026-03-19 09:34:12 +00:00
Simon Larsen
8b0926413e Merge pull request #2363 from OneUptime/k8s-impl
K8s impl
2026-03-19 09:26:20 +00:00
Nawaz Dhandala
3efacce002 Refactor and clean up code across multiple components
- Simplified error handling in KubernetesEventsTab and KubernetesLogsTab by removing unnecessary line breaks.
- Consolidated import statements in KubernetesMetricsTab for better readability.
- Improved JSX formatting in KubernetesOverviewTab, KubernetesClusterContainerDetail, and other components for consistency.
- Enhanced code clarity in KubernetesObjectFetcher and KubernetesObjectParser by removing redundant line breaks and comments.
- Streamlined API response handling in IPWhitelistAPI for better readability.
- Updated PageSEO configuration for improved formatting.
2026-03-19 09:25:52 +00:00
Nawaz Dhandala
f84df20610 feat: Update Icon component and Kubernetes icon for improved styling and consistency 2026-03-19 09:17:18 +00:00
Nawaz Dhandala
36041cef6a feat: Add JSON encoding to OTLP HTTP exporter configuration in configmap deployment 2026-03-19 09:09:37 +00:00
Nawaz Dhandala
e814027048 feat: Update Icon component stroke properties and enhance configmap deployment settings for telemetry 2026-03-19 09:06:35 +00:00
Nawaz Dhandala
0161bac994 feat: Update Kubernetes detail views to use Navigation.getLastParamAsString for improved parameter handling
feat: Increase memory limits in Helm chart configuration for better resource management
feat: Add resourceSpecs configuration to values schema for enhanced dashboard detail views
2026-03-19 08:41:25 +00:00
Nawaz Dhandala
dc3db1ec47 Refactor code structure for improved readability and maintainability 2026-03-19 08:15:35 +00:00
Nawaz Dhandala
139aa83fe4 feat: Revamp Kubernetes overview page with enhanced UI components and updated resource metrics display 2026-03-18 22:21:10 +00:00
Nawaz Dhandala
50d5514fea chore: clean up empty code change sections in the changes log 2026-03-18 22:09:07 +00:00
Nawaz Dhandala
220cfa2d28 feat: Enhance Kubernetes configuration with kubeletstats receiver and update daemonset tolerations 2026-03-18 21:54:14 +00:00
Nawaz Dhandala
10d0237747 feat: Add metric view data initialization and update query configurations for Kubernetes detail views 2026-03-18 21:48:10 +00:00
Nawaz Dhandala
7708d791b1 fix: Update resource attribute keys for Kubernetes metrics 2026-03-18 21:27:46 +00:00
Nawaz Dhandala
6eb7b98002 feat: Implement session revocation on password reset and change 2026-03-18 21:21:41 +00:00
Nawaz Dhandala
6860033586 feat: Enhance KubernetesResourceTable with dynamic column rendering and integrate Table component 2026-03-18 21:14:56 +00:00
Nawaz Dhandala
27b94fdbaf docs: Update IP whitelist documentation with programmatic fetching instructions 2026-03-18 21:10:16 +00:00
Nawaz Dhandala
c4903e5d1c feat: Add IP whitelist API and configuration support 2026-03-18 21:00:37 +00:00
Nawaz Dhandala
a2c8022442 fix: Update default hoursBack value to 24 in fetchResourceList options 2026-03-18 20:53:31 +00:00
Nawaz Dhandala
7cc6e81fe6 fix: Update hoursBack default value to 1 in fetchResourceList options 2026-03-18 19:56:30 +00:00
Nawaz Dhandala
758aab5f17 fix: Update default hoursBack value to 24 in fetchResourceList options 2026-03-18 19:56:00 +00:00
Nawaz Dhandala
6af7f24d1b Revert "refactor: Update Kubernetes resource attribute names for consistency"
This reverts commit 3ddd5658a1.
2026-03-18 19:52:38 +00:00
Nawaz Dhandala
3ddd5658a1 refactor: Update Kubernetes resource attribute names for consistency 2026-03-18 19:41:48 +00:00
Nawaz Dhandala
5a87333275 Add Kubernetes resource views for DaemonSets, Deployments, Jobs, and StatefulSets
- Implemented DaemonSets view with resource fetching and metrics display.
- Created DeploymentDetail page to show metrics for specific deployments.
- Added Deployments view to list all deployments in a Kubernetes cluster.
- Developed JobDetail page for displaying metrics related to jobs.
- Introduced Jobs view to list all jobs in the cluster.
- Created NamespaceDetail page to show metrics for specific namespaces.
- Added Namespaces view to list all namespaces in the cluster.
- Implemented StatefulSetDetail page for metrics related to stateful sets.
- Created StatefulSets view to list all stateful sets in the cluster.
2026-03-18 19:28:29 +00:00
Nawaz Dhandala
5b5b0e8d54 fix: Update PostgreSQL hbaConfiguration to allow local connections for database initialization 2026-03-18 19:03:56 +00:00
Nawaz Dhandala
a5d74ae76a feat: Add Kubernetes menu item to the Dashboard navbar 2026-03-18 18:55:39 +00:00
Simon Larsen
0e0829bdd7 Merge pull request #2360 from OneUptime/k8s-impl
feat: Add Kubernetes Cluster Management and Monitoring Agent
2026-03-18 18:54:29 +00:00
Nawaz Dhandala
e8fe9a0f0d refactor: Comment out Kubernetes menu item in the Dashboard navbar 2026-03-18 18:54:07 +00:00
Nawaz Dhandala
deb873c832 refactor: Enhance type definitions and improve code clarity across multiple components and files 2026-03-18 14:40:44 +00:00
Nawaz Dhandala
305fa4a476 Refactor and enhance various components and routes in the Dashboard and MobileApp
- Improved formatting and readability in Kubernetes PodDetail and SideMenu components.
- Added service count fetching and loading/error handling in Logs, Metrics, and Traces pages.
- Updated Exception and Kubernetes routes for better readability.
- Enhanced Postgres migration scripts for KubernetesCluster and KubernetesClusterLabel.
- Minor formatting adjustments in MarkdownViewer and CriticalPath utility.
- Refactored EmptyState, MonitorSummaryView, and hooks in MobileApp for improved clarity.
- Fixed minor issues in MonitorDetailScreen and MonitorsScreen regarding status display.
2026-03-18 14:21:39 +00:00
Nawaz Dhandala
65a4132081 fix: Simplify error response handling in API class 2026-03-18 13:13:33 +00:00
Simon Larsen
5e15bf1bdc Merge pull request #2361 from OneUptime/chore/npm-audit-fix
chore: npm audit fix
2026-03-18 13:12:25 +00:00
Nawaz Dhandala
4375e1c8fd feat: Implement Kubernetes documentation card and enhance telemetry features in dashboard pages 2026-03-18 13:10:49 +00:00
Nawaz Dhandala
2f76fd3bcd feat: Add JSON schema for Kubernetes agent configuration 2026-03-18 12:56:53 +00:00
Nawaz Dhandala
38e617432f feat: Enhance Helm chart packaging and linting for OneUptime and Kubernetes Agent 2026-03-18 12:55:22 +00:00
Nawaz Dhandala
bfbe3fe050 feat: Replace select element with Dropdown component for ingestion key selection 2026-03-18 12:43:43 +00:00
Nawaz Dhandala
c3ffc681bd chore: Remove CLAUDE.md file and its gstack skills documentation 2026-03-18 12:19:40 +00:00
Nawaz Dhandala
f9b22fa0cd feat: Add gstack skills configuration to CLAUDE.md
Configure gstack skills for the team including /browse for web browsing,
/review, /ship, /qa, and other workflow skills.
2026-03-18 12:17:04 +00:00
Nawaz Dhandala
34d1f0a04c fix: Update OTLP host computation logic in TelemetryDocumentation component 2026-03-18 12:16:37 +00:00
Nawaz Dhandala
e0b530a323 fix: Update hasData state initialization and condition for TelemetryDocumentation display 2026-03-18 12:11:05 +00:00
Nawaz Dhandala
17839a819f feat: Add onClose handler to TelemetryDocumentation and improve documentation display logic in LogsPage 2026-03-18 12:04:27 +00:00
Nawaz Dhandala
0446f55a9c feat: Add Documentation section to side menus and update telemetry log collector visibility 2026-03-18 12:01:29 +00:00
Nawaz Dhandala
ad999313c3 feat: Add Exceptions documentation page and update routing, breadcrumbs, and side menu 2026-03-18 11:55:17 +00:00
Nawaz Dhandala
ffc49d83eb feat: Add documentation pages for Metrics and Traces, enhance LogsViewer and Metrics components with documentation links 2026-03-18 11:41:13 +00:00
Nawaz Dhandala
3df5640463 feat: Update OpenTelemetry exporter configurations to use HTTP protocol for improved compatibility 2026-03-18 11:24:48 +00:00
Nawaz Dhandala
e4a76117b1 feat: Enhance telemetry services with Kubernetes cluster auto-discovery and health check configuration 2026-03-18 11:20:13 +00:00
Nawaz Dhandala
4cba330605 feat: Enhance Telemetry documentation with additional language support and improved ingestion key handling 2026-03-18 11:13:47 +00:00
Nawaz Dhandala
a8d1c90b33 feat: Improve Markdown blockquote styling and enhance inline code handling 2026-03-18 10:27:12 +00:00
Nawaz Dhandala
3c2847ed10 feat: Enhance telemetry exception handling and documentation UI with ingestion key management and improved cluster details editing 2026-03-18 10:23:33 +00:00
Nawaz Dhandala
ef7ce703dd feat: Implement cluster count fetching and enhance Kubernetes monitoring UI with installation guidance 2026-03-18 09:11:20 +00:00
Nawaz Dhandala
2d56a56650 feat: Enhance Kubernetes monitoring with installation documentation, telemetry integration, and UI improvements across logs, metrics, and traces pages 2026-03-18 08:57:21 +00:00
Nawaz Dhandala
a2ac43baab feat: Add dashboard launch configuration and enhance TelemetryDocumentation with integration method selection and improved styling 2026-03-18 08:36:22 +00:00
Nawaz Dhandala
a26532538a feat: Enhance Kubernetes icon representation with a 7-sided helm wheel design and dynamic spoke rendering 2026-03-18 08:14:27 +00:00
simlarsen
1bc82ef3b9 chore: npm audit fix 2026-03-18 02:32:43 +00:00
Nawaz Dhandala
7b978f1885 feat: Update TelemetryDocumentation component to accept telemetryType prop for better context in exception, log, and metric pages; enhance Kubernetes icon representation 2026-03-17 20:40:46 +00:00
Nawaz Dhandala
e6861a2abe feat: Add Kubernetes documentation and mobile apps download section; update navigation and icons 2026-03-17 19:40:30 +00:00
Nawaz Dhandala
6d98893b8e refactor: Remove unused metric queries and related components from KubernetesClusterOverview 2026-03-17 18:47:39 +00:00
Nawaz Dhandala
6026f5cb81 fix: Correct parameter usage in Navigation.getLastParamAsObjectID for KubernetesClusterOverview 2026-03-17 16:32:08 +00:00
Nawaz Dhandala
103a49facc feat: Add KubernetesCluster API integration to BaseAPIFeatureSet 2026-03-17 15:44:21 +00:00
Simon Larsen
5d13911026 Merge pull request #2358 from mvanhorn/fix/mobile-uptime-history-chart
fix: show uptime history bar chart on mobile status page
2026-03-17 15:34:58 +00:00
Simon Larsen
d873306b33 Merge pull request #2357 from simao-silva/master
ci: Allow loading of Slack App configuration from existing secret
2026-03-17 15:34:15 +00:00
Nawaz Dhandala
af7a3a9286 feat: Add migration for KubernetesCluster and KubernetesClusterLabel constraints and indexes 2026-03-17 15:31:07 +00:00
Nawaz Dhandala
bc9949abe4 feat: Add Kubernetes Cluster Management and Monitoring Agent
- Implemented a new migration for the KubernetesCluster and KubernetesClusterLabel tables in the database.
- Created a KubernetesClusterService for managing cluster instances, including methods for finding or creating clusters, updating their status, and marking disconnected clusters.
- Introduced a Helm chart for the OneUptime Kubernetes Monitoring Agent, including configuration files, deployment templates, and RBAC settings.
- Added support for collecting metrics and logs from Kubernetes clusters using OpenTelemetry.
- Configured service accounts, secrets, and resource limits for the agent's deployment and daemonset.
- Provided detailed notes and helper templates for the Helm chart to facilitate installation and configuration.
2026-03-17 15:29:52 +00:00
Simon Larsen
9049533338 Merge pull request #2359 from OneUptime/mob-app-monitors
Mob app monitors
2026-03-17 15:29:26 +00:00
Nawaz Dhandala
da6c749d96 feat: enhance monitor details and summary view with improved probe handling and display 2026-03-17 15:05:00 +00:00
Nawaz Dhandala
b0ab4ef199 feat: add monitor probes functionality and summary view for monitoring data 2026-03-17 14:45:41 +00:00
Nawaz Dhandala
85243e6b56 fix: correct typo in Kubernetes monitoring documentation regarding custom agent design 2026-03-17 14:43:36 +00:00
Nawaz Dhandala
ddf2df4206 feat: enhance MonitorsScreen with summary counts and improved error handling 2026-03-17 14:30:18 +00:00
Nawaz Dhandala
80d4ccbd7d feat: implement Kubernetes as a standalone observability product with multi-cluster support, auto-discovery, and enhanced monitoring features 2026-03-17 13:39:23 +00:00
Nawaz Dhandala
3a1e75515c feat: add Phase 2 enhancements for trace visualization and analysis, including flame graph view, latency breakdown, in-trace span search, service flow map, and span link navigation 2026-03-17 13:33:02 +00:00
Nawaz Dhandala
d097738f13 refactor: remove CaptureSpan decorators from Express class methods 2026-03-17 13:22:35 +00:00
Nawaz Dhandala
b6fb2d761e feat: expand Kubernetes monitoring roadmap with auto-discovery, AI-driven RCA, and incident automation 2026-03-17 13:15:10 +00:00
Nawaz Dhandala
57974f0895 feat: enhance monitor functionality with project-specific queries and status tracking 2026-03-17 13:03:34 +00:00
Nawaz Dhandala
2adc399d74 feat: enhance span view routing with modelId support 2026-03-17 12:25:22 +00:00
Nawaz Dhandala
bcb1e92cab feat: Implement critical path analysis for distributed traces
- Added CriticalPathUtil class to compute self-time, critical path, and service breakdown for spans in distributed traces.
- Introduced SpanData, SpanSelfTime, CriticalPathResult, and ServiceBreakdown interfaces for structured data handling.
- Created API for fetching monitor data, including fetching all monitors, monitor details, statuses, and feeds.
- Developed MonitorCard component for displaying individual monitor information.
- Implemented MonitorsScreen and MonitorDetailScreen for listing and detailing monitors, respectively.
- Added hooks for managing monitor data fetching and state.
- Integrated navigation for monitor detail views.
2026-03-17 12:13:16 +00:00
Nawaz Dhandala
7984e5d1ab feat: add control plane monitoring for etcd, API server, scheduler, and controller manager 2026-03-17 12:09:54 +00:00
Nawaz Dhandala
9b380d424d feat: add plans for Docker and Kubernetes monitoring implementation 2026-03-17 12:05:36 +00:00
Nawaz Dhandala
7741bebe31 chore: update version to 10.0.34 2026-03-17 11:41:43 +00:00
Nawaz Dhandala
39a4c7e8f1 refactor: simplify error message construction in WhatsAppAuthorization middleware 2026-03-17 11:41:05 +00:00
Nawaz Dhandala
fe76e946c0 feat: implement WhatsApp webhook authorization middleware for signature verification 2026-03-17 11:40:24 +00:00
Matt Van Horn
b16c997f20 fix: show uptime history bar chart on mobile status page
The uptime history bar chart and its time labels were hidden on mobile
via `hidden sm:block` and `hidden sm:flex` classes. Replaced with
`overflow-x-auto` so the chart is visible and horizontally scrollable
on small screens.

Fixes #2049

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 00:21:01 -07:00
Nawaz Dhandala
66e5f43c5d fix: add validation for unknown columns in group by, sort, and select statements 2026-03-16 22:33:07 +00:00
Simão Silva
5cf660a44e Merge branch 'OneUptime:master' into master 2026-03-16 22:31:54 +00:00
Nawaz Dhandala
a7c9618a64 feat: add iOS_TEAM_ID environment variable for Xcode build process 2026-03-16 22:08:01 +00:00
Nawaz Dhandala
a02018aeb2 refactor: adjust formatting in SelectFieldGenerator test and update roadmap documents for clarity and new features 2026-03-16 21:23:59 +00:00
Nawaz Dhandala
9f1389ce87 Merge branch 'release' of https://github.com/OneUptime/oneuptime into release 2026-03-16 21:08:11 +00:00
Nawaz Dhandala
11e5c2778d refactor: update AnalyticsTableName declaration for improved type safety in tests 2026-03-16 21:06:37 +00:00
Nawaz Dhandala
9619090b98 fix: optimize SQL query in fetchAttributesFromDatabase to improve attribute key handling 2026-03-16 20:57:58 +00:00
Simão Silva
eacf106d10 Merge branch 'OneUptime:master' into master 2026-03-16 20:34:59 +00:00
Nawaz Dhandala
92c00e4fc8 feat: add LogScrubRule migration and update OnCallDutyPolicyScheduleLayer defaults; refactor test imports and clean up code formatting 2026-03-16 20:00:52 +00:00
Simão Silva
da3bbca1bb Merge branch 'OneUptime:master' into master 2026-03-16 19:51:29 +00:00
Nawaz Dhandala
5f52af2aa8 refactor: rename Record to AnalyticsRecord for clarity in StatementGenerator 2026-03-16 19:40:54 +00:00
Simão Silva
d08d6fde48 Merge branch 'OneUptime:master' into master 2026-03-16 19:38:36 +00:00
Simão Silva
82fd2ffef6 ci: Updated value schema file formatting 2026-03-16 19:29:04 +00:00
Simão Silva
b0c07038b5 ci: Allow loading of Slack app parameters from existing secret 2026-03-16 19:23:55 +00:00
Nawaz Dhandala
777e9612a4 feat: add migration for LogScrubRule table and update OnCallDutyPolicyScheduleLayer defaults 2026-03-16 15:51:24 +00:00
Nawaz Dhandala
7ff5d9dba6 feat: remove unused migration imports from Index.ts 2026-03-16 15:48:55 +00:00
Nawaz Dhandala
8b97807991 feat: update SQL query in ExceptionInstance model to remove unnecessary ORDER BY clause 2026-03-16 15:47:18 +00:00
Nawaz Dhandala
dff3c50a97 feat: update SQL queries in Log and Span models to remove unnecessary ORDER BY clauses 2026-03-16 15:37:30 +00:00
Nawaz Dhandala
d00d7cb19f feat: update SQL queries in ExceptionInstance, Log, and Span models to include GROUP BY clauses 2026-03-16 15:37:02 +00:00
Nawaz Dhandala
8cb25d9917 feat: remove unused migration imports from Index.ts 2026-03-16 14:56:11 +00:00
Nawaz Dhandala
ea58fbdc0d feat: add RFP directory to .gitignore 2026-03-16 12:57:21 +00:00
Nawaz Dhandala
5f660bae02 feat: refactor table names in analytics models to use AnalyticsTableName enum 2026-03-16 12:43:55 +00:00
Nawaz Dhandala
d327edb165 feat: update table names to include version suffix for ExceptionInstance, Log, Metric, MonitorLog, and Span models 2026-03-16 12:27:49 +00:00
Nawaz Dhandala
de7990c41e feat: add skipIndex configurations and projections in ExceptionInstance model; update codec in MonitorLog and Span models 2026-03-16 12:16:36 +00:00
Nawaz Dhandala
8f9e5a46fa feat: update column types to MapStringString and BigNumber, and add projections in Log, Metric, and Span models 2026-03-16 12:08:30 +00:00
Nawaz Dhandala
046482a2a8 feat: upgrade time field type to DateTime64 for improved precision in MonitorLog model 2026-03-16 11:54:33 +00:00
Nawaz Dhandala
3568d766ea feat: upgrade time field type to DateTime64 for enhanced precision in ExceptionInstance model 2026-03-16 11:49:32 +00:00
Nawaz Dhandala
41a8287975 refactor: remove unused data migration entries from the migration list 2026-03-16 11:47:49 +00:00
Nawaz Dhandala
6629cc9023 feat: upgrade time fields to DateTime64 for improved precision in Metric and Span models 2026-03-16 11:46:45 +00:00
Nawaz Dhandala
85d927f291 refactor: remove ChangeLogTimeColumnToDateTime64 migration from the migration list 2026-03-16 10:37:33 +00:00
Nawaz Dhandala
1edda202be feat: implement basic trace-based alerting with span count threshold and filtering options 2026-03-16 10:29:00 +00:00
Nawaz Dhandala
9a47b02a0c feat: upgrade time column to DateTime64 for nanosecond precision in logs 2026-03-16 10:26:03 +00:00
Nawaz Dhandala
3ccd089d4f Delete the Traces Audit document, which included a comprehensive analysis of OneUptime's traces implementation, storage, visualization, correlation, and competitive gaps. 2026-03-16 10:05:55 +00:00
Nawaz Dhandala
2c7486714f feat: Add comprehensive metrics and traces roadmap for industry parity
- Introduced detailed plans for enhancing OneUptime's metrics and traces capabilities to match and exceed industry standards.
- Metrics roadmap includes features like percentile aggregations, rate calculations, multi-attribute grouping, rollups, and advanced visualizations.
- Traces roadmap outlines improvements such as trace analytics, RED metrics, trace-based alerting, and enhanced visualization options like flame graphs and critical path analysis.
- Both roadmaps emphasize phased implementation, quick wins, and verification strategies to ensure robust feature delivery and performance.
2026-03-16 09:51:08 +00:00
Nawaz Dhandala
4781c6a532 fix: update array access syntax for consistency in AnalyticsDatabaseService 2026-03-16 09:20:32 +00:00
Nawaz Dhandala
1a58481265 refactor: simplify code formatting in LogAggregationService and LogsViewerToolbar 2026-03-15 00:28:07 +00:00
Nawaz Dhandala
58024398cf feat: add methods for column existence check and codec management in SpanItem 2026-03-15 00:03:46 +00:00
Nawaz Dhandala
dde1e89c34 fix: cast attributes to JSONObject in attribute remapping methods 2026-03-14 23:23:00 +00:00
Nawaz Dhandala
4b89add3b8 chore: update version to 10.0.32 2026-03-14 22:50:05 +00:00
Nawaz Dhandala
c7b8d13b49 feat: enhance trace storage and indexing with new skip indexes and compression codecs 2026-03-14 22:46:12 +00:00
Nawaz Dhandala
7622367d5d refactor: improve data handling and component state management in various files 2026-03-14 22:36:00 +00:00
Simon Larsen
916666fba5 Merge pull request #2356 from OneUptime/log-phase3
Log phase3
2026-03-14 22:07:31 +00:00
Simon Larsen
8683e7a880 Merge pull request #2354 from OneUptime/chore/npm-audit-fix
chore: npm audit fix
2026-03-14 22:07:09 +00:00
Nawaz Dhandala
1e8f8da91d feat(LogPipelineService): add attribute key resolution methods for improved attribute handling
feat(ProcessorForm, LogPipelines): set default sortOrder to 1 on processor creation
2026-03-14 21:47:08 +00:00
Nawaz Dhandala
564f69d91a feat: enhance UI components for filter conditions and processor forms with improved layout and descriptions 2026-03-14 21:11:18 +00:00
Nawaz Dhandala
7da10c48a8 feat: update route handling in various components to utilize RouteUtil for parameter population 2026-03-14 20:54:32 +00:00
Nawaz Dhandala
59904cb843 feat(ProcessorForm): enhance descriptions and add usage examples for processors 2026-03-14 20:45:14 +00:00
Nawaz Dhandala
43c7954dbe fix(FilterConditionElement): align delete button with other columns for consistent UI 2026-03-14 20:31:42 +00:00
Nawaz Dhandala
9c361b4150 feat(FilterCondition, FilterQueryBuilder): enhance filter condition UI with timeline and improve severity color handling 2026-03-14 20:25:38 +00:00
Nawaz Dhandala
6dfc364cea feat(FilterCondition, FilterQueryBuilder): enhance filter conditions with severity options and improve UI for condition management 2026-03-14 14:00:20 +00:00
Nawaz Dhandala
bb48f530bc fix(FilterQueryBuilder): remove unused originalQuery state management 2026-03-14 13:44:48 +00:00
Nawaz Dhandala
8c2931b8c9 refactor(FilterQueryBuilder, LogDropFilterView, LogPipelineView, StatementGenerator, AddSpanTableOptimizations): streamline code formatting and improve readability 2026-03-14 13:36:35 +00:00
Nawaz Dhandala
202b8b3845 feat(AddSpanTableOptimizations): apply async settings for codec modifications and index operations to prevent timeouts 2026-03-14 13:32:18 +00:00
Nawaz Dhandala
53a77ed47a feat(SpanTableOptimizations): streamline span table migration by removing RetrySpanTableOptimizations and optimizing codec application 2026-03-14 13:06:52 +00:00
Nawaz Dhandala
34dffaa710 feat(RetrySpanTableOptimizations): add migration for hasException column and codec optimizations in SpanItem 2026-03-14 12:17:09 +00:00
Nawaz Dhandala
d8bbe4b2cd feat(Span): add hasException column and optimize Span table with new skip indexes and codecs 2026-03-14 12:08:04 +00:00
Nawaz Dhandala
0b922f4dbf feat(ProcessorForm): replace Card with Modal for improved user experience in processor configuration 2026-03-14 11:40:39 +00:00
simlarsen
526cc21b9c chore: npm audit fix 2026-03-14 02:23:15 +00:00
Nawaz Dhandala
d7ac8dbb1e feat(LogFilterEvaluator): refactor getFieldValue to use getAttrValue for improved attribute retrieval 2026-03-13 20:42:13 +00:00
Nawaz Dhandala
b4fecdad21 feat(FilterQueryBuilder): remove success message handling from filter save process 2026-03-13 20:17:24 +00:00
Nawaz Dhandala
df613292df feat(FilterQueryBuilder): implement modal for editing filter conditions with improved UI and validation 2026-03-13 20:16:31 +00:00
Nawaz Dhandala
7088e8073f feat(LogStatusIndicators): add status indicators using Pill components for Log Drop Filter and Log Pipeline views 2026-03-13 20:01:33 +00:00
Nawaz Dhandala
f3fc6904bd feat(LogDropFilters): enhance Log Drop Filters with action and status indicators using Pill components 2026-03-13 19:54:28 +00:00
Nawaz Dhandala
af79613637 feat(LogDropFilterView): add Log Drop Filter view with details, action configuration, and filter conditions 2026-03-13 19:53:43 +00:00
Nawaz Dhandala
b7289c918c feat(LogScrubRules): enhance scrub rules configuration with new fields and detailed pill components 2026-03-13 18:03:02 +00:00
Nawaz Dhandala
1b65ca934e feat(LogDropFilters): add detailed documentation for Log Drop Filters functionality
refactor(LogScrubRules): remove initial values for create operation and update sort order requirement
2026-03-13 17:54:50 +00:00
Nawaz Dhandala
d968666535 feat(LogDocumentation): add detailed markdown documentation for Log Pipelines, Log Processors, and Log Scrub Rules 2026-03-13 17:23:42 +00:00
Nawaz Dhandala
aaa5414248 feat(LogScrubRules): enhance custom regex pattern field with conditional visibility 2026-03-13 17:18:41 +00:00
Nawaz Dhandala
60ae745fb1 feat(LogScrubRules): add multi-step form configuration for scrub rules 2026-03-13 17:16:35 +00:00
Nawaz Dhandala
2081f9030a Refactor and clean up code across multiple components
- Added missing commas in migration index.
- Improved formatting of permission descriptions for better readability.
- Enhanced dependency array formatting in useEffect hooks in LogsViewer component.
- Cleaned up JSON normalization in LogDetailsPanel for better readability.
- Refactored LogSearchBar component for improved readability and structure.
- Simplified filter logic in AnalyticsTooltip and LogsAnalyticsView components.
- Streamlined LogScrubRuleService methods for better clarity and consistency.
- Updated OtelLogsIngestService to improve readability of async calls.
- Enhanced LogFilterEvaluator comments and formatting for better understanding.
2026-03-13 15:37:44 +00:00
Nawaz Dhandala
735024216b feat(LogsViewerToolbar): simplify keyboard shortcuts button with kbd element 2026-03-13 15:24:48 +00:00
Nawaz Dhandala
4f84966326 feat(Migration): Add MigrationName1773414578773 for LogScrubRule constraints and indexes 2026-03-13 15:13:02 +00:00
Nawaz Dhandala
bcac918edd feat: Implement Log Pipeline Filter Query Builder and Processor Form
- Removed unused fields from LogPipelines settings page.
- Added FilterCondition component for managing individual filter conditions.
- Created FilterQueryBuilder component to build and manage filter queries for log pipelines.
- Introduced ProcessorForm component for creating log pipeline processors with various configurations.
- Added SeverityMappingRow component for managing severity mappings in the Severity Remapper processor.
- Implemented database migration for LogScrubRule table.
- Updated schema migrations to include new migration for LogScrubRule.
2026-03-13 15:02:58 +00:00
Nawaz Dhandala
b64c66a7c2 feat(LogScrubRules): Implement Log Scrub Rules feature with CRUD operations and UI integration 2026-03-13 14:55:09 +00:00
Nawaz Dhandala
2d88e4fe0e feat(LogsViewer): add keyboard shortcuts functionality and help component 2026-03-13 14:46:02 +00:00
Nawaz Dhandala
ec0c9c8c56 feat(LogPipelines): set isDeleteable prop to false for LogPipelines table 2026-03-13 14:39:09 +00:00
Nawaz Dhandala
71fb8e7bc9 feat(LogsViewer): Enhance keyboard navigation and focus management
- Added keyboard shortcuts for navigating logs using 'j' and 'k', and focusing the search bar with '/'.
- Implemented logic to reset the focused row index when displayed logs change.
- Integrated a ref for the LogSearchBar to allow programmatic focus.
- Updated LogsTable to visually indicate the focused row.
- Enhanced LogsAnalyticsView with a new tooltip component for better data representation.
- Refactored LogSearchBar and LogsFilterCard to support forwarding refs.
- Improved styling and accessibility across various components.
2026-03-13 14:19:30 +00:00
Nawaz Dhandala
cae34e080e feat: add isViewable prop to LogPipelines component for enhanced functionality 2026-03-13 13:55:04 +00:00
Nawaz Dhandala
0282ac3977 feat: update LogPipelineView to use Dropdown for processor type and add descriptions for configuration options 2026-03-13 13:43:01 +00:00
Nawaz Dhandala
df35775f3f feat: add LogPipeline, LogPipelineProcessor, and LogDropFilter services and integrate with BaseAPI 2026-03-13 12:38:44 +00:00
Nawaz Dhandala
da26755cbf feat: add comprehensive Dashboards Audit documentation comparing OneUptime with industry leaders 2026-03-13 12:26:47 +00:00
Nawaz Dhandala
48cdfee319 feat: add migration for LogPipeline, LogPipelineProcessor, and LogDropFilter tables 2026-03-13 11:51:01 +00:00
Nawaz Dhandala
0e93929a3f feat: add LogPipelineProcessor model and related services
- Implemented LogPipelineProcessor model with necessary fields and access controls.
- Created LogDropFilterAction enum for defining actions on log drop filters.
- Introduced LogPipelineProcessorType enum and configuration interfaces for various processor types.
- Developed LogExport utility for exporting logs in CSV and JSON formats.
- Added LogDropFilterService for managing drop filters with caching.
- Implemented LogPipelineService for loading and processing log pipelines with processors.
- Created LogFilterEvaluator for evaluating filter queries against log entries.
2026-03-13 11:50:07 +00:00
Nawaz Dhandala
fd1ee0c248 feat: add OpenTelemetry Metrics and Traces Audit documentation 2026-03-13 11:49:34 +00:00
Nawaz Dhandala
8e3e6769ed chore: update permissions in release workflows for consistency 2026-03-13 10:15:38 +00:00
Simon Larsen
417ae4473e Merge pull request #2353 from OneUptime/master
Release
2026-03-13 10:07:09 +00:00
Simon Larsen
810fcd4740 Merge pull request #2352 from OneUptime/chore/npm-audit-fix
chore: npm audit fix
2026-03-13 08:40:07 +00:00
simlarsen
374de9cf82 chore: npm audit fix 2026-03-13 02:25:28 +00:00
Nawaz Dhandala
b9eff113ac Refactor Docker image build and merge workflows for multi-architecture support
- Updated GitHub Actions workflows to implement a matrix strategy for building Docker images across multiple architectures (linux/amd64 and linux/arm64).
- Introduced new jobs for merging multi-arch manifests after building images, ensuring proper tagging and pushing to Docker Hub and GitHub Container Registry.
- Enhanced the `build_docker_images.sh` script to append architecture suffixes to tags for single-platform builds, preventing tag collisions.
- Added a new script `merge_docker_manifests.sh` to create and push multi-arch manifests for built images.
- Removed redundant Docker metadata actions and streamlined the build process for clarity and efficiency.
2026-03-12 22:14:10 +00:00
Nawaz Dhandala
de0d923c63 fix(Log): correct formatting of description for dropped attributes count column in log model 2026-03-12 21:39:44 +00:00
Nawaz Dhandala
d394c96eb9 feat(Logs): add missing OpenTelemetry log fields to enhance compliance and debugging 2026-03-12 21:38:58 +00:00
Nawaz Dhandala
03c38be19c feat(Logs): add new fields for observed time, dropped attributes count, and flags in log model and migration 2026-03-12 21:37:57 +00:00
Nawaz Dhandala
a94e48501e feat(Logs): update gap analysis and implementation phases for log management features 2026-03-12 21:23:47 +00:00
Nawaz Dhandala
35e973ebd9 feat(Nginx): update short URL location path for link shortener
feat(VERSION): bump version to 10.0.31
2026-03-12 21:10:06 +00:00
Nawaz Dhandala
8b6bfc3ef7 feat(LogsViewer): refactor saved view query handling for improved clarity and type safety 2026-03-12 21:07:41 +00:00
Nawaz Dhandala
ad25241d1c feat(OnTriggerBaseModel): improve comments for boolean conversion in select values
style(LogsViewer): format export types for better readability

style(LogsAnalyticsView): clean up function formatting and improve readability

chore: bump version to 10.0.30
2026-03-12 20:38:25 +00:00
Nawaz Dhandala
954281c3d5 feat(LogsViewer): add analytics view mode and corresponding components
- Introduced a new view mode "analytics" to the LogsViewer component.
- Created LogsAnalyticsView component to handle analytics display.
- Updated LogsViewerToolbar to allow switching between "list" and "analytics" views.
- Enhanced LogsViewer to manage internal state for view mode and handle changes.
- Updated types to include LogsViewMode for better type safety.
- Refactored main content rendering logic to conditionally display analytics or logs table.
2026-03-12 20:35:36 +00:00
Nawaz Dhandala
fca0816d6b feat(OnTriggerBaseModel): convert string "true"/"false" values to booleans in select 2026-03-12 20:27:39 +00:00
Nawaz Dhandala
d67d5315e0 feat(migrations): update LogSavedView schema migration to include new constraints and indexes 2026-03-12 19:43:56 +00:00
Nawaz Dhandala
2177f8d56c feat(migrations): add MigrationName1773344537755 to update LogSavedView schema 2026-03-12 19:43:04 +00:00
Nawaz Dhandala
5602536058 feat(OpenAPI): add pagination parameters 'limit' and 'skip' to API specifications
refactor(LogsViewer): format emptyMessage prop for better readability
refactor(SavedViewsDropdown): simplify className conditional rendering
2026-03-12 19:41:22 +00:00
Nawaz Dhandala
abdc4b1d07 chore(version): bump version to 10.0.29 2026-03-12 19:32:45 +00:00
Nawaz Dhandala
6c7f3b5090 feat(start-server): add protobuf body parser middleware for OTLP ingestion 2026-03-12 19:25:50 +00:00
Nawaz Dhandala
7e9fc625c4 Merge branch 'log-view' 2026-03-12 19:11:50 +00:00
Nawaz Dhandala
1382758f12 fix(logs-viewer): update selectedSavedViewId prop to allow undefined value in dropdown and toolbar components
refactor(logs-viewer): conditionally unset other defaults in LogSavedViewService
2026-03-12 19:11:29 +00:00
Simon Larsen
7442270e08 Merge pull request #2350 from OneUptime/log-view
Log view
2026-03-12 19:06:22 +00:00
Nawaz Dhandala
295e191456 feat(logs-viewer): update SavedViewsDropdown layout and enhance button interactions 2026-03-12 18:57:26 +00:00
Nawaz Dhandala
8baaa587e2 feat(logs-viewer): enhance empty message handling and update button styles across components 2026-03-12 18:52:02 +00:00
Nawaz Dhandala
66034a2473 feat(logs-viewer): add create saved view button to SavedViewsDropdown and update LogsViewerToolbar 2026-03-12 18:41:54 +00:00
Nawaz Dhandala
d972c565e3 refactor(logs-viewer): simplify layout structure and enhance toolbar display 2026-03-12 18:32:13 +00:00
Nawaz Dhandala
45d2c4aa2a feat(logs-viewer): implement saved view functionality and refactor related handlers 2026-03-12 18:18:24 +00:00
Nawaz Dhandala
144826ff84 feat(logs-viewer): add saved views functionality with toolbar enhancements
- Introduced `LogSavedView` model to manage saved log views in the database.
- Created migration to add `LogSavedView` table with necessary fields and constraints.
- Implemented `LogSavedViewService` for CRUD operations on saved views.
- Enhanced `LogsViewerToolbar` to include buttons for saving views and selecting saved views.
- Added `ColumnSelector` component for managing visible columns in the logs table.
- Created `SavedViewsDropdown` component for selecting and managing saved views.
- Updated types to support new saved views and column options.
2026-03-12 17:36:04 +00:00
Nawaz Dhandala
4c620a2600 Merge branch 'master' of https://github.com/OneUptime/oneuptime 2026-03-12 16:57:25 +00:00
Nawaz Dhandala
9786e46a2b refactor(Logs.md): update log management context and completed features for clarity 2026-03-12 16:57:21 +00:00
Simon Larsen
22c89b7579 Merge pull request #2345 from OneUptime/snyk-fix-8ed94913d5b7c146b3ac6c4b38e8fee5
[Snyk] Security upgrade multer from 2.0.2 to 2.1.1
2026-03-12 15:42:21 +00:00
Nawaz Dhandala
9a08c945bd chore(VERSION): update version to 10.0.28 2026-03-12 15:23:15 +00:00
Nawaz Dhandala
d79588e02c refactor(FacetSection): format imports and improve line length readability 2026-03-12 15:22:34 +00:00
Nawaz Dhandala
9ae8dc2266 feat(FacetSection): add search functionality to filter displayed values
feat(LogAggregationService): update default facet limit to 500
feat(TelemetryAPI): increase default limit for metrics attributes to 500
2026-03-12 15:21:32 +00:00
Nawaz Dhandala
4d5bb32ad6 chore(VERSION): update version to 10.0.27 2026-03-12 15:13:33 +00:00
Nawaz Dhandala
375b2e67d0 refactor(LogsViewer, DataMigrations, Routes): convert comments to block comments for clarity 2026-03-12 15:13:21 +00:00
Nawaz Dhandala
7e48bbb206 feat(DataMigrations): remove FixTokenBFIndexesAndAddCodecs migration and update migration order 2026-03-12 15:11:42 +00:00
Nawaz Dhandala
737f053347 feat(Statement): add NotEqual condition to value serialization 2026-03-12 14:27:09 +00:00
Nawaz Dhandala
20594be771 feat(DataRetention): add monitor metric retention settings and migration 2026-03-12 13:59:22 +00:00
Nawaz Dhandala
890e0a836c feat(MonitorLog): implement dynamic log retention based on GlobalConfig and remove old cron jobs 2026-03-12 13:46:20 +00:00
Nawaz Dhandala
6fb4e24808 feat(DataMigrations): fix retentionDate for existing rows in multiple tables 2026-03-12 13:32:54 +00:00
Nawaz Dhandala
3ba5af0f95 chore: update version to 10.0.26 2026-03-12 13:27:08 +00:00
Nawaz Dhandala
a6058c6e65 refactor: clean up code formatting and comments across multiple files 2026-03-12 13:26:57 +00:00
Nawaz Dhandala
dfc2755551 feat(DataMigrations): add migration to fix tokenbf_v1 indexes and apply ZSTD codecs 2026-03-12 13:12:38 +00:00
Nawaz Dhandala
1ff774684c feat(Logs): implement ClickHouse storage optimizations and telemetry data retention with TTL support 2026-03-12 11:59:32 +00:00
Nawaz Dhandala
28c4eeb6ff feat: Add retentionDate column and TTL support to telemetry models
- Introduced retentionDate column to Log, Metric, Span, and MonitorLog models for per-service data retention.
- Implemented TTL configuration using retentionDate to enable automatic data deletion in ClickHouse.
- Added skip indexes for improved query performance on relevant columns in Log, Span, Metric, and Exception models.
- Updated ingestion services to compute and store retentionDate based on service-specific retention settings.
- Created a data migration to add retentionDate columns and skip indexes to existing telemetry tables.
- Deprecated the cron job for data deletion, transitioning to ClickHouse's native TTL mechanism.
2026-03-12 11:54:26 +00:00
Nawaz Dhandala
4a566c7369 refactor(LogQueryToFilter): update comment formatting for severity normalization 2026-03-12 11:28:04 +00:00
Nawaz Dhandala
5ff9a8b75b chore(VERSION): bump version to 10.0.25 2026-03-12 11:26:40 +00:00
Nawaz Dhandala
46c9f36578 feat(Logs): add Phase 5 optimizations for ClickHouse storage and query performance 2026-03-12 11:26:10 +00:00
Nawaz Dhandala
981f609b84 feat(Nginx): update /otlp location block to remove trailing slash 2026-03-12 11:10:24 +00:00
Nawaz Dhandala
fa7bde4aca feat(LogsViewer): add service name resolution and improve value suggestions handling
feat(LogQueryToFilter): normalize severity values and enhance filter application logic
chore(config): update OpenTelemetry exporter endpoint and headers
2026-03-12 10:42:09 +00:00
Nawaz Dhandala
a8a9022ea2 chore(VERSION): bump version to 10.0.24 2026-03-12 10:05:12 +00:00
Nawaz Dhandala
2e903d82e5 Merge branch 'release' 2026-03-12 10:04:59 +00:00
Nawaz Dhandala
27ecf76254 refactor(LogAggregationService): improve formatting and logging level in authentication and service files 2026-03-12 10:04:45 +00:00
Nawaz Dhandala
a77c59a9bd feat(LogAggregationService): add validation for facet keys and improve SQL query construction 2026-03-12 09:02:34 +00:00
Simon Larsen
d48e40efac Merge pull request #2346 from OneUptime/chore/npm-audit-fix
chore: npm audit fix
2026-03-11 21:16:42 +00:00
Simon Larsen
b7412813fd Merge pull request #2349 from Eliaxie/fix/alert-without-monitor-notification
fix: Handle null monitor in workspace notification rules
2026-03-11 21:16:25 +00:00
Elia Maggioni
b93f4a9d7f fix: Handle null monitor in workspace notification rules
Fixes #2348

Alerts without monitors were causing workspace notifications to crash with:
TypeError: Cannot read properties of null (reading 'id')

This restores proper null handling that was accidentally removed in
commit 76ab3a8cd6.
2026-03-11 22:09:45 +01:00
Nawaz Dhandala
9df8513954 chore(release): bump version to 10.0.23 2026-03-11 19:51:53 +00:00
Nawaz Dhandala
bbd6bdac12 refactor(tests): improve formatting of makeAggregateBy function for better readability 2026-03-11 18:47:00 +00:00
Nawaz Dhandala
484dbabc3c fix(analytics): improve formatting and readability in input validation and error handling 2026-03-11 18:46:28 +00:00
simlarsen
989de0c811 chore: npm audit fix 2026-03-11 02:14:09 +00:00
Nawaz Dhandala
818f6a3788 test(aggregateBy): add input validation tests for aggregation parameters 2026-03-09 18:45:35 +00:00
Nawaz Dhandala
02e2a247c3 fix(analytics): validate aggregation types and column names in aggregateBy method 2026-03-09 18:32:23 +00:00
snyk-bot
599b7fdf7b fix: Common/package.json & Common/package-lock.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-MULTER-15417528
2026-03-09 18:11:44 +00:00
Nawaz Dhandala
812e6ab6df fix(UUID): update UUID generation to use v4 for improved uniqueness 2026-03-09 18:06:30 +00:00
Nawaz Dhandala
dd4effa449 feat(markdown): integrate DOMPurify for enhanced security in Mermaid diagrams 2026-03-09 18:04:01 +00:00
899 changed files with 94657 additions and 21353 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

@@ -18,9 +18,10 @@ jobs:
- name: Install Helm
run: |
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
- name: Lint Helm Chart
- name: Lint Helm Chart
run: |
helm lint ./HelmChart/Public/oneuptime
helm lint ./HelmChart/Public/kubernetes-agent
js-lint:
runs-on: ubuntu-latest

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:

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,11 @@ on:
push:
branches:
- "master"
permissions:
contents: write
packages: write
jobs:
generate-build-number:
runs-on: ubuntu-latest
@@ -86,13 +91,21 @@ jobs:
echo "patch=${target_patch}" >> "$GITHUB_OUTPUT"
echo "Using version base: ${new_version}"
nginx-docker-image-deploy:
# ─── Docker image build jobs (per-arch matrix) ───────────────────────
nginx-docker-image-build:
needs: [read-version, generate-build-number]
runs-on: ubuntu-latest
env:
QEMU_CPU: max
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
@@ -102,17 +115,6 @@ jobs:
large-packages: true
docker-images: true
swap-storage: true
- name: Docker Meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
oneuptime/nginx
ghcr.io/oneuptime/nginx
tags: |
type=raw,value=test,enable=true
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
- uses: actions/checkout@v4
with:
@@ -122,20 +124,12 @@ jobs:
with:
node-version: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v10.0.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate Dockerfile from Dockerfile.tpl
run: npm run prerun
# Build and deploy nginx.
- name: Login to Docker Hub
run: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
@@ -151,19 +145,52 @@ jobs:
--version "${{needs.read-version.outputs.major_minor}}-test" \
--dockerfile ./Nginx/Dockerfile \
--context . \
--platforms linux/amd64,linux/arm64 \
--platforms ${{ matrix.platform }} \
--git-sha "${{ github.sha }}" \
--extra-tags test \
--extra-enterprise-tags enterprise-test
e2e-docker-image-deploy:
needs: [read-version, generate-build-number]
nginx-docker-image-merge:
needs: [nginx-docker-image-build, read-version]
runs-on: ubuntu-latest
env:
QEMU_CPU: max
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 nginx \
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
e2e-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
@@ -173,17 +200,6 @@ jobs:
large-packages: true
docker-images: true
swap-storage: true
- name: Docker Meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
oneuptime/e2e
ghcr.io/oneuptime/e2e
tags: |
type=raw,value=test,enable=true
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
- uses: actions/checkout@v4
with:
@@ -193,20 +209,12 @@ jobs:
with:
node-version: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v10.0.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate Dockerfile from Dockerfile.tpl
run: npm run prerun
# Build and deploy e2e.
- name: Login to Docker Hub
run: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
@@ -222,18 +230,51 @@ jobs:
--version "${{needs.read-version.outputs.major_minor}}-test" \
--dockerfile ./E2E/Dockerfile \
--context . \
--platforms linux/amd64,linux/arm64 \
--platforms ${{ matrix.platform }} \
--git-sha "${{ github.sha }}" \
--extra-tags test \
--extra-enterprise-tags enterprise-test
test-server-docker-image-deploy:
needs: [read-version, generate-build-number]
e2e-docker-image-merge:
needs: [e2e-docker-image-build, read-version]
runs-on: ubuntu-latest
env:
QEMU_CPU: max
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 e2e \
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
test-server-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
@@ -243,17 +284,6 @@ jobs:
large-packages: true
docker-images: true
swap-storage: true
- name: Docker Meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
oneuptime/test-server
ghcr.io/oneuptime/test-server
tags: |
type=raw,value=test,enable=true
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
- uses: actions/checkout@v4
with:
@@ -263,20 +293,12 @@ jobs:
with:
node-version: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v10.0.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate Dockerfile from Dockerfile.tpl
run: npm run prerun
# Build and deploy test-server.
- name: Login to Docker Hub
run: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
@@ -292,18 +314,51 @@ jobs:
--version "${{needs.read-version.outputs.major_minor}}-test" \
--dockerfile ./TestServer/Dockerfile \
--context . \
--platforms linux/amd64,linux/arm64 \
--platforms ${{ matrix.platform }} \
--git-sha "${{ github.sha }}" \
--extra-tags test \
--extra-enterprise-tags enterprise-test
home-docker-image-deploy:
needs: [read-version, generate-build-number]
test-server-docker-image-merge:
needs: [test-server-docker-image-build, read-version]
runs-on: ubuntu-latest
env:
QEMU_CPU: max
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 test-server \
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
home-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
@@ -313,17 +368,6 @@ jobs:
large-packages: true
docker-images: true
swap-storage: true
- name: Docker Meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
oneuptime/home
ghcr.io/oneuptime/home
tags: |
type=raw,value=test,enable=true
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
- uses: actions/checkout@v4
with:
@@ -333,20 +377,12 @@ jobs:
with:
node-version: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v10.0.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate Dockerfile from Dockerfile.tpl
run: npm run prerun
# Build and deploy home.
- name: Login to Docker Hub
run: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
@@ -362,20 +398,51 @@ jobs:
--version "${{needs.read-version.outputs.major_minor}}-test" \
--dockerfile ./Home/Dockerfile \
--context . \
--platforms linux/amd64,linux/arm64 \
--platforms ${{ matrix.platform }} \
--git-sha "${{ github.sha }}" \
--extra-tags test \
--extra-enterprise-tags enterprise-test
test-docker-image-deploy:
needs: [read-version, generate-build-number]
home-docker-image-merge:
needs: [home-docker-image-build, read-version]
runs-on: ubuntu-latest
env:
QEMU_CPU: max
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 home \
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
test-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
@@ -385,17 +452,6 @@ jobs:
large-packages: true
docker-images: true
swap-storage: true
- name: Docker Meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
oneuptime/test
ghcr.io/oneuptime/test
tags: |
type=raw,value=test,enable=true
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
- uses: actions/checkout@v4
with:
@@ -405,20 +461,12 @@ jobs:
with:
node-version: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v10.0.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate Dockerfile from Dockerfile.tpl
run: npm run prerun
# Build and deploy test.
- name: Login to Docker Hub
run: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
@@ -434,62 +482,22 @@ jobs:
--version "${{needs.read-version.outputs.major_minor}}-test" \
--dockerfile ./Tests/Dockerfile \
--context . \
--platforms linux/amd64,linux/arm64 \
--platforms ${{ matrix.platform }} \
--git-sha "${{ github.sha }}" \
--extra-tags test \
--extra-enterprise-tags enterprise-test
telemetry-docker-image-deploy:
needs: [read-version, generate-build-number]
test-docker-image-merge:
needs: [test-docker-image-build, read-version]
runs-on: ubuntu-latest
env:
QEMU_CPU: max
steps:
- name: Free Disk Space (Ubuntu)
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
- name: Docker Meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
oneuptime/telemetry
ghcr.io/oneuptime/telemetry
tags: |
type=raw,value=test,enable=true
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- uses: actions/setup-node@v4
with:
node-version: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v10.0.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate Dockerfile from Dockerfile.tpl
run: npm run prerun
# Build and deploy telemetry.
- name: Login to Docker Hub
run: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
@@ -498,25 +506,27 @@ jobs:
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
- name: Merge multi-arch manifests
run: |
bash ./Scripts/GHA/build_docker_images.sh \
--image telemetry \
--version "${{needs.read-version.outputs.major_minor}}-test" \
--dockerfile ./Telemetry/Dockerfile \
--context . \
--platforms linux/amd64,linux/arm64 \
--git-sha "${{ github.sha }}" \
--extra-tags test \
--extra-enterprise-tags enterprise-test
VERSION="${{needs.read-version.outputs.major_minor}}-test"
SANITIZED_VERSION="${VERSION//+/-}"
bash ./Scripts/GHA/merge_docker_manifests.sh \
--image test \
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
probe-docker-image-deploy:
probe-docker-image-build:
needs: [read-version, generate-build-number]
runs-on: ubuntu-latest
env:
QEMU_CPU: max
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
@@ -526,17 +536,6 @@ jobs:
large-packages: true
docker-images: true
swap-storage: true
- name: Docker Meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
oneuptime/probe
ghcr.io/oneuptime/probe
tags: |
type=raw,value=test,enable=true
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
- uses: actions/checkout@v4
with:
@@ -546,20 +545,12 @@ jobs:
with:
node-version: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v10.0.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate Dockerfile from Dockerfile.tpl
run: npm run prerun
# Build and deploy probe.
- name: Login to Docker Hub
run: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
@@ -575,18 +566,51 @@ jobs:
--version "${{needs.read-version.outputs.major_minor}}-test" \
--dockerfile ./Probe/Dockerfile \
--context . \
--platforms linux/amd64,linux/arm64 \
--platforms ${{ matrix.platform }} \
--git-sha "${{ github.sha }}" \
--extra-tags test \
--extra-enterprise-tags enterprise-test
app-docker-image-deploy:
needs: [read-version, generate-build-number]
probe-docker-image-merge:
needs: [probe-docker-image-build, read-version]
runs-on: ubuntu-latest
env:
QEMU_CPU: max
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 probe \
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
app-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
@@ -596,17 +620,6 @@ jobs:
large-packages: true
docker-images: true
swap-storage: true
- name: Docker Meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
oneuptime/app
ghcr.io/oneuptime/app
tags: |
type=raw,value=test,enable=true
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
- uses: actions/checkout@v4
with:
@@ -616,20 +629,12 @@ jobs:
with:
node-version: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v10.0.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate Dockerfile from Dockerfile.tpl
run: npm run prerun
# Build and deploy app.
- name: Login to Docker Hub
run: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
@@ -645,21 +650,51 @@ jobs:
--version "${{needs.read-version.outputs.major_minor}}-test" \
--dockerfile ./App/Dockerfile \
--context . \
--platforms linux/amd64,linux/arm64 \
--platforms ${{ matrix.platform }} \
--git-sha "${{ github.sha }}" \
--extra-tags test \
--extra-enterprise-tags enterprise-test
ai-agent-docker-image-deploy:
needs: [read-version, generate-build-number]
app-docker-image-merge:
needs: [app-docker-image-build, read-version]
runs-on: ubuntu-latest
env:
QEMU_CPU: max
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 app \
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
ai-agent-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
@@ -669,17 +704,6 @@ jobs:
large-packages: true
docker-images: true
swap-storage: true
- name: Docker Meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
oneuptime/ai-agent
ghcr.io/oneuptime/ai-agent
tags: |
type=raw,value=test,enable=true
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
- uses: actions/checkout@v4
with:
@@ -689,19 +713,12 @@ jobs:
with:
node-version: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v10.0.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate Dockerfile from Dockerfile.tpl
run: npm run prerun
# Build and deploy ai-agent.
- name: Login to Docker Hub
run: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
@@ -717,61 +734,22 @@ jobs:
--version "${{needs.read-version.outputs.major_minor}}-test" \
--dockerfile ./AIAgent/Dockerfile \
--context . \
--platforms linux/amd64,linux/arm64 \
--platforms ${{ matrix.platform }} \
--git-sha "${{ github.sha }}" \
--extra-tags test \
--extra-enterprise-tags enterprise-test
worker-docker-image-deploy:
needs: [read-version, generate-build-number]
ai-agent-docker-image-merge:
needs: [ai-agent-docker-image-build, read-version]
runs-on: ubuntu-latest
env:
QEMU_CPU: max
steps:
- name: Free Disk Space (Ubuntu)
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
- name: Docker Meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
oneuptime/worker
ghcr.io/oneuptime/worker
tags: |
type=raw,value=test,enable=true
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- uses: actions/setup-node@v4
with:
node-version: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v10.0.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate Dockerfile from Dockerfile.tpl
run: npm run prerun
# Build and deploy accounts.
- name: Login to Docker Hub
run: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
@@ -780,18 +758,15 @@ jobs:
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
- name: Merge multi-arch manifests
run: |
bash ./Scripts/GHA/build_docker_images.sh \
--image worker \
--version "${{needs.read-version.outputs.major_minor}}-test" \
--dockerfile ./Worker/Dockerfile \
--context . \
--platforms linux/amd64,linux/arm64 \
--git-sha "${{ github.sha }}" \
--extra-tags test \
--extra-enterprise-tags enterprise-test
VERSION="${{needs.read-version.outputs.major_minor}}-test"
SANITIZED_VERSION="${VERSION//+/-}"
bash ./Scripts/GHA/merge_docker_manifests.sh \
--image ai-agent \
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
# ─── Non-Docker jobs (unchanged) ─────────────────────────────────────
publish-terraform-provider:
runs-on: ubuntu-latest
@@ -805,11 +780,10 @@ jobs:
VERSION="${{needs.read-version.outputs.major_minor}}-test"
echo "Skipping Terraform provider publish for test release $VERSION"
test-helm-chart:
runs-on: ubuntu-latest
needs: [infrastructure-agent-deploy, publish-terraform-provider, telemetry-docker-image-deploy, worker-docker-image-deploy, home-docker-image-deploy, test-server-docker-image-deploy, test-docker-image-deploy, probe-docker-image-deploy, app-docker-image-deploy, ai-agent-docker-image-deploy, nginx-docker-image-deploy, e2e-docker-image-deploy]
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:
@@ -910,7 +884,7 @@ jobs:
retention-days: 7
test-e2e-test-self-hosted:
test-e2e-test-self-hosted:
runs-on: ubuntu-latest
# After all the jobs runs
needs: [test-helm-chart, generate-build-number, read-version]
@@ -1003,7 +977,7 @@ jobs:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Set up Go
uses: actions/setup-go@v4
@@ -1027,7 +1001,7 @@ jobs:
- name: Release MSI Images
run: cd InfrastructureAgent && bash build-msi.sh ${{needs.read-version.outputs.major_minor}}.${{needs.generate-build-number.outputs.build_number}}
- name: Upload Release Binaries
uses: actions/upload-artifact@v4
@@ -1036,13 +1010,13 @@ jobs:
# Name of the artifact to upload.
# Optional. Default is 'artifact'
name: binaries
# A file, directory or wildcard pattern that describes what to upload
# Required.
path: |
./InfrastructureAgent/dist
# Duration after which artifact will expire in days. 0 means using default retention.
# Minimum 1 day.
# Maximum 90 days unless changed from the repository settings page.

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

2
.gitignore vendored
View File

@@ -141,3 +141,5 @@ terraform.tfstate.backup
.terraform.lock.hcl
.claude/worktrees/**
App/FeatureSet/Dashboard/public/sw.js
RFP/

1
AGENTS.md Normal file
View File

@@ -0,0 +1 @@
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,11 +48,13 @@
"@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",
"@types/archiver": "^6.0.3",
"@types/crypto-js": "^4.2.2",
"@types/dompurify": "^3.0.5",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
"@types/react-highlight": "^0.12.8",
@@ -69,6 +71,7 @@
"cors": "^2.8.5",
"cron-parser": "^4.8.1",
"crypto-js": "^4.2.0",
"dompurify": "^3.3.2",
"dotenv": "^16.4.4",
"ejs": "^3.1.10",
"elkjs": "^0.10.0",
@@ -78,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",
@@ -87,7 +90,7 @@
"mermaid": "^11.12.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"multer": "^2.0.2",
"multer": "^2.1.1",
"node-cron": "^3.0.3",
"nodemailer": "^7.0.7",
"otpauth": "^9.3.1",
@@ -1486,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"
@@ -2223,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": {
@@ -3751,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": {
@@ -3984,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": {
@@ -4438,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,11 +52,13 @@
"@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",
"@types/archiver": "^6.0.3",
"@types/crypto-js": "^4.2.2",
"@types/dompurify": "^3.0.5",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
"@types/react-highlight": "^0.12.8",
@@ -73,6 +75,7 @@
"cors": "^2.8.5",
"cron-parser": "^4.8.1",
"crypto-js": "^4.2.0",
"dompurify": "^3.3.2",
"dotenv": "^16.4.4",
"ejs": "^3.1.10",
"elkjs": "^0.10.0",
@@ -82,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",
@@ -91,7 +94,7 @@
"mermaid": "^11.12.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"multer": "^2.0.2",
"multer": "^2.1.1",
"node-cron": "^3.0.3",
"nodemailer": "^7.0.7",
"otpauth": "^9.3.1",
@@ -800,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": {
@@ -928,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"
@@ -1177,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,11 +51,13 @@
"@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",
"@types/archiver": "^6.0.3",
"@types/crypto-js": "^4.2.2",
"@types/dompurify": "^3.0.5",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
"@types/react-highlight": "^0.12.8",
@@ -72,6 +74,7 @@
"cors": "^2.8.5",
"cron-parser": "^4.8.1",
"crypto-js": "^4.2.0",
"dompurify": "^3.3.2",
"dotenv": "^16.4.4",
"ejs": "^3.1.10",
"elkjs": "^0.10.0",
@@ -81,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",
@@ -90,7 +93,7 @@
"mermaid": "^11.12.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"multer": "^2.0.2",
"multer": "^2.1.1",
"node-cron": "^3.0.3",
"nodemailer": "^7.0.7",
"otpauth": "^9.3.1",
@@ -784,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": {
@@ -912,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"
@@ -1161,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

@@ -12,6 +12,8 @@ import SettingsEmail from "./Pages/Settings/Email/Index";
import SettingsProbes from "./Pages/Settings/Probes/Index";
import SettingsAIAgents from "./Pages/Settings/AIAgents/Index";
import SettingsLlmProviders from "./Pages/Settings/LlmProviders/Index";
import SendEmail from "./Pages/SendEmail/Index";
import MoreEmail from "./Pages/More/Email";
import Users from "./Pages/Users/Index";
import PageMap from "./Utils/PageMap";
import RouteMap from "./Utils/RouteMap";
@@ -149,6 +151,16 @@ const App: () => JSX.Element = () => {
path={RouteMap[PageMap.SETTINGS_DATA_RETENTION]?.toString() || ""}
element={<SettingsDataRetention />}
/>
<PageRoute
path={RouteMap[PageMap.SEND_EMAIL]?.toString() || ""}
element={<SendEmail />}
/>
<PageRoute
path={RouteMap[PageMap.MORE_EMAIL]?.toString() || ""}
element={<MoreEmail />}
/>
</Routes>
</MasterPage>
);

View File

@@ -20,6 +20,14 @@ const DashboardNavbar: FunctionComponent = (): ReactElement => {
icon: IconProp.Folder,
route: RouteUtil.populateRouteParams(RouteMap[PageMap.PROJECTS] as Route),
},
{
id: "more-nav-bar-item",
title: "More",
icon: IconProp.More,
route: RouteUtil.populateRouteParams(
RouteMap[PageMap.MORE_EMAIL] as Route,
),
},
{
id: "settings-nav-bar-item",
title: "Settings",

View File

@@ -0,0 +1,305 @@
import PageMap from "../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
import MoreSideMenu from "./SideMenu";
import Route from "Common/Types/API/Route";
import React, { FunctionComponent, ReactElement, useState } from "react";
import Page from "Common/UI/Components/Page/Page";
import Card from "Common/UI/Components/Card/Card";
import BasicForm from "Common/UI/Components/Forms/BasicForm";
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import { JSONObject } from "Common/Types/JSON";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import URL from "Common/Types/API/URL";
import API from "Common/UI/Utils/API/API";
import { APP_API_URL } from "Common/UI/Config";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import Modal from "Common/UI/Components/Modal/Modal";
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
const MoreEmail: FunctionComponent = (): ReactElement => {
const [isSendingTest, setIsSendingTest] = useState<boolean>(false);
const [isSendingAll, setIsSendingAll] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [success, setSuccess] = useState<string>("");
const [showConfirmModal, setShowConfirmModal] = useState<boolean>(false);
const [showTestModal, setShowTestModal] = useState<boolean>(false);
const [testEmail, setTestEmail] = useState<string>("");
const [testError, setTestError] = useState<string>("");
const [testSuccess, setTestSuccess] = useState<string>("");
const [pendingSubject, setPendingSubject] = useState<string>("");
const [pendingMessage, setPendingMessage] = useState<string>("");
const [currentFormValues, setCurrentFormValues] = useState<JSONObject>({
subject: "",
message: "",
});
const sendTestEmail: () => Promise<void> = async (): Promise<void> => {
setIsSendingTest(true);
setTestError("");
setTestSuccess("");
try {
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(APP_API_URL.toString()).addRoute(
"/admin/email/send-test",
),
data: {
subject: pendingSubject,
message: pendingMessage,
testEmail: testEmail,
},
});
if (response instanceof HTTPErrorResponse) {
throw response;
}
if (response.isFailure()) {
throw new Error("Failed to send test email.");
}
setTestSuccess("Test email sent successfully. Please check your inbox.");
} catch (err) {
setTestError(API.getFriendlyMessage(err));
} finally {
setIsSendingTest(false);
}
};
const sendToAllUsers: () => Promise<void> = async (): Promise<void> => {
setIsSendingAll(true);
setError("");
setSuccess("");
try {
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(APP_API_URL.toString()).addRoute(
"/admin/email/send-to-all-users",
),
data: {
subject: pendingSubject,
message: pendingMessage,
},
});
if (response instanceof HTTPErrorResponse) {
throw response;
}
if (response.isFailure()) {
throw new Error("Failed to send emails.");
}
setSuccess(
"Broadcast email job has been started. Emails will be sent in the background.",
);
} catch (err) {
setError(API.getFriendlyMessage(err));
} finally {
setIsSendingAll(false);
}
};
return (
<Page
title={"Send Announcement Email"}
breadcrumbLinks={[
{
title: "Admin Dashboard",
to: RouteUtil.populateRouteParams(RouteMap[PageMap.HOME] as Route),
},
{
title: "More",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.MORE_EMAIL] as Route,
),
},
{
title: "Send Email",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.MORE_EMAIL] as Route,
),
},
]}
sideMenu={<MoreSideMenu />}
>
<Card
title="Send Announcement Email"
description="Compose an announcement email to send to all registered users. You can send a test email first to preview how it looks."
>
{success ? (
<Alert type={AlertType.SUCCESS} title={success} className="mb-4" />
) : (
<></>
)}
<BasicForm
id="send-email-form"
name="Send Announcement Email"
isLoading={isSendingAll}
error={error || ""}
hideSubmitButton={true}
onChange={(values: JSONObject) => {
setCurrentFormValues(values as JSONObject);
}}
initialValues={{
subject: "",
message: "",
}}
fields={[
{
field: {
subject: true,
},
title: "Subject",
description: "The subject line of the announcement email.",
placeholder: "Enter email subject",
required: true,
fieldType: FormFieldSchemaType.Text,
},
{
field: {
message: true,
},
title: "Message",
description:
"The body of the announcement email. This will be displayed in a branded OneUptime email template. You can use Markdown formatting.",
placeholder: "Enter your announcement message here...",
required: true,
fieldType: FormFieldSchemaType.Markdown,
},
]}
onSubmit={async () => {}}
footer={
<div className="flex w-full justify-end mt-3 space-x-3">
<Button
title="Send Test Email"
buttonStyle={ButtonStyleType.NORMAL}
onClick={() => {
const subject: string = String(
currentFormValues["subject"] || "",
).trim();
const message: string = String(
currentFormValues["message"] || "",
).trim();
if (!subject || !message) {
setError(
"Please fill in subject and message before sending a test.",
);
return;
}
setError("");
setPendingSubject(subject);
setPendingMessage(message);
setTestEmail("");
setTestError("");
setTestSuccess("");
setShowTestModal(true);
}}
/>
<Button
title="Send to All Users"
buttonStyle={ButtonStyleType.PRIMARY}
isLoading={isSendingAll}
onClick={() => {
const subject: string = String(
currentFormValues["subject"] || "",
).trim();
const message: string = String(
currentFormValues["message"] || "",
).trim();
if (!subject || !message) {
setSuccess("");
setError("Please fill in all fields.");
return;
}
setError("");
setPendingSubject(subject);
setPendingMessage(message);
setShowConfirmModal(true);
}}
/>
</div>
}
/>
</Card>
{showTestModal ? (
<Modal
title="Send Test Email"
description="Enter an email address to send a test of this announcement."
onClose={() => {
setShowTestModal(false);
}}
submitButtonText="Send Test"
isLoading={isSendingTest}
onSubmit={() => {
if (!testEmail.trim()) {
setTestError("Please enter a test email address.");
return;
}
sendTestEmail().catch(() => {});
}}
error={testError}
>
{testSuccess ? (
<Alert
type={AlertType.SUCCESS}
title={testSuccess}
className="mb-4"
/>
) : (
<></>
)}
<div className="mb-4">
<label
htmlFor="test-email-input"
className="block text-sm font-medium text-gray-700 mb-1"
>
Test Email Address
</label>
<input
id="test-email-input"
type="email"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
placeholder="test@example.com"
value={testEmail}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTestEmail(e.target.value);
}}
/>
</div>
</Modal>
) : (
<></>
)}
{showConfirmModal ? (
<ConfirmModal
title="Confirm Send to All Users"
description="Are you sure you want to send this announcement email to all registered users? This action cannot be undone."
submitButtonText="Yes, Send to All Users"
onSubmit={async () => {
setShowConfirmModal(false);
await sendToAllUsers();
}}
onClose={() => {
setShowConfirmModal(false);
}}
/>
) : (
<></>
)}
</Page>
);
};
export default MoreEmail;

View File

@@ -0,0 +1,28 @@
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 from "Common/UI/Components/SideMenu/SideMenu";
import SideMenuItem from "Common/UI/Components/SideMenu/SideMenuItem";
import SideMenuSection from "Common/UI/Components/SideMenu/SideMenuSection";
import React, { ReactElement } from "react";
const MoreSideMenu: () => JSX.Element = (): ReactElement => {
return (
<SideMenu>
<SideMenuSection title="Communication">
<SideMenuItem
link={{
title: "Send Email",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.MORE_EMAIL] as Route,
),
}}
icon={IconProp.Email}
/>
</SideMenuSection>
</SideMenu>
);
};
export default MoreSideMenu;

View File

@@ -0,0 +1,281 @@
import PageMap from "../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import React, { FunctionComponent, ReactElement, useState } from "react";
import Page from "Common/UI/Components/Page/Page";
import Card from "Common/UI/Components/Card/Card";
import BasicForm from "Common/UI/Components/Forms/BasicForm";
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import { JSONObject } from "Common/Types/JSON";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import URL from "Common/Types/API/URL";
import API from "Common/UI/Utils/API/API";
import { APP_API_URL } from "Common/UI/Config";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
const SendEmail: FunctionComponent = (): ReactElement => {
const [isSendingTest, setIsSendingTest] = useState<boolean>(false);
const [isSendingAll, setIsSendingAll] = useState<boolean>(false);
const [testError, setTestError] = useState<string>("");
const [testSuccess, setTestSuccess] = useState<string>("");
const [sendAllError, setSendAllError] = useState<string>("");
const [sendAllSuccess, setSendAllSuccess] = useState<string>("");
const [showConfirmModal, setShowConfirmModal] = useState<boolean>(false);
const [pendingSubject, setPendingSubject] = useState<string>("");
const [pendingMessage, setPendingMessage] = useState<string>("");
const sendToAllUsers: () => Promise<void> = async (): Promise<void> => {
setIsSendingAll(true);
setSendAllError("");
setSendAllSuccess("");
try {
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(APP_API_URL.toString()).addRoute(
"/admin/email/send-to-all-users",
),
data: {
subject: pendingSubject,
message: pendingMessage,
},
});
if (response instanceof HTTPErrorResponse) {
throw response;
}
if (response.isFailure()) {
throw new Error("Failed to send emails.");
}
setSendAllSuccess(
"Broadcast email job has been started. Emails will be sent in the background.",
);
} catch (err) {
setSendAllError(API.getFriendlyMessage(err));
} finally {
setIsSendingAll(false);
}
};
return (
<Page
title={"Send Announcement Email"}
breadcrumbLinks={[
{
title: "Admin Dashboard",
to: RouteUtil.populateRouteParams(RouteMap[PageMap.HOME] as Route),
},
{
title: "Send Email",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.SEND_EMAIL] as Route,
),
},
]}
>
<Card
title="Send Test Email"
description="Send a test announcement email to a single email address to preview how it looks before sending to all users."
>
{testSuccess ? (
<Alert
type={AlertType.SUCCESS}
title={testSuccess}
className="mb-4"
/>
) : (
<></>
)}
<BasicForm
id="send-test-email-form"
name="Send Test Announcement Email"
isLoading={isSendingTest}
error={testError || ""}
submitButtonText="Send Test Email"
maxPrimaryButtonWidth={true}
initialValues={{
subject: "",
message: "",
testEmail: "",
}}
fields={[
{
field: {
subject: true,
},
title: "Subject",
description: "The subject line of the announcement email.",
placeholder: "Enter email subject",
required: true,
fieldType: FormFieldSchemaType.Text,
},
{
field: {
message: true,
},
title: "Message",
description:
"The body of the announcement email. This will be displayed in a branded OneUptime email template. You can use Markdown formatting.",
placeholder: "Enter your announcement message here...",
required: true,
fieldType: FormFieldSchemaType.Markdown,
},
{
field: {
testEmail: true,
},
title: "Test Email Address",
description:
"The email address where the test email will be sent.",
placeholder: "test@example.com",
required: true,
fieldType: FormFieldSchemaType.Email,
disableSpellCheck: true,
},
]}
onSubmit={async (
values: JSONObject,
onSubmitSuccessful?: () => void,
) => {
const subject: string = String(values["subject"] || "").trim();
const message: string = String(values["message"] || "").trim();
const testEmail: string = String(values["testEmail"] || "").trim();
if (!subject || !message || !testEmail) {
setTestSuccess("");
setTestError("Please fill in all fields.");
return;
}
setIsSendingTest(true);
setTestError("");
setTestSuccess("");
try {
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(APP_API_URL.toString()).addRoute(
"/admin/email/send-test",
),
data: {
subject,
message,
testEmail,
},
});
if (response instanceof HTTPErrorResponse) {
throw response;
}
if (response.isFailure()) {
throw new Error("Failed to send test email.");
}
setTestSuccess(
"Test email sent successfully. Please check your inbox.",
);
if (onSubmitSuccessful) {
onSubmitSuccessful();
}
} catch (err) {
setTestError(API.getFriendlyMessage(err));
} finally {
setIsSendingTest(false);
}
}}
/>
</Card>
<Card
title="Send Email to All Users"
description="Send an announcement email to all registered users. Please send a test email first to verify the content."
>
{sendAllSuccess ? (
<Alert
type={AlertType.SUCCESS}
title={sendAllSuccess}
className="mb-4"
/>
) : (
<></>
)}
<BasicForm
id="send-all-email-form"
name="Send Announcement to All Users"
isLoading={isSendingAll}
error={sendAllError || ""}
submitButtonText="Send to All Users"
maxPrimaryButtonWidth={true}
initialValues={{
subject: "",
message: "",
}}
fields={[
{
field: {
subject: true,
},
title: "Subject",
description: "The subject line of the announcement email.",
placeholder: "Enter email subject",
required: true,
fieldType: FormFieldSchemaType.Text,
},
{
field: {
message: true,
},
title: "Message",
description:
"The body of the announcement email. This will be sent to all registered users. You can use Markdown formatting.",
placeholder: "Enter your announcement message here...",
required: true,
fieldType: FormFieldSchemaType.Markdown,
},
]}
onSubmit={async (values: JSONObject) => {
const subject: string = String(values["subject"] || "").trim();
const message: string = String(values["message"] || "").trim();
if (!subject || !message) {
setSendAllSuccess("");
setSendAllError("Please fill in all fields.");
return;
}
setPendingSubject(subject);
setPendingMessage(message);
setShowConfirmModal(true);
}}
/>
</Card>
{showConfirmModal ? (
<ConfirmModal
title="Confirm Send to All Users"
description="Are you sure you want to send this announcement email to all registered users? This action cannot be undone."
submitButtonText="Yes, Send to All Users"
onSubmit={async () => {
setShowConfirmModal(false);
await sendToAllUsers();
}}
onClose={() => {
setShowConfirmModal(false);
}}
/>
) : (
<></>
)}
</Page>
);
};
export default SendEmail;

View File

@@ -78,6 +78,51 @@ const Settings: FunctionComponent = (): ReactElement => {
modelId: ObjectID.getZeroObjectID(),
}}
/>
<CardModelDetail
name="Monitor Metric Retention Settings"
cardProps={{
title: "Monitor Metric Retention",
description:
"Configure how long monitor metrics are retained before being automatically deleted.",
}}
isEditable={true}
editButtonText="Edit Settings"
formFields={[
{
field: {
monitorMetricRetentionInDays: true,
},
title: "Monitor Metric Retention (Days)",
fieldType: FormFieldSchemaType.PositiveNumber,
required: false,
description:
"Number of days to retain monitor metrics. Monitor metrics older than this will be automatically deleted. Default is 1 day if not set. Minimum: 1 day, Maximum: 365 days.",
validation: {
minValue: 1,
maxValue: 365,
},
placeholder: "1",
},
]}
modelDetailProps={{
modelType: GlobalConfig,
id: "model-detail-global-config-monitor-metric-retention",
fields: [
{
field: {
monitorMetricRetentionInDays: true,
},
fieldType: FieldType.Number,
title: "Monitor Metric Retention (Days)",
placeholder: "1 (default)",
description:
"Number of days to retain monitor metrics. Monitor metrics older than this will be automatically deleted.",
},
],
modelId: ObjectID.getZeroObjectID(),
}}
/>
</Page>
);
};

View File

@@ -23,6 +23,10 @@ enum PageMap {
SETTINGS_AUTHENTICATION = "SETTINGS_AUTHENTICATION",
SETTINGS_API_KEY = "SETTINGS_API_KEY",
SETTINGS_DATA_RETENTION = "SETTINGS_DATA_RETENTION",
SEND_EMAIL = "SEND_EMAIL",
MORE_EMAIL = "MORE_EMAIL",
}
export default PageMap;

View File

@@ -39,6 +39,10 @@ const RouteMap: Dictionary<Route> = {
[PageMap.SETTINGS_DATA_RETENTION]: new Route(
`/admin/settings/data-retention`,
),
[PageMap.SEND_EMAIL]: new Route(`/admin/send-email`),
[PageMap.MORE_EMAIL]: new Route(`/admin/more/email`),
};
export class RouteUtil {

View File

@@ -28,6 +28,9 @@ import MonitorAPI from "Common/Server/API/MonitorAPI";
import ShortLinkAPI from "Common/Server/API/ShortLinkAPI";
import StatusPageAPI from "Common/Server/API/StatusPageAPI";
import WorkspaceNotificationRuleAPI from "Common/Server/API/WorkspaceNotificationRuleAPI";
import WorkspaceNotificationSummaryAPI from "Common/Server/API/WorkspaceNotificationSummaryAPI";
import DashboardAPI from "Common/Server/API/DashboardAPI";
import DashboardDomainAPI from "Common/Server/API/DashboardDomainAPI";
import StatusPageDomainAPI from "Common/Server/API/StatusPageDomainAPI";
import StatusPageSubscriberAPI from "Common/Server/API/StatusPageSubscriberAPI";
import UserCallAPI from "Common/Server/API/UserCallAPI";
@@ -185,6 +188,21 @@ import IncidentPostmortemTemplateService, {
import TableViewService, {
Service as TableViewServiceType,
} from "Common/Server/Services/TableViewService";
import LogSavedViewService, {
Service as LogSavedViewServiceType,
} from "Common/Server/Services/LogSavedViewService";
import LogPipelineService, {
Service as LogPipelineServiceType,
} from "Common/Server/Services/LogPipelineService";
import LogPipelineProcessorService, {
Service as LogPipelineProcessorServiceType,
} from "Common/Server/Services/LogPipelineProcessorService";
import LogDropFilterService, {
Service as LogDropFilterServiceType,
} from "Common/Server/Services/LogDropFilterService";
import LogScrubRuleService, {
Service as LogScrubRuleServiceType,
} from "Common/Server/Services/LogScrubRuleService";
import IncidentOwnerTeamService, {
Service as IncidentOwnerTeamServiceType,
} from "Common/Server/Services/IncidentOwnerTeamService";
@@ -218,6 +236,9 @@ import IncidentTemplateOwnerUserService, {
import IncidentTemplateService, {
Service as IncidentTemplateServiceType,
} from "Common/Server/Services/IncidentTemplateService";
import KubernetesClusterService, {
Service as KubernetesClusterServiceType,
} from "Common/Server/Services/KubernetesClusterService";
import LabelService, {
Service as LabelServiceType,
} from "Common/Server/Services/LabelService";
@@ -378,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,
@@ -481,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";
@@ -537,6 +566,7 @@ import IncidentTemplate from "Common/Models/DatabaseModels/IncidentTemplate";
import IncidentTemplateOwnerTeam from "Common/Models/DatabaseModels/IncidentTemplateOwnerTeam";
import IncidentTemplateOwnerUser from "Common/Models/DatabaseModels/IncidentTemplateOwnerUser";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Label from "Common/Models/DatabaseModels/Label";
import MonitorCustomField from "Common/Models/DatabaseModels/MonitorCustomField";
import MonitorGroupOwnerTeam from "Common/Models/DatabaseModels/MonitorGroupOwnerTeam";
@@ -630,6 +660,11 @@ import ScheduledMaintenanceTemplateOwnerUserService, {
Service as ScheduledMaintenanceTemplateOwnerUserServiceType,
} from "Common/Server/Services/ScheduledMaintenanceTemplateOwnerUserService";
import TableView from "Common/Models/DatabaseModels/TableView";
import LogSavedView from "Common/Models/DatabaseModels/LogSavedView";
import LogPipeline from "Common/Models/DatabaseModels/LogPipeline";
import LogPipelineProcessor from "Common/Models/DatabaseModels/LogPipelineProcessor";
import LogDropFilter from "Common/Models/DatabaseModels/LogDropFilter";
import LogScrubRule from "Common/Models/DatabaseModels/LogScrubRule";
import IncidentFeed from "Common/Models/DatabaseModels/IncidentFeed";
import AlertFeed from "Common/Models/DatabaseModels/AlertFeed";
@@ -1259,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>(
@@ -1494,6 +1545,46 @@ const BaseAPIFeatureSet: FeatureSet = {
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<LogSavedView, LogSavedViewServiceType>(
LogSavedView,
LogSavedViewService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<LogPipeline, LogPipelineServiceType>(
LogPipeline,
LogPipelineService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<LogPipelineProcessor, LogPipelineProcessorServiceType>(
LogPipelineProcessor,
LogPipelineProcessorService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<LogDropFilter, LogDropFilterServiceType>(
LogDropFilter,
LogDropFilterService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<LogScrubRule, LogScrubRuleServiceType>(
LogScrubRule,
LogScrubRuleService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<IncidentState, IncidentStateServiceType>(
@@ -1799,6 +1890,14 @@ const BaseAPIFeatureSet: FeatureSet = {
new BaseAPI<Label, LabelServiceType>(Label, LabelService).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<KubernetesCluster, KubernetesClusterServiceType>(
KubernetesCluster,
KubernetesClusterService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<EmailVerificationToken, EmailVerificationTokenServiceType>(
@@ -1985,6 +2084,10 @@ const BaseAPIFeatureSet: FeatureSet = {
`/${APP_NAME.toLocaleLowerCase()}`,
new WorkspaceNotificationRuleAPI().getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new WorkspaceNotificationSummaryAPI().getRouter(),
);
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new FileAPI().getRouter());
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
@@ -1996,6 +2099,13 @@ const BaseAPIFeatureSet: FeatureSet = {
new StatusPageDomainAPI().getRouter(),
);
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new DashboardAPI().getRouter());
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new DashboardDomainAPI().getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new ProjectSsoAPI().getRouter(),

View File

@@ -55,11 +55,13 @@
"@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",
"@types/archiver": "^6.0.3",
"@types/crypto-js": "^4.2.2",
"@types/dompurify": "^3.0.5",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
"@types/react-highlight": "^0.12.8",
@@ -76,6 +78,7 @@
"cors": "^2.8.5",
"cron-parser": "^4.8.1",
"crypto-js": "^4.2.0",
"dompurify": "^3.3.2",
"dotenv": "^16.4.4",
"ejs": "^3.1.10",
"elkjs": "^0.10.0",
@@ -85,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",
@@ -94,7 +97,7 @@
"mermaid": "^11.12.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"multer": "^2.0.2",
"multer": "^2.1.1",
"node-cron": "^3.0.3",
"nodemailer": "^7.0.7",
"otpauth": "^9.3.1",
@@ -1081,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": {
@@ -1378,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"
@@ -1718,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(() => {
@@ -181,6 +190,15 @@ const ServiceRoutes: React.LazyExoticComponent<
};
});
});
const KubernetesRoutes: React.LazyExoticComponent<
AllRoutesModule["KubernetesRoutes"]
> = lazy(() => {
return import("./Routes/AllRoutes").then((m: AllRoutesModule) => {
return {
default: m.KubernetesRoutes,
};
});
});
const CodeRepositoryRoutes: React.LazyExoticComponent<
AllRoutesModule["CodeRepositoryRoutes"]
> = lazy(() => {
@@ -498,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() || ""}
@@ -528,6 +552,12 @@ const App: () => JSX.Element = () => {
element={<ServiceRoutes {...commonPageProps} />}
/>
{/* Kubernetes */}
<PageRoute
path={RouteMap[PageMap.KUBERNETES_ROOT]?.toString() || ""}
element={<KubernetesRoutes {...commonPageProps} />}
/>
{/* Code Repository */}
<PageRoute
path={RouteMap[PageMap.CODE_REPOSITORY_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,22 +24,57 @@ const BlankCanvasElement: FunctionComponent<ComponentProps> = (
if (!props.isEditMode && props.dashboardViewConfig.components.length === 0) {
return (
<div className="ml-1 mr-1 rounded p-10 border-2 border-gray-100 text-sm text-gray-400 text-center pt-24 pb-24">
No components added to this dashboard. Please add one to get started.
<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)" }}
>
<svg
className="w-6 h-6 text-gray-400"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z"
/>
</svg>
</div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">
No widgets yet
</h3>
<p className="text-sm text-gray-400 max-w-sm mx-auto">
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}`}>
<div
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 = "";
if (props.isEditMode) {
className +=
"border-2 border-gray-100 rounded hover:border-gray-300 hover:bg-gray-100 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";
@@ -55,7 +54,7 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
}}
leftFooterElement={
<Button
title={`Delete Component`}
title={`Delete Widget`}
icon={IconProp.Trash}
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
onClick={() => {
@@ -67,12 +66,12 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
<>
{showDeleteConfirmation && (
<ConfirmModal
title={`Delete?`}
description={`Are you sure you want to delete this component? This action is not recoverable.`}
title={`Delete Widget?`}
description={`Are you sure you want to delete this widget? This action cannot be undone.`}
onClose={() => {
setShowDeleteConfirmation(false);
}}
submitButtonText={"Delete"}
submitButtonText={"Delete Widget"}
onSubmit={() => {
props.onComponentDelete(component);
setShowDeleteConfirmation(false);
@@ -82,7 +81,16 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
/>
)}
<Divider />
{/* 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{" "}
{component.heightInDashboardUnits} units
</span>
</div>
<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";
@@ -25,6 +28,7 @@ export interface ComponentProps {
telemetryAttributes: string[];
};
dashboardStartAndEndDate: RangeStartAndEndDateTime;
refreshTick?: number | undefined;
}
const DashboardCanvas: FunctionComponent<ComponentProps> = (
@@ -33,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 ||
@@ -51,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) => {
@@ -105,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={() => {
@@ -127,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 => {
@@ -136,10 +138,18 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
},
);
const width: number = DefaultDashboardSize.widthInDashboardUnits;
return (
<div ref={dashboardCanvasRef} className={`grid grid-cols-${width}`}>
<div
ref={dashboardCanvasRef}
style={{
display: "grid",
gridTemplateColumns: `repeat(${canvasWidth}, 1fr)`,
gap: `${gap}px`,
gridAutoRows: `${unitSize}px`,
borderRadius: "16px",
padding: "8px",
}}
>
{finalRenderedComponents}
</div>
);
@@ -191,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
@@ -221,8 +230,8 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
updateComponent(updatedComponent);
}}
isSelected={isSelected}
refreshTick={props.refreshTick}
onClick={() => {
// component is selected
props.onComponentSelected(componentId);
}}
/>
@@ -252,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,17 +1,30 @@
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";
import DashboardTableComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTableComponent";
import DashboardGaugeComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardGaugeComponent";
import DashboardLogStreamComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardLogStreamComponent";
import DashboardTraceListComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTraceListComponent";
import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent";
import DashboardChartComponent from "./DashboardChartComponent";
import DashboardValueComponent from "./DashboardValueComponent";
import DashboardTextComponent from "./DashboardTextComponent";
import DashboardTableComponent from "./DashboardTableComponent";
import DashboardGaugeComponent from "./DashboardGaugeComponent";
import DashboardLogStreamComponent from "./DashboardLogStreamComponent";
import DashboardTraceListComponent from "./DashboardTraceListComponent";
import DefaultDashboardSize, {
GetDashboardComponentHeightInDashboardUnits,
GetDashboardComponentWidthInDashboardUnits,
GetDashboardUnitHeightInPx,
GetDashboardUnitWidthInPx,
MarginForEachUnitInPx,
SpaceBetweenUnitsInPx,
} from "Common/Types/Dashboard/DashboardSize";
import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes";
@@ -37,324 +50,437 @@ export interface DashboardBaseComponentProps {
dashboardViewConfig: DashboardViewConfig;
dashboardStartAndEndDate: RangeStartAndEndDateTime;
metricTypes: Array<MetricType>;
refreshTick?: number | undefined;
}
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-md col-span-${widthOfComponent} row-span-${heightOfComponent} p-2 bg-white border-2 border-solid border-gray-100`;
// ── 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) {
className += " cursor-pointer";
// ── 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-2 border-blue-300";
}
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-2 h-12 bg-blue-300 hover:bg-blue-400 rounded-full cursor-pointer`}
></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-300 hover:bg-blue-400 rounded-full cursor-pointer"
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-12 h-2 bg-blue-300 hover:bg-blue-400 rounded-full cursor-pointer`}
></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>
);
};
@@ -362,51 +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`,
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.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}
{/* 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>
)}
{getResizeWidthElement()}
{getResizeHeightElement()}
{/* 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>
{getResizeWidthHandle()}
{getResizeHeightHandle()}
{getResizeCornerHandle()}
</div>
);
};

View File

@@ -3,12 +3,10 @@ import DashboardChartComponent from "Common/Types/Dashboard/DashboardComponents/
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
import MetricCharts from "../../Metrics/MetricCharts";
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricUtil from "../../Metrics/Utils/Metrics";
import API from "Common/UI/Utils/API/API";
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
import JSONFunctions from "Common/Types/JSONFunctions";
import MetricQueryConfigData, {
MetricChartType,
@@ -31,10 +29,28 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
const [error, setError] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState<boolean>(true);
// 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
) {
configs.push(...props.component.arguments.metricQueryConfigs);
}
return configs;
};
const queryConfigs: Array<MetricQueryConfigData> = resolveQueryConfigs();
const metricViewData: MetricViewData = {
queryConfigs: props.component.arguments.metricQueryConfig
? [props.component.arguments.metricQueryConfig]
: [],
queryConfigs: queryConfigs,
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
props.dashboardStartAndEndDate,
),
@@ -97,80 +113,121 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
useEffect(() => {
fetchAggregatedResults();
}, [props.dashboardStartAndEndDate, props.metricTypes]);
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
const [metricQueryConfig, setMetricQueryConfig] = React.useState<
MetricQueryConfigData | undefined
>(props.component.arguments.metricQueryConfig);
const [prevQueryConfigs, setPrevQueryConfigs] = React.useState<
Array<MetricQueryConfigData> | MetricQueryConfigData | undefined
>(
props.component.arguments.metricQueryConfigs ||
props.component.arguments.metricQueryConfig,
);
useEffect(() => {
// set metricQueryConfig to the new value only if it is different from the previous value
const currentConfigs:
| Array<MetricQueryConfigData>
| MetricQueryConfigData
| undefined =
props.component.arguments.metricQueryConfigs ||
props.component.arguments.metricQueryConfig;
if (
JSONFunctions.isJSONObjectDifferent(
metricQueryConfig || {},
props.component.arguments.metricQueryConfig || {},
prevQueryConfigs || {},
currentConfigs || {},
)
) {
setMetricQueryConfig(props.component.arguments.metricQueryConfig);
setPrevQueryConfigs(currentConfigs);
fetchAggregatedResults();
}
}, [props.component.arguments.metricQueryConfig]);
}, [
props.component.arguments.metricQueryConfig,
props.component.arguments.metricQueryConfigs,
]);
useEffect(() => {
fetchAggregatedResults();
}, []);
if (isLoading) {
return <ComponentLoader />;
}
if (error) {
if (isLoading && metricResults.length === 0) {
// Skeleton loading for chart - only on initial load
return (
<div className="m-auto flex flex-col justify-center w-full h-full">
<div className="h-7 w-7 text-gray-400 w-full text-center mx-auto">
<Icon icon={IconProp.ChartBar} />
<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>
<div className="flex-1 flex items-end gap-1 px-2 pb-2">
{Array.from({ length: 12 }).map((_: unknown, i: number) => {
return (
<div
key={i}
className="flex-1 bg-gray-100 rounded-t"
style={{
height: `${20 + Math.random() * 60}%`,
opacity: 0.4 + Math.random() * 0.4,
}}
></div>
);
})}
</div>
<ErrorMessage message={error} />
</div>
);
}
let heightOfChart: number | undefined =
(props.dashboardComponentHeightInPx || 0) - 100;
if (error) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
<div className="w-12 h-12 rounded-full bg-gray-50 flex items-center justify-center">
<div className="h-5 w-5 text-gray-300">
<Icon icon={IconProp.ChartBar} />
</div>
</div>
<p className="text-xs text-gray-400 text-center max-w-48">{error}</p>
</div>
);
}
if (heightOfChart < 0) {
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) -
widgetHeaderHeight -
numberOfCharts * perChartOverhead) /
numberOfCharts;
if (heightOfChart < 50) {
heightOfChart = undefined;
}
// add title and description.
type GetMetricChartType = () => MetricChartType;
// Convert dashboard chart type to metric chart type
const getMetricChartType: GetMetricChartType = (): MetricChartType => {
if (props.component.arguments.chartType === DashboardChartType.Bar) {
return MetricChartType.BAR;
}
if (
props.component.arguments.chartType === DashboardChartType.Area ||
props.component.arguments.chartType === DashboardChartType.StackedArea
) {
return MetricChartType.AREA;
}
return MetricChartType.LINE;
};
const chartMetricViewData: MetricViewData = {
queryConfigs: props.component.arguments.metricQueryConfig
? [
{
...props.component.arguments.metricQueryConfig!,
metricAliasData: {
title: props.component.arguments.chartTitle || undefined,
description:
props.component.arguments.chartDescription || undefined,
metricVariable: undefined,
legend: props.component.arguments.legendText || undefined,
legendUnit: props.component.arguments.legendUnit || undefined,
},
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,
),
@@ -178,14 +235,37 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
};
return (
<div>
<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

@@ -0,0 +1,451 @@
import React, { FunctionComponent, ReactElement, useEffect } from "react";
import DashboardGaugeComponent from "Common/Types/Dashboard/DashboardComponents/DashboardGaugeComponent";
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricUtil from "../../Metrics/Utils/Metrics";
import API from "Common/UI/Utils/API/API";
import JSONFunctions from "Common/Types/JSONFunctions";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
export interface ComponentProps extends DashboardBaseComponentProps {
component: DashboardGaugeComponent;
}
const DashboardGaugeComponentElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [metricResults, setMetricResults] = React.useState<
Array<AggregatedResult>
>([]);
const [aggregationType, setAggregationType] = React.useState<AggregationType>(
AggregationType.Avg,
);
const [error, setError] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState<boolean>(true);
const metricViewData: MetricViewData = {
queryConfigs: props.component.arguments.metricQueryConfig
? [props.component.arguments.metricQueryConfig]
: [],
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
props.dashboardStartAndEndDate,
),
formulaConfigs: [],
};
const fetchAggregatedResults: PromiseVoidFunction =
async (): Promise<void> => {
setIsLoading(true);
if (
!metricViewData.startAndEndDate?.startValue ||
!metricViewData.startAndEndDate?.endValue
) {
setIsLoading(false);
return;
}
if (
!metricViewData.queryConfigs ||
metricViewData.queryConfigs.length === 0 ||
!metricViewData.queryConfigs[0] ||
!metricViewData.queryConfigs[0].metricQueryData ||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
Object.keys(metricViewData.queryConfigs[0].metricQueryData.filterData)
.length === 0
) {
setIsLoading(false);
return;
}
if (
!metricViewData.queryConfigs[0] ||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
!metricViewData.queryConfigs[0].metricQueryData.filterData
?.aggegationType
) {
setIsLoading(false);
return;
}
setAggregationType(
(metricViewData.queryConfigs[0].metricQueryData.filterData
?.aggegationType as AggregationType) || AggregationType.Avg,
);
try {
const results: Array<AggregatedResult> = await MetricUtil.fetchResults({
metricViewData: metricViewData,
});
setMetricResults(results);
setError("");
} catch (err: unknown) {
setError(API.getFriendlyErrorMessage(err as Error));
}
setIsLoading(false);
};
const [metricQueryConfig, setMetricQueryConfig] = React.useState<
MetricQueryConfigData | undefined
>(props.component.arguments.metricQueryConfig);
useEffect(() => {
fetchAggregatedResults();
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
useEffect(() => {
if (
JSONFunctions.isJSONObjectDifferent(
metricQueryConfig || {},
props.component.arguments.metricQueryConfig || {},
)
) {
setMetricQueryConfig(props.component.arguments.metricQueryConfig);
fetchAggregatedResults();
}
}, [props.component.arguments.metricQueryConfig]);
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>
<div
className="bg-gray-100 rounded-full"
style={{
width: `${Math.min(props.dashboardComponentWidthInPx * 0.5, 120)}px`,
height: `${Math.min(props.dashboardComponentWidthInPx * 0.25, 60)}px`,
borderRadius: "999px 999px 0 0",
}}
></div>
<div className="h-5 w-12 bg-gray-100 rounded mt-2"></div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
<svg
className="w-5 h-5 text-gray-300"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z"
/>
</svg>
</div>
<p className="text-xs text-gray-400 text-center max-w-40">{error}</p>
</div>
);
}
// Show setup state if no metric configured
if (
!props.component.arguments.metricQueryConfig ||
!props.component.arguments.metricQueryConfig.metricQueryData?.filterData ||
Object.keys(
props.component.arguments.metricQueryConfig.metricQueryData.filterData,
).length === 0
) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
<div className="w-10 h-10 rounded-full bg-emerald-50 flex items-center justify-center">
<svg
className="w-5 h-5 text-emerald-300"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z"
/>
</svg>
</div>
<p className="text-xs font-medium text-gray-500">
{props.component.arguments.gaugeTitle || "Gauge Widget"}
</p>
<p className="text-xs text-gray-400 text-center">
Click to configure metric
</p>
</div>
);
}
// Calculate aggregated value
let aggregatedValue: number = 0;
let avgCount: number = 0;
for (const result of metricResults) {
for (const item of result.data) {
const value: number = item.value;
if (aggregationType === AggregationType.Avg) {
aggregatedValue += value;
avgCount += 1;
} else if (aggregationType === AggregationType.Sum) {
aggregatedValue += value;
} else if (aggregationType === AggregationType.Min) {
aggregatedValue = Math.min(aggregatedValue, value);
} else if (aggregationType === AggregationType.Max) {
aggregatedValue = Math.max(aggregatedValue, value);
} else if (aggregationType === AggregationType.Count) {
aggregatedValue += 1;
}
}
}
if (aggregationType === AggregationType.Avg && avgCount > 0) {
aggregatedValue = aggregatedValue / avgCount;
}
aggregatedValue = Math.round(aggregatedValue * 100) / 100;
const minValue: number = props.component.arguments.minValue ?? 0;
const maxValue: number = props.component.arguments.maxValue ?? 100;
const warningThreshold: number | undefined =
props.component.arguments.warningThreshold;
const criticalThreshold: number | undefined =
props.component.arguments.criticalThreshold;
// Calculate percentage for the gauge arc
const range: number = maxValue - minValue;
const percentage: number =
range > 0
? Math.min(Math.max((aggregatedValue - minValue) / range, 0), 1)
: 0;
// Determine color based on thresholds
let gaugeColor: string = "#10b981"; // green
if (criticalThreshold !== undefined && aggregatedValue >= criticalThreshold) {
gaugeColor = "#ef4444"; // red
} else if (
warningThreshold !== undefined &&
aggregatedValue >= warningThreshold
) {
gaugeColor = "#f59e0b"; // yellow
}
// SVG gauge rendering
const size: number = Math.min(
props.dashboardComponentWidthInPx - 40,
props.dashboardComponentHeightInPx - 60,
);
const gaugeSize: number = Math.max(size, 80);
const strokeWidth: number = Math.max(gaugeSize * 0.1, 8);
const radius: number = (gaugeSize - strokeWidth) / 2;
const centerX: number = gaugeSize / 2;
const centerY: number = gaugeSize / 2;
// Semi-circle arc (180 degrees, from left to right)
const startAngle: number = Math.PI;
const endAngle: number = 0;
const sweepAngle: number = startAngle - endAngle;
const currentAngle: number = startAngle - sweepAngle * percentage;
const arcStartX: number = centerX + radius * Math.cos(startAngle);
const arcStartY: number = centerY - radius * Math.sin(startAngle);
const arcEndX: number = centerX + radius * Math.cos(endAngle);
const arcEndY: number = centerY - radius * Math.sin(endAngle);
const arcCurrentX: number = centerX + radius * Math.cos(currentAngle);
const arcCurrentY: number = centerY - radius * Math.sin(currentAngle);
const backgroundPath: string = `M ${arcStartX} ${arcStartY} A ${radius} ${radius} 0 0 1 ${arcEndX} ${arcEndY}`;
const valuePath: string = `M ${arcStartX} ${arcStartY} A ${radius} ${radius} 0 ${percentage > 0.5 ? 1 : 0} 1 ${arcCurrentX} ${arcCurrentY}`;
const titleHeightInPx: number = Math.min(
Math.max(props.dashboardComponentHeightInPx * 0.1, 12),
16,
);
const valueHeightInPx: number = Math.max(gaugeSize * 0.22, 16);
// Generate a unique gradient ID for this component instance
const gradientId: string = `gauge-gradient-${props.componentId?.toString() || "default"}`;
// Threshold marker positions on arc
type ThresholdMarker = {
angle: number;
x: number;
y: number;
color: string;
};
const thresholdMarkers: Array<ThresholdMarker> = [];
if (warningThreshold !== undefined && range > 0) {
const warningPct: number = Math.min(
Math.max((warningThreshold - minValue) / range, 0),
1,
);
const warningAngle: number = startAngle - sweepAngle * warningPct;
thresholdMarkers.push({
angle: warningAngle,
x: centerX + (radius + strokeWidth * 0.7) * Math.cos(warningAngle),
y: centerY - (radius + strokeWidth * 0.7) * Math.sin(warningAngle),
color: "#f59e0b",
});
}
if (criticalThreshold !== undefined && range > 0) {
const criticalPct: number = Math.min(
Math.max((criticalThreshold - minValue) / range, 0),
1,
);
const criticalAngle: number = startAngle - sweepAngle * criticalPct;
thresholdMarkers.push({
angle: criticalAngle,
x: centerX + (radius + strokeWidth * 0.7) * Math.cos(criticalAngle),
y: centerY - (radius + strokeWidth * 0.7) * Math.sin(criticalAngle),
color: "#ef4444",
});
}
const percentDisplay: number = Math.round(percentage * 100);
return (
<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={{
fontSize: titleHeightInPx > 0 ? `${titleHeightInPx}px` : "",
}}
className="text-center font-medium text-gray-400 mb-2 truncate uppercase tracking-wider"
>
{props.component.arguments.gaugeTitle}
</div>
)}
<svg
width={gaugeSize}
height={gaugeSize / 2 + strokeWidth + 8}
viewBox={`0 0 ${gaugeSize} ${gaugeSize / 2 + strokeWidth + 8}`}
>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={gaugeColor} stopOpacity="0.6" />
<stop offset="50%" stopColor={gaugeColor} stopOpacity="0.85" />
<stop offset="100%" stopColor={gaugeColor} stopOpacity="1" />
</linearGradient>
<filter
id={`gauge-glow-${props.componentId?.toString() || "default"}`}
>
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Background track */}
<path
d={backgroundPath}
fill="none"
stroke="#f0f0f0"
strokeWidth={strokeWidth + 4}
strokeLinecap="round"
/>
{/* Value arc */}
{percentage > 0 && (
<path
d={valuePath}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
filter={`url(#gauge-glow-${props.componentId?.toString() || "default"})`}
/>
)}
{/* Threshold markers */}
{thresholdMarkers.map((marker: ThresholdMarker, index: number) => {
return (
<circle
key={index}
cx={marker.x}
cy={marker.y}
r={3}
fill={marker.color}
stroke="white"
strokeWidth={1.5}
/>
);
})}
{/* Needle tip dot at current position */}
{percentage > 0 && (
<circle
cx={arcCurrentX}
cy={arcCurrentY}
r={strokeWidth * 0.4}
fill="white"
stroke={gaugeColor}
strokeWidth={2}
/>
)}
</svg>
{/* Value + percentage display */}
<div
style={{
marginTop: `-${gaugeSize * 0.2}px`,
}}
>
<div
className="font-bold text-gray-900"
style={{
fontSize: valueHeightInPx > 0 ? `${valueHeightInPx}px` : "",
lineHeight: 1.1,
letterSpacing: "-0.03em",
}}
>
{aggregatedValue}
</div>
<div
className="text-gray-400 font-medium"
style={{
fontSize: `${Math.max(valueHeightInPx * 0.45, 10)}px`,
}}
>
{percentDisplay}%
</div>
</div>
{/* Min/Max labels */}
<div
className="flex justify-between w-full px-2 mt-0.5"
style={{ maxWidth: `${gaugeSize + 10}px` }}
>
<span
className="text-gray-300 tabular-nums"
style={{ fontSize: "10px" }}
>
{minValue}
</span>
<span
className="text-gray-300 tabular-nums"
style={{ fontSize: "10px" }}
>
{maxValue}
</span>
</div>
</div>
);
};
export default DashboardGaugeComponentElement;

View File

@@ -0,0 +1,283 @@
import React, { FunctionComponent, ReactElement, useEffect } from "react";
import DashboardLogStreamComponent from "Common/Types/Dashboard/DashboardComponents/DashboardLogStreamComponent";
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AnalyticsModelAPI, {
ListResult,
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
import Log from "Common/Models/AnalyticsModels/Log";
import API from "Common/UI/Utils/API/API";
import Icon from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import OneUptimeDate from "Common/Types/Date";
import Query from "Common/Types/BaseDatabase/Query";
import {
queryStringToFilter,
LogFilter,
} from "Common/Types/Log/LogQueryToFilter";
export interface ComponentProps extends DashboardBaseComponentProps {
component: DashboardLogStreamComponent;
}
type SeverityColor = {
dot: string;
text: string;
bg: string;
};
const getSeverityColor: (severity: string) => SeverityColor = (
severity: string,
): SeverityColor => {
const lower: string = severity.toLowerCase();
if (lower === "fatal") {
return {
dot: "bg-purple-500",
text: "text-purple-700",
bg: "bg-purple-50",
};
}
if (lower === "error") {
return { dot: "bg-red-500", text: "text-red-700", bg: "bg-red-50" };
}
if (lower === "warning") {
return {
dot: "bg-yellow-500",
text: "text-yellow-700",
bg: "bg-yellow-50",
};
}
if (lower === "information") {
return { dot: "bg-blue-500", text: "text-blue-700", bg: "bg-blue-50" };
}
if (lower === "debug") {
return { dot: "bg-gray-400", text: "text-gray-600", bg: "bg-gray-50" };
}
if (lower === "trace") {
return { dot: "bg-gray-300", text: "text-gray-500", bg: "bg-gray-50" };
}
return { dot: "bg-gray-300", text: "text-gray-500", bg: "bg-gray-50" };
};
const DashboardLogStreamComponentElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [logs, setLogs] = React.useState<Array<Log>>([]);
const [error, setError] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState<boolean>(true);
const maxRows: number = props.component.arguments.maxRows || 50;
const fetchLogs: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
const startAndEndDate: InBetween<Date> =
RangeStartAndEndDateTimeUtil.getStartAndEndDate(
props.dashboardStartAndEndDate,
);
if (!startAndEndDate.startValue || !startAndEndDate.endValue) {
setIsLoading(false);
setError("Please select a valid start and end date.");
return;
}
try {
const query: Query<Log> = {
time: new InBetween<Date>(
startAndEndDate.startValue,
startAndEndDate.endValue,
),
} as Query<Log>;
// Add severity filter if set
if (
props.component.arguments.severityFilter &&
props.component.arguments.severityFilter !== ""
) {
(query as Record<string, unknown>)["severityText"] =
props.component.arguments.severityFilter;
}
// Add body contains filter if set
if (
props.component.arguments.bodyContains &&
props.component.arguments.bodyContains.trim() !== ""
) {
(query as Record<string, unknown>)["body"] =
props.component.arguments.bodyContains.trim();
}
// Add attribute filters if set
if (
props.component.arguments.attributeFilterQuery &&
props.component.arguments.attributeFilterQuery.trim() !== ""
) {
const parsedFilter: LogFilter = queryStringToFilter(
props.component.arguments.attributeFilterQuery.trim(),
);
if (parsedFilter.attributes) {
(query as Record<string, unknown>)["attributes"] =
parsedFilter.attributes;
}
}
const listResult: ListResult<Log> = await AnalyticsModelAPI.getList<Log>({
modelType: Log,
query: query,
limit: maxRows,
skip: 0,
select: {
time: true,
severityText: true,
body: true,
serviceId: true,
traceId: true,
spanId: true,
attributes: true,
},
sort: {
time: SortOrder.Descending,
},
requestOptions: {},
});
setLogs(listResult.data);
setError("");
} catch (err: unknown) {
setError(API.getFriendlyErrorMessage(err as Error));
}
setIsLoading(false);
};
useEffect(() => {
fetchLogs();
}, [props.dashboardStartAndEndDate, props.refreshTick]);
useEffect(() => {
fetchLogs();
}, [
props.component.arguments.severityFilter,
props.component.arguments.bodyContains,
props.component.arguments.attributeFilterQuery,
props.component.arguments.maxRows,
]);
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>
<div className="flex-1 space-y-2">
{Array.from({ length: 6 }).map((_: unknown, i: number) => {
return (
<div
key={i}
className="flex gap-2 items-center"
style={{ opacity: 1 - i * 0.12 }}
>
<div className="w-1.5 h-1.5 bg-gray-200 rounded-full"></div>
<div className="h-3 w-16 bg-gray-100 rounded"></div>
<div
className="h-3 bg-gray-50 rounded flex-1"
style={{ maxWidth: `${40 + Math.random() * 50}%` }}
></div>
</div>
);
})}
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
<div className="h-5 w-5 text-gray-300">
<Icon icon={IconProp.List} />
</div>
</div>
<p className="text-xs text-gray-400 text-center max-w-48">{error}</p>
</div>
);
}
return (
<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">
{props.component.arguments.title}
</span>
<span className="text-xs text-gray-300 tabular-nums">
{logs.length} entries
</span>
</div>
)}
<div className="flex-1 overflow-auto rounded-md border border-gray-100">
<div className="divide-y divide-gray-50">
{logs.map((log: Log, index: number) => {
const severity: string =
(log.severityText as string) || "Unspecified";
const colors: SeverityColor = getSeverityColor(severity);
const body: string = (log.body as string) || "";
const time: Date | undefined = log.time
? OneUptimeDate.fromString(log.time as unknown as string)
: undefined;
return (
<div
key={index}
className="flex items-start gap-2 px-3 py-1.5 hover:bg-gray-50/50 transition-colors duration-100 group"
>
<div className="flex items-center gap-1.5 shrink-0 mt-0.5">
<div
className={`w-1.5 h-1.5 rounded-full ${colors.dot}`}
></div>
<span
className={`text-xs font-medium ${colors.text} ${colors.bg} px-1 py-0.5 rounded w-12 text-center`}
style={{ fontSize: "10px" }}
>
{severity.substring(0, 4).toUpperCase()}
</span>
</div>
{time && (
<span
className="text-xs text-gray-400 shrink-0 tabular-nums"
style={{ fontSize: "11px" }}
>
{OneUptimeDate.getDateAsLocalFormattedString(time, true)}
</span>
)}
<span
className="text-xs text-gray-600 truncate flex-1 font-mono"
style={{ fontSize: "11px" }}
>
{body}
</span>
</div>
);
})}
{logs.length === 0 && (
<div className="px-4 py-8 text-center text-gray-400 text-sm">
No logs found
</div>
)}
</div>
</div>
</div>
);
};
export default DashboardLogStreamComponentElement;

View File

@@ -0,0 +1,269 @@
import React, { FunctionComponent, ReactElement, useEffect } from "react";
import DashboardTableComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTableComponent";
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
import AggregatedModel from "Common/Types/BaseDatabase/AggregatedModel";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricUtil from "../../Metrics/Utils/Metrics";
import API from "Common/UI/Utils/API/API";
import JSONFunctions from "Common/Types/JSONFunctions";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
import Icon from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
import OneUptimeDate from "Common/Types/Date";
export interface ComponentProps extends DashboardBaseComponentProps {
component: DashboardTableComponent;
}
const DashboardTableComponentElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [metricResults, setMetricResults] = React.useState<
Array<AggregatedResult>
>([]);
const [error, setError] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState<boolean>(true);
const metricViewData: MetricViewData = {
queryConfigs: props.component.arguments.metricQueryConfig
? [props.component.arguments.metricQueryConfig]
: [],
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
props.dashboardStartAndEndDate,
),
formulaConfigs: [],
};
const fetchAggregatedResults: PromiseVoidFunction =
async (): Promise<void> => {
setIsLoading(true);
if (
!metricViewData.startAndEndDate?.startValue ||
!metricViewData.startAndEndDate?.endValue
) {
setIsLoading(false);
setError("Please select a valid start and end date.");
return;
}
if (
!metricViewData.queryConfigs ||
metricViewData.queryConfigs.length === 0 ||
!metricViewData.queryConfigs[0] ||
!metricViewData.queryConfigs[0].metricQueryData ||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
Object.keys(metricViewData.queryConfigs[0].metricQueryData.filterData)
.length === 0
) {
setIsLoading(false);
setError("Please select a metric. Click here to add a metric.");
return;
}
if (
!metricViewData.queryConfigs[0] ||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
!metricViewData.queryConfigs[0].metricQueryData.filterData
?.aggegationType
) {
setIsLoading(false);
setError(
"Please select an aggregation. Click here to add an aggregation.",
);
return;
}
try {
const results: Array<AggregatedResult> = await MetricUtil.fetchResults({
metricViewData: metricViewData,
});
setMetricResults(results);
setError("");
} catch (err: unknown) {
setError(API.getFriendlyErrorMessage(err as Error));
}
setIsLoading(false);
};
useEffect(() => {
fetchAggregatedResults();
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
const [metricQueryConfig, setMetricQueryConfig] = React.useState<
MetricQueryConfigData | undefined
>(props.component.arguments.metricQueryConfig);
useEffect(() => {
if (
JSONFunctions.isJSONObjectDifferent(
metricQueryConfig || {},
props.component.arguments.metricQueryConfig || {},
)
) {
setMetricQueryConfig(props.component.arguments.metricQueryConfig);
fetchAggregatedResults();
}
}, [props.component.arguments.metricQueryConfig]);
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>
<div className="flex-1 space-y-2">
<div className="flex gap-4">
<div className="h-3 w-32 bg-gray-100 rounded"></div>
<div className="h-3 w-16 bg-gray-100 rounded ml-auto"></div>
</div>
{Array.from({ length: 5 }).map((_: unknown, i: number) => {
return (
<div
key={i}
className="flex gap-4"
style={{ opacity: 1 - i * 0.15 }}
>
<div className="h-3 w-28 bg-gray-50 rounded"></div>
<div className="h-3 w-14 bg-gray-50 rounded ml-auto"></div>
</div>
);
})}
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
<div className="h-5 w-5 text-gray-300">
<Icon icon={IconProp.TableCells} />
</div>
</div>
<ErrorMessage message={error} />
</div>
);
}
const maxRows: number = props.component.arguments.maxRows || 20;
const allData: Array<AggregatedModel> = [];
for (const result of metricResults) {
for (const item of result.data) {
allData.push(item);
}
}
const displayData: Array<AggregatedModel> = allData.slice(0, maxRows);
// Calculate max value for bar visualization
const maxDataValue: number =
displayData.length > 0
? Math.max(
...displayData.map((item: AggregatedModel) => {
return Math.abs(item.value);
}),
)
: 1;
return (
<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">
{props.component.arguments.tableTitle}
</span>
<span className="text-xs text-gray-300 tabular-nums">
{displayData.length} rows
</span>
</div>
)}
<div className="flex-1 overflow-auto rounded-md border border-gray-100">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-400 uppercase bg-gray-50/80 sticky top-0 border-b border-gray-100">
<tr>
<th
className="px-4 py-2.5 font-medium tracking-wider"
style={{ width: "45%" }}
>
Timestamp
</th>
<th
className="px-4 py-2.5 font-medium tracking-wider text-right"
style={{ width: "25%" }}
>
Value
</th>
<th
className="px-4 py-2.5 font-medium tracking-wider"
style={{ width: "30%" }}
></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{displayData.map((item: AggregatedModel, index: number) => {
const roundedValue: number = Math.round(item.value * 100) / 100;
const barWidth: number =
maxDataValue > 0
? (Math.abs(roundedValue) / maxDataValue) * 100
: 0;
return (
<tr
key={index}
className="hover:bg-gray-50/50 transition-colors duration-100 group"
>
<td className="px-4 py-2 text-gray-500 text-xs">
{OneUptimeDate.getDateAsLocalFormattedString(
OneUptimeDate.fromString(item.timestamp),
)}
</td>
<td className="px-4 py-2 font-semibold text-gray-900 text-right tabular-nums text-xs">
{roundedValue}
</td>
<td className="px-3 py-2">
<div className="w-full h-3 bg-gray-50 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${barWidth}%`,
background:
"linear-gradient(90deg, rgba(99, 102, 241, 0.2) 0%, rgba(99, 102, 241, 0.4) 100%)",
}}
></div>
</div>
</td>
</tr>
);
})}
{displayData.length === 0 && (
<tr>
<td
colSpan={3}
className="px-4 py-8 text-center text-gray-400 text-sm"
>
No data available
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default DashboardTableComponentElement;

View File

@@ -1,6 +1,7 @@
import React, { FunctionComponent, ReactElement } from "react";
import DashboardTextComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTextComponent";
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
import LazyMarkdownViewer from "Common/UI/Components/Markdown.tsx/LazyMarkdownViewer";
export interface ComponentProps extends DashboardBaseComponentProps {
component: DashboardTextComponent;
@@ -9,18 +10,31 @@ export interface ComponentProps extends DashboardBaseComponentProps {
const DashboardTextComponentElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const textClassName: string = `m-auto truncate flex flex-col justify-center h-full ${props.component.arguments.isBold ? "font-medium" : ""} ${props.component.arguments.isItalic ? "italic" : ""} ${props.component.arguments.isUnderline ? "underline" : ""}`;
const textHeightInxPx: number = props.dashboardComponentHeightInPx * 0.4;
if (props.component.arguments.isMarkdown) {
return (
<div className="h-full overflow-auto p-2">
<LazyMarkdownViewer text={props.component.arguments.text || ""} />
</div>
);
}
const textClassName: string = `flex items-center justify-center h-full text-gray-800 leading-snug ${props.component.arguments.isBold ? "font-semibold" : "font-normal"} ${props.component.arguments.isItalic ? "italic" : ""} ${props.component.arguments.isUnderline ? "underline decoration-gray-300 underline-offset-4" : ""}`;
const textHeightInxPx: number = Math.min(
props.dashboardComponentHeightInPx * 0.35,
64,
);
return (
<div className="h-full">
<div className="h-full px-2">
<div
className={textClassName}
style={{
fontSize: textHeightInxPx > 0 ? `${textHeightInxPx}px` : "",
}}
>
{props.component.arguments.text}
{props.component.arguments.text || (
<span className="text-gray-300 text-sm">No text configured</span>
)}
</div>
</div>
);

View File

@@ -0,0 +1,300 @@
import React, { FunctionComponent, ReactElement, useEffect } from "react";
import DashboardTraceListComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTraceListComponent";
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AnalyticsModelAPI, {
ListResult,
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
import Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
import API from "Common/UI/Utils/API/API";
import Icon from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import OneUptimeDate from "Common/Types/Date";
import Query from "Common/Types/BaseDatabase/Query";
export interface ComponentProps extends DashboardBaseComponentProps {
component: DashboardTraceListComponent;
}
type StatusStyle = {
label: string;
textClass: string;
bgClass: string;
};
const getStatusStyle: (statusCode: number) => StatusStyle = (
statusCode: number,
): StatusStyle => {
if (statusCode === SpanStatus.Error) {
return {
label: "Error",
textClass: "text-red-700",
bgClass: "bg-red-50 border-red-100",
};
}
if (statusCode === SpanStatus.Ok) {
return {
label: "Ok",
textClass: "text-green-700",
bgClass: "bg-green-50 border-green-100",
};
}
return {
label: "Unset",
textClass: "text-gray-500",
bgClass: "bg-gray-50 border-gray-100",
};
};
const formatDuration: (durationNano: number) => string = (
durationNano: number,
): string => {
if (durationNano < 1000) {
return `${durationNano}ns`;
}
const durationMicro: number = durationNano / 1000;
if (durationMicro < 1000) {
return `${Math.round(durationMicro)}µs`;
}
const durationMs: number = durationMicro / 1000;
if (durationMs < 1000) {
return `${Math.round(durationMs * 10) / 10}ms`;
}
const durationS: number = durationMs / 1000;
return `${Math.round(durationS * 100) / 100}s`;
};
const DashboardTraceListComponentElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [spans, setSpans] = React.useState<Array<Span>>([]);
const [error, setError] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState<boolean>(true);
const maxRows: number = props.component.arguments.maxRows || 50;
const fetchTraces: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
const startAndEndDate: InBetween<Date> =
RangeStartAndEndDateTimeUtil.getStartAndEndDate(
props.dashboardStartAndEndDate,
);
if (!startAndEndDate.startValue || !startAndEndDate.endValue) {
setIsLoading(false);
setError("Please select a valid start and end date.");
return;
}
try {
const query: Query<Span> = {
startTime: new InBetween<Date>(
startAndEndDate.startValue,
startAndEndDate.endValue,
),
} as Query<Span>;
// Add status filter if set
if (
props.component.arguments.statusFilter &&
props.component.arguments.statusFilter !== ""
) {
(query as Record<string, unknown>)["statusCode"] = parseInt(
props.component.arguments.statusFilter,
);
}
const listResult: ListResult<Span> =
await AnalyticsModelAPI.getList<Span>({
modelType: Span,
query: query,
limit: maxRows,
skip: 0,
select: {
startTime: true,
name: true,
statusCode: true,
durationUnixNano: true,
traceId: true,
spanId: true,
kind: true,
serviceId: true,
},
sort: {
startTime: SortOrder.Descending,
},
requestOptions: {},
});
setSpans(listResult.data);
setError("");
} catch (err: unknown) {
setError(API.getFriendlyErrorMessage(err as Error));
}
setIsLoading(false);
};
useEffect(() => {
fetchTraces();
}, [props.dashboardStartAndEndDate, props.refreshTick]);
useEffect(() => {
fetchTraces();
}, [
props.component.arguments.statusFilter,
props.component.arguments.maxRows,
]);
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>
<div className="flex-1 space-y-2">
<div className="flex gap-4">
<div className="h-3 w-32 bg-gray-100 rounded"></div>
<div className="h-3 w-16 bg-gray-100 rounded"></div>
<div className="h-3 w-12 bg-gray-100 rounded ml-auto"></div>
</div>
{Array.from({ length: 5 }).map((_: unknown, i: number) => {
return (
<div
key={i}
className="flex gap-4"
style={{ opacity: 1 - i * 0.15 }}
>
<div className="h-3 w-28 bg-gray-50 rounded"></div>
<div className="h-3 w-14 bg-gray-50 rounded"></div>
<div className="h-3 w-10 bg-gray-50 rounded ml-auto"></div>
</div>
);
})}
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
<div className="h-5 w-5 text-gray-300">
<Icon icon={IconProp.Activity} />
</div>
</div>
<p className="text-xs text-gray-400 text-center max-w-48">{error}</p>
</div>
);
}
return (
<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">
{props.component.arguments.title}
</span>
<span className="text-xs text-gray-300 tabular-nums">
{spans.length} traces
</span>
</div>
)}
<div className="flex-1 overflow-auto rounded-md border border-gray-100">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-400 uppercase bg-gray-50/80 sticky top-0 border-b border-gray-100">
<tr>
<th
className="px-3 py-2.5 font-medium tracking-wider"
style={{ width: "35%" }}
>
Span Name
</th>
<th
className="px-3 py-2.5 font-medium tracking-wider"
style={{ width: "20%" }}
>
Duration
</th>
<th
className="px-3 py-2.5 font-medium tracking-wider"
style={{ width: "15%" }}
>
Status
</th>
<th
className="px-3 py-2.5 font-medium tracking-wider"
style={{ width: "30%" }}
>
Time
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{spans.map((span: Span, index: number) => {
const statusCode: number =
(span.statusCode as number) || SpanStatus.Unset;
const statusStyle: StatusStyle = getStatusStyle(statusCode);
const durationNano: number =
(span.durationUnixNano as number) || 0;
const startTime: Date | undefined = span.startTime
? OneUptimeDate.fromString(span.startTime as unknown as string)
: undefined;
return (
<tr
key={index}
className="hover:bg-gray-50/50 transition-colors duration-100 group"
>
<td className="px-3 py-2 text-xs text-gray-700 font-mono truncate">
{(span.name as string) || "—"}
</td>
<td className="px-3 py-2 text-xs text-gray-600 tabular-nums font-medium">
{formatDuration(durationNano)}
</td>
<td className="px-3 py-2">
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border ${statusStyle.textClass} ${statusStyle.bgClass}`}
style={{ fontSize: "10px" }}
>
{statusStyle.label}
</span>
</td>
<td className="px-3 py-2 text-xs text-gray-500 tabular-nums">
{startTime
? OneUptimeDate.getDateAsLocalFormattedString(
startTime,
true,
)
: "—"}
</td>
</tr>
);
})}
{spans.length === 0 && (
<tr>
<td
colSpan={4}
className="px-4 py-8 text-center text-gray-400 text-sm"
>
No traces found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default DashboardTraceListComponentElement;

View File

@@ -1,24 +1,88 @@
import React, { FunctionComponent, ReactElement, useEffect } from "react";
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import AggregatedModel from "Common/Types/BaseDatabase/AggregatedModel";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricUtil from "../../Metrics/Utils/Metrics";
import API from "Common/UI/Utils/API/API";
import DashboardValueComponent from "Common/Types/Dashboard/DashboardComponents/DashboardValueComponent";
import DashboardValueComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardValueComponent";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
import JSONFunctions from "Common/Types/JSONFunctions";
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
import MetricType from "Common/Models/DatabaseModels/MetricType";
import Icon from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
export interface ComponentProps extends DashboardBaseComponentProps {
component: DashboardValueComponent;
component: DashboardValueComponentType;
}
const DashboardValueComponent: FunctionComponent<ComponentProps> = (
// Mini sparkline SVG component
interface SparklineProps {
data: Array<number>;
width: number;
height: number;
color: string;
fillColor: string;
}
const Sparkline: FunctionComponent<SparklineProps> = (
props: SparklineProps,
): ReactElement => {
if (props.data.length < 2) {
return <></>;
}
const dataPoints: Array<number> = props.data;
const minVal: number = Math.min(...dataPoints);
const maxVal: number = Math.max(...dataPoints);
const range: number = maxVal - minVal || 1;
const padding: number = 2;
const points: string = dataPoints
.map((value: number, index: number) => {
const x: number =
padding +
(index / (dataPoints.length - 1)) * (props.width - padding * 2);
const y: number =
props.height -
padding -
((value - minVal) / range) * (props.height - padding * 2);
return `${x},${y}`;
})
.join(" ");
// Create fill area path
const firstX: number = padding;
const lastX: number =
padding +
((dataPoints.length - 1) / (dataPoints.length - 1)) *
(props.width - padding * 2);
const fillPoints: string = `${firstX},${props.height} ${points} ${lastX},${props.height}`;
return (
<svg
width={props.width}
height={props.height}
viewBox={`0 0 ${props.width} ${props.height}`}
className="overflow-visible"
>
<polygon points={fillPoints} fill={props.fillColor} />
<polyline
points={points}
fill="none"
stroke={props.color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
const DashboardValueComponentElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [metricResults, setMetricResults] = React.useState<
@@ -99,14 +163,9 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
useEffect(() => {
fetchAggregatedResults();
}, [props.dashboardStartAndEndDate, props.metricTypes]);
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
useEffect(() => {
fetchAggregatedResults();
}, []);
useEffect(() => {
// set metricQueryConfig to the new value only if it is different from the previous value
if (
JSONFunctions.isJSONObjectDifferent(
metricQueryConfig || {},
@@ -118,40 +177,90 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
}
}, [props.component.arguments.metricQueryConfig]);
if (isLoading) {
return <ComponentLoader />;
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>
<div className="h-8 w-24 bg-gray-100 rounded mb-2"></div>
<div className="h-6 w-32 bg-gray-50 rounded mt-1"></div>
</div>
);
}
if (error) {
return <ErrorMessage message={error} />;
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
<div className="h-5 w-5 text-gray-300">
<Icon icon={IconProp.ChartBar} />
</div>
</div>
<p className="text-xs text-gray-400 text-center max-w-40">{error}</p>
</div>
);
}
let heightOfText: number | undefined =
(props.dashboardComponentHeightInPx || 0) - 100;
// Show setup state if no metric configured
if (
!props.component.arguments.metricQueryConfig ||
!props.component.arguments.metricQueryConfig.metricQueryData?.filterData ||
Object.keys(
props.component.arguments.metricQueryConfig.metricQueryData.filterData,
).length === 0
) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
<div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center">
<svg
className="w-5 h-5 text-indigo-300"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<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 0 1 3 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 0 1-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 0 1-1.125-1.125V4.125Z"
/>
</svg>
</div>
<p className="text-xs font-medium text-gray-500">
{props.component.arguments.title || "Value Widget"}
</p>
<p className="text-xs text-gray-400 text-center">
Click to configure metric
</p>
</div>
);
}
if (heightOfText < 0) {
heightOfText = undefined;
// Collect all data points for sparkline and aggregation
const allDataPoints: Array<AggregatedModel> = [];
for (const result of metricResults) {
for (const item of result.data) {
allDataPoints.push(item);
}
}
let aggregatedValue: number = 0;
let avgCount: number = 0;
for (const result of metricResults) {
for (const item of result.data) {
const value: number = item.value;
for (const item of allDataPoints) {
const value: number = item.value;
if (aggregationType === AggregationType.Avg) {
aggregatedValue += value;
avgCount += 1;
} else if (aggregationType === AggregationType.Sum) {
aggregatedValue += value;
} else if (aggregationType === AggregationType.Min) {
aggregatedValue = Math.min(aggregatedValue, value);
} else if (aggregationType === AggregationType.Max) {
aggregatedValue = Math.max(aggregatedValue, value);
} else if (aggregationType === AggregationType.Count) {
aggregatedValue += 1;
}
if (aggregationType === AggregationType.Avg) {
aggregatedValue += value;
avgCount += 1;
} else if (aggregationType === AggregationType.Sum) {
aggregatedValue += value;
} else if (aggregationType === AggregationType.Min) {
aggregatedValue = Math.min(aggregatedValue, value);
} else if (aggregationType === AggregationType.Max) {
aggregatedValue = Math.max(aggregatedValue, value);
} else if (aggregationType === AggregationType.Count) {
aggregatedValue += 1;
}
}
@@ -162,8 +271,17 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
// round to 2 decimal places
aggregatedValue = Math.round(aggregatedValue * 100) / 100;
const valueHeightInPx: number = props.dashboardComponentHeightInPx * 0.4;
const titleHeightInPx: number = props.dashboardComponentHeightInPx * 0.13;
// Sparkline data - take raw values in order
const sparklineData: Array<number> = allDataPoints.map(
(item: AggregatedModel) => {
return item.value;
},
);
const valueHeightInPx: number = props.dashboardComponentHeightInPx * 0.35;
const titleHeightInPx: number = props.dashboardComponentHeightInPx * 0.11;
const showSparkline: boolean =
sparklineData.length >= 2 && props.dashboardComponentHeightInPx > 100;
const unit: string | undefined =
props.metricTypes?.find((item: MetricType) => {
@@ -173,27 +291,146 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
);
})?.unit || "";
// Determine color based on thresholds
let valueColorClass: string = "text-gray-900";
let bgStyle: React.CSSProperties = {};
let sparklineColor: string = "#6366f1"; // indigo
let sparklineFill: string = "rgba(99, 102, 241, 0.08)";
const warningThreshold: number | undefined =
props.component.arguments.warningThreshold;
const criticalThreshold: number | undefined =
props.component.arguments.criticalThreshold;
if (criticalThreshold !== undefined && aggregatedValue >= criticalThreshold) {
valueColorClass = "text-red-600";
bgStyle = {
background:
"linear-gradient(135deg, rgba(254, 226, 226, 0.4) 0%, rgba(254, 202, 202, 0.2) 100%)",
};
sparklineColor = "#ef4444";
sparklineFill = "rgba(239, 68, 68, 0.08)";
} else if (
warningThreshold !== undefined &&
aggregatedValue >= warningThreshold
) {
valueColorClass = "text-amber-600";
bgStyle = {
background:
"linear-gradient(135deg, rgba(254, 243, 199, 0.4) 0%, rgba(253, 230, 138, 0.2) 100%)",
};
sparklineColor = "#f59e0b";
sparklineFill = "rgba(245, 158, 11, 0.08)";
}
// Calculate trend (compare first half avg to second half avg)
let trendPercent: number | null = null;
let trendDirection: "up" | "down" | "flat" = "flat";
if (sparklineData.length >= 4) {
const midpoint: number = Math.floor(sparklineData.length / 2);
const firstHalf: Array<number> = sparklineData.slice(0, midpoint);
const secondHalf: Array<number> = sparklineData.slice(midpoint);
const firstAvg: number =
firstHalf.reduce((a: number, b: number) => {
return a + b;
}, 0) / firstHalf.length;
const secondAvg: number =
secondHalf.reduce((a: number, b: number) => {
return a + b;
}, 0) / secondHalf.length;
if (firstAvg !== 0) {
trendPercent =
Math.round(((secondAvg - firstAvg) / Math.abs(firstAvg)) * 1000) / 10;
trendDirection =
trendPercent > 0.5 ? "up" : trendPercent < -0.5 ? "down" : "flat";
}
}
const sparklineWidth: number = Math.min(
props.dashboardComponentWidthInPx * 0.6,
120,
);
const sparklineHeight: number = Math.min(
props.dashboardComponentHeightInPx * 0.18,
30,
);
return (
<div className="w-full text-center h-full m-auto">
<div
style={{
fontSize: titleHeightInPx > 0 ? `${titleHeightInPx}px` : "",
}}
className="text-center text-bold mb-1 truncate"
>
{props.component.arguments.title || " "}
<div
className="w-full h-full flex flex-col items-center justify-center rounded-md relative overflow-hidden"
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">
<span
style={{
fontSize:
titleHeightInPx > 0
? `${Math.max(Math.min(titleHeightInPx, 14), 11)}px`
: "12px",
}}
className="text-center font-medium text-gray-400 truncate uppercase tracking-wider"
>
{props.component.arguments.title || " "}
</span>
</div>
{/* Value */}
<div
className="text-center text-semibold truncate"
className={`text-center font-bold truncate ${valueColorClass}`}
style={{
fontSize: valueHeightInPx > 0 ? `${valueHeightInPx}px` : "",
lineHeight: 1.15,
letterSpacing: "-0.03em",
}}
>
{aggregatedValue || "0"}
{unit}
<span
className="text-gray-400 font-normal"
style={{
fontSize: valueHeightInPx > 0 ? `${valueHeightInPx * 0.3}px` : "",
}}
>
{unit ? ` ${unit}` : ""}
</span>
</div>
{/* Trend indicator */}
{trendPercent !== null && trendDirection !== "flat" && (
<div
className={`flex items-center gap-0.5 mt-0.5 ${
trendDirection === "up" ? "text-emerald-500" : "text-red-500"
}`}
style={{
fontSize: `${Math.max(Math.min(titleHeightInPx, 12), 10)}px`,
}}
>
<span>{trendDirection === "up" ? "\u2191" : "\u2193"}</span>
<span className="font-medium tabular-nums">
{Math.abs(trendPercent)}%
</span>
</div>
)}
{/* Sparkline */}
{showSparkline && (
<div className="mt-1">
<Sparkline
data={sparklineData}
width={sparklineWidth}
height={sparklineHeight}
color={sparklineColor}
fillColor={sparklineFill}
/>
</div>
)}
</div>
);
};
export default DashboardValueComponent;
export default DashboardValueComponentElement;

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

@@ -1,6 +1,7 @@
import React, {
FunctionComponent,
ReactElement,
useCallback,
useEffect,
useRef,
useState,
@@ -9,12 +10,19 @@ import DashboardToolbar from "./Toolbar/DashboardToolbar";
import DashboardCanvas from "./Canvas/Index";
import DashboardMode from "Common/Types/Dashboard/DashboardMode";
import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentType";
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
import DashboardViewConfig, {
AutoRefreshInterval,
getAutoRefreshIntervalInMs,
} from "Common/Types/Dashboard/DashboardViewConfig";
import { ObjectType } from "Common/Types/JSON";
import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent";
import DashboardChartComponentUtil from "Common/Utils/Dashboard/Components/DashboardChartComponent";
import DashboardValueComponentUtil from "Common/Utils/Dashboard/Components/DashboardValueComponent";
import DashboardTextComponentUtil from "Common/Utils/Dashboard/Components/DashboardTextComponent";
import DashboardTableComponentUtil from "Common/Utils/Dashboard/Components/DashboardTableComponent";
import DashboardGaugeComponentUtil from "Common/Utils/Dashboard/Components/DashboardGaugeComponent";
import DashboardLogStreamComponentUtil from "Common/Utils/Dashboard/Components/DashboardLogStreamComponent";
import DashboardTraceListComponentUtil from "Common/Utils/Dashboard/Components/DashboardTraceListComponent";
import BadDataException from "Common/Types/Exception/BadDataException";
import ObjectID from "Common/Types/ObjectID";
import Dashboard from "Common/Models/DatabaseModels/Dashboard";
@@ -30,6 +38,7 @@ import MetricUtil from "../Metrics/Utils/Metrics";
import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime";
import TimeRange from "Common/Types/Time/TimeRange";
import MetricType from "Common/Models/DatabaseModels/MetricType";
import DashboardVariable from "Common/Types/Dashboard/DashboardVariable";
export interface ComponentProps {
dashboardId: ObjectID;
@@ -49,6 +58,23 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
const [isSaving, setIsSaving] = useState<boolean>(false);
// Auto-refresh state
const [autoRefreshInterval, setAutoRefreshInterval] =
useState<AutoRefreshInterval>(AutoRefreshInterval.OFF);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [dashboardVariables, setDashboardVariables] = useState<
Array<DashboardVariable>
>([]);
// Zoom stack for time range
const [timeRangeStack, setTimeRangeStack] = useState<
Array<RangeStartAndEndDateTime>
>([]);
const autoRefreshTimerRef: React.MutableRefObject<ReturnType<
typeof setInterval
> | null> = useRef<ReturnType<typeof setInterval> | null>(null);
const [refreshTick, setRefreshTick] = useState<number>(0);
// ref for dashboard div.
const dashboardViewRef: React.RefObject<HTMLDivElement> =
@@ -76,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);
@@ -132,6 +159,8 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
dashboardViewConfig: true,
name: true,
description: true,
pageTitle: true,
pageDescription: true,
},
});
@@ -140,13 +169,28 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
return;
}
setDashboardViewConfig(
JSONFunctions.deserializeValue(
dashboard.dashboardViewConfig ||
DashboardViewConfigUtil.createDefaultDashboardViewConfig(),
) as DashboardViewConfig,
const config: DashboardViewConfig = JSONFunctions.deserializeValue(
dashboard.dashboardViewConfig ||
DashboardViewConfigUtil.createDefaultDashboardViewConfig(),
) as DashboardViewConfig;
setDashboardViewConfig(config);
setDashboardName(
dashboard.pageTitle || dashboard.name || "Untitled Dashboard",
);
setDashboardName(dashboard.name || "Untitled Dashboard");
setDashboardDescription(
dashboard.pageDescription || dashboard.description || "",
);
// Restore saved auto-refresh interval
if (config.refreshInterval) {
setAutoRefreshInterval(config.refreshInterval);
}
// Restore saved variables
if (config.variables) {
setDashboardVariables(config.variables);
}
};
const loadPage: PromiseVoidFunction = async (): Promise<void> => {
@@ -169,6 +213,47 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
});
}, []);
// Auto-refresh timer management
const triggerRefresh: () => void = useCallback(() => {
setIsRefreshing(true);
setRefreshTick((prev: number) => {
return prev + 1;
});
// Brief indicator
setTimeout(() => {
setIsRefreshing(false);
}, 500);
}, []);
useEffect(() => {
// Clear existing timer
if (autoRefreshTimerRef.current) {
clearInterval(autoRefreshTimerRef.current);
autoRefreshTimerRef.current = null;
}
// Don't auto-refresh in edit mode
if (dashboardMode === DashboardMode.Edit) {
return;
}
const intervalMs: number | null =
getAutoRefreshIntervalInMs(autoRefreshInterval);
if (intervalMs !== null) {
autoRefreshTimerRef.current = setInterval(() => {
triggerRefresh();
}, intervalMs);
}
return () => {
if (autoRefreshTimerRef.current) {
clearInterval(autoRefreshTimerRef.current);
autoRefreshTimerRef.current = null;
}
};
}, [autoRefreshInterval, dashboardMode, triggerRefresh]);
const isEditMode: boolean = dashboardMode === DashboardMode.Edit;
const sideBarWidth: number = isEditMode && selectedComponentId ? 650 : 0;
@@ -191,9 +276,13 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
return (
<div
ref={dashboardViewRef}
className="min-h-screen"
style={{
minWidth: "1000px",
width: `calc(100% - ${sideBarWidth}px)`,
background: isEditMode
? "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)"
: "#f8f9fb",
}}
>
<DashboardToolbar
@@ -217,17 +306,36 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
}}
dashboardViewConfig={dashboardViewConfig}
dashboardName={dashboardName}
dashboardDescription={dashboardDescription}
isSaving={isSaving}
onSaveClick={() => {
// Save auto-refresh interval with the config
const configWithRefresh: DashboardViewConfig = {
...dashboardViewConfig,
refreshInterval: autoRefreshInterval,
};
setDashboardViewConfig(configWithRefresh);
saveDashboardViewConfig().catch((err: Error) => {
setError(API.getFriendlyErrorMessage(err));
});
setDashboardMode(DashboardMode.View);
}}
startAndEndDate={startAndEndDate}
canResetZoom={timeRangeStack.length > 0}
onResetZoom={() => {
if (timeRangeStack.length > 0) {
const previousRange: RangeStartAndEndDateTime =
timeRangeStack[timeRangeStack.length - 1]!;
setStartAndEndDate(previousRange);
setTimeRangeStack(timeRangeStack.slice(0, -1));
}
}}
onStartAndEndDateChange={(
newStartAndEndDate: RangeStartAndEndDateTime,
) => {
// Push current range to zoom stack before changing
setTimeRangeStack([...timeRangeStack, startAndEndDate]);
setStartAndEndDate(newStartAndEndDate);
}}
onCancelEditClick={async () => {
@@ -238,6 +346,26 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
onEditClick={() => {
setDashboardMode(DashboardMode.Edit);
}}
autoRefreshInterval={autoRefreshInterval}
onAutoRefreshIntervalChange={(interval: AutoRefreshInterval) => {
setAutoRefreshInterval(interval);
}}
isRefreshing={isRefreshing}
variables={dashboardVariables}
onVariableValueChange={(variableId: string, value: string) => {
const updatedVariables: Array<DashboardVariable> =
dashboardVariables.map((v: DashboardVariable) => {
if (v.id === variableId) {
return { ...v, selectedValue: value };
}
return v;
});
setDashboardVariables(updatedVariables);
// Trigger refresh when variable changes
setRefreshTick((prev: number) => {
return prev + 1;
});
}}
onAddComponentClick={(componentType: DashboardComponentType) => {
let newComponent: DashboardBaseComponent | null = null;
@@ -253,6 +381,24 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
newComponent = DashboardTextComponentUtil.getDefaultComponent();
}
if (componentType === DashboardComponentType.Table) {
newComponent = DashboardTableComponentUtil.getDefaultComponent();
}
if (componentType === DashboardComponentType.Gauge) {
newComponent = DashboardGaugeComponentUtil.getDefaultComponent();
}
if (componentType === DashboardComponentType.LogStream) {
newComponent =
DashboardLogStreamComponentUtil.getDefaultComponent();
}
if (componentType === DashboardComponentType.TraceList) {
newComponent =
DashboardTraceListComponentUtil.getDefaultComponent();
}
if (!newComponent) {
throw new BadDataException(
`Unknown component type: ${componentType}`,
@@ -270,7 +416,15 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
setDashboardViewConfig(newDashboardConfig);
}}
/>
<div ref={dashboardCanvasRef}>
<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) => {
@@ -291,6 +445,7 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
telemetryAttributes,
metricTypes,
}}
refreshTick={refreshTick}
/>
</div>
</div>

View File

@@ -1,15 +1,32 @@
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";
import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentType";
import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime";
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
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;
@@ -20,11 +37,229 @@ export interface ComponentProps {
onAddComponentClick: (type: DashboardComponentType) => void;
isSaving: boolean;
dashboardName: string;
dashboardDescription?: string | undefined;
startAndEndDate: RangeStartAndEndDateTime;
onStartAndEndDateChange: (startAndEndDate: RangeStartAndEndDateTime) => void;
dashboardViewConfig: DashboardViewConfig;
autoRefreshInterval: AutoRefreshInterval;
onAutoRefreshIntervalChange: (interval: AutoRefreshInterval) => void;
isRefreshing?: boolean | undefined;
variables?: Array<DashboardVariable> | undefined;
onVariableValueChange?:
| ((variableId: string, value: string) => void)
| undefined;
canResetZoom?: boolean | undefined;
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 => {
@@ -34,103 +269,234 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
const isSaving: boolean = props.isSaving;
return (
<div
className={`mt-1.5 mb-1.5 ml-1 mr-1 p-1 h-20 pt-5 pb-5 pl-4 pr-4 rounded bg-white border-2 border-gray-100`}
>
<div className="w-full flex justify-between">
<div className="text-md font-medium mt-2">
{/* Name Component */}
{props.dashboardName}
</div>
{!isSaving && (
<div className="flex">
{props.dashboardViewConfig &&
props.dashboardViewConfig.components &&
props.dashboardViewConfig.components.length > 0 && (
<div className="mt-1.5">
<RangeStartAndEndDateView
dashboardStartAndEndDate={props.startAndEndDate}
onChange={(startAndEndDate: RangeStartAndEndDateTime) => {
props.onStartAndEndDateChange(startAndEndDate);
}}
/>
</div>
)}
const hasComponents: boolean = Boolean(
props.dashboardViewConfig &&
props.dashboardViewConfig.components &&
props.dashboardViewConfig.components.length > 0,
);
{isEditMode ? (
<MoreMenu menuIcon={IconProp.Add} text="Add Component">
<MoreMenuItem
text={"Add Chart"}
key={"add-chart"}
onClick={() => {
props.onAddComponentClick(DashboardComponentType.Chart);
const isAutoRefreshActive: boolean =
props.autoRefreshInterval !== AutoRefreshInterval.OFF;
const autoRefreshMs: number | null = getAutoRefreshIntervalInMs(
props.autoRefreshInterval,
);
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={"Add Value"}
key={"add-value"}
onClick={() => {
props.onAddComponentClick(DashboardComponentType.Value);
}}
text={"Edit Dashboard"}
icon={IconProp.Pencil}
key={"edit"}
onClick={props.onEditClick}
/>
<MoreMenuItem
text={"Add Text"}
key={"add-text"}
onClick={() => {
props.onAddComponentClick(DashboardComponentType.Text);
}}
text={"Full Screen"}
icon={IconProp.Expand}
key={"fullscreen"}
onClick={props.onFullScreenClick}
/>
</MoreMenu>
) : (
<></>
)}
{!isEditMode && (
<Button
icon={IconProp.Expand}
buttonStyle={ButtonStyleType.ICON}
onClick={props.onFullScreenClick}
tooltip="Full Screen"
/>
{/* Edit mode actions */}
{!isSaving && isEditMode && (
<div className="flex items-center gap-1">
<MoreMenu menuIcon={IconProp.Add} text="Add Widget">
<MoreMenuItem
text={"Chart"}
icon={IconProp.ChartBar}
key={"add-chart"}
onClick={() => {
props.onAddComponentClick(DashboardComponentType.Chart);
}}
/>
<MoreMenuItem
text={"Value"}
icon={IconProp.Hashtag}
key={"add-value"}
onClick={() => {
props.onAddComponentClick(DashboardComponentType.Value);
}}
/>
<MoreMenuItem
text={"Text"}
icon={IconProp.Text}
key={"add-text"}
onClick={() => {
props.onAddComponentClick(DashboardComponentType.Text);
}}
/>
<MoreMenuItem
text={"Table"}
icon={IconProp.TableCells}
key={"add-table"}
onClick={() => {
props.onAddComponentClick(DashboardComponentType.Table);
}}
/>
<MoreMenuItem
text={"Gauge"}
icon={IconProp.Gauge}
key={"add-gauge"}
onClick={() => {
props.onAddComponentClick(DashboardComponentType.Gauge);
}}
/>
<MoreMenuItem
text={"Log Stream"}
icon={IconProp.Logs}
key={"add-log-stream"}
onClick={() => {
props.onAddComponentClick(
DashboardComponentType.LogStream,
);
}}
/>
<MoreMenuItem
text={"Trace List"}
icon={IconProp.Waterfall}
key={"add-trace-list"}
onClick={() => {
props.onAddComponentClick(
DashboardComponentType.TraceList,
);
}}
/>
</MoreMenu>
<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);
}}
/>
</div>
)}
{!isEditMode && (
<Button
icon={IconProp.Pencil}
title="Edit"
buttonStyle={ButtonStyleType.ICON}
onClick={props.onEditClick}
tooltip="Edit"
/>
)}
{isEditMode && (
<Button
icon={IconProp.Check}
title="Save"
buttonStyle={ButtonStyleType.HOVER_PRIMARY_OUTLINE}
onClick={props.onSaveClick}
/>
)}
{isEditMode && (
<Button
icon={IconProp.Close}
title="Cancel"
buttonStyle={ButtonStyleType.HOVER_DANGER_OUTLINE}
onClick={() => {
setShowCancelModal(true);
}}
/>
{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">
<Loader />
<div className="ml-2 text-sm text-gray-400">Saving...</div>
</div>
)}
</div>
</div>
{showCancelModal ? (

View File

@@ -0,0 +1,65 @@
import React, { FunctionComponent, ReactElement } from "react";
import DashboardVariable from "Common/Types/Dashboard/DashboardVariable";
export interface ComponentProps {
variables: Array<DashboardVariable>;
onVariableValueChange: (variableId: string, value: string) => void;
}
const DashboardVariableSelector: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
if (!props.variables || props.variables.length === 0) {
return <></>;
}
return (
<div className="flex flex-wrap gap-3 items-center">
{props.variables.map((variable: DashboardVariable) => {
const options: Array<string> = variable.customListValues
? variable.customListValues.split(",").map((v: string) => {
return v.trim();
})
: [];
return (
<div key={variable.id} className="flex items-center gap-1.5">
<label className="text-xs font-medium text-gray-400 uppercase tracking-wide">
{variable.label || variable.name}
</label>
{options.length > 0 ? (
<select
className="text-xs border border-gray-200 rounded-md px-2.5 py-1.5 bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-300 transition-colors"
value={variable.selectedValue || variable.defaultValue || ""}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
props.onVariableValueChange(variable.id, e.target.value);
}}
>
<option value="">All</option>
{options.map((option: string) => {
return (
<option key={option} value={option}>
{option}
</option>
);
})}
</select>
) : (
<input
type="text"
className="text-xs border border-gray-200 rounded-md px-2.5 py-1.5 bg-white text-gray-700 w-28 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-300 transition-colors"
value={variable.selectedValue || variable.defaultValue || ""}
placeholder={variable.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
props.onVariableValueChange(variable.id, e.target.value);
}}
/>
)}
</div>
);
})}
</div>
);
};
export default DashboardVariableSelector;

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

@@ -29,6 +29,9 @@ export interface ComponentProps {
query: Query<TelemetryException>;
title: string;
description: string;
onFetchSuccess?:
| ((data: Array<TelemetryException>, totalCount: number) => void)
| undefined;
}
const TelemetryExceptionTable: FunctionComponent<ComponentProps> = (
@@ -47,6 +50,7 @@ const TelemetryExceptionTable: FunctionComponent<ComponentProps> = (
userPreferencesKey="telemetry-exception-table"
isEditable={false}
isCreateable={false}
onFetchSuccess={props.onFetchSuccess}
singularName="Exception"
pluralName="Exceptions"
name="TelemetryException"

View File

@@ -3,6 +3,7 @@ import Route from "Common/Types/API/Route";
import IconProp from "Common/Types/Icon/IconProp";
import MetricFormulaConfigData from "Common/Types/Metrics/MetricFormulaConfigData";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
import MetricsViewConfig from "Common/Types/Metrics/MetricsViewConfig";
import {
CheckOn,
CriteriaFilter,
@@ -35,6 +36,15 @@ export interface ComponentProps {
monitorStep: MonitorStep;
}
const isMetricOnlyMonitorType: (monitorType: MonitorType) => boolean = (
monitorType: MonitorType,
): boolean => {
return (
monitorType === MonitorType.Kubernetes ||
monitorType === MonitorType.Metrics
);
};
const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
@@ -77,6 +87,22 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
);
}, [criteriaFilter]);
const isMetricOnly: boolean = isMetricOnlyMonitorType(props.monitorType);
// Auto-select MetricValue for metric-only monitor types (Kubernetes, Metrics)
useEffect(() => {
if (
isMetricOnly &&
criteriaFilter &&
criteriaFilter.checkOn !== CheckOn.MetricValue
) {
props.onChange?.({
...criteriaFilter,
checkOn: CheckOn.MetricValue,
});
}
}, [isMetricOnly]);
if (isLoading) {
return <></>;
}
@@ -125,15 +151,20 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
);
});
// Collect metric variables from both metricMonitor and kubernetesMonitor configs
const metricViewConfig: MetricsViewConfig | undefined =
props.monitorStep.data?.metricMonitor?.metricViewConfig ||
props.monitorStep.data?.kubernetesMonitor?.metricViewConfig;
let metricVariables: Array<string> =
props.monitorStep.data?.metricMonitor?.metricViewConfig?.queryConfigs?.map(
metricViewConfig?.queryConfigs?.map(
(queryConfig: MetricQueryConfigData) => {
return queryConfig.metricAliasData?.metricVariable || "";
},
) || [];
// push formula variables as well.
props.monitorStep.data?.metricMonitor?.metricViewConfig?.formulaConfigs?.forEach(
metricViewConfig?.formulaConfigs?.forEach(
(formulaConfig: MetricFormulaConfigData) => {
metricVariables.push(formulaConfig.metricAliasData.metricVariable || "");
},
@@ -168,24 +199,29 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
return (
<div>
<div className="rounded-md p-2 bg-gray-50 my-5 border-gray-200 border-solid border-2">
<div className="">
<FieldLabelElement title="Filter Type" />
<Dropdown
value={checkOnOptions.find((i: DropdownOption) => {
return i.value === criteriaFilter?.checkOn;
})}
options={checkOnOptions}
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
props.onChange?.({
checkOn: value?.toString() as CheckOn,
filterType: undefined,
value: undefined,
evaluateOverTime: false,
evaluateOverTimeOptions: undefined,
});
}}
/>
</div>
{/* Hide Filter Type dropdown for metric-only monitors since MetricValue is the only option */}
{!isMetricOnly && (
<div className="">
<FieldLabelElement title="Filter Type" />
<Dropdown
value={checkOnOptions.find((i: DropdownOption) => {
return i.value === criteriaFilter?.checkOn;
})}
options={checkOnOptions}
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
props.onChange?.({
checkOn: value?.toString() as CheckOn,
filterType: undefined,
value: undefined,
evaluateOverTime: false,
evaluateOverTimeOptions: undefined,
});
}}
/>
</div>
)}
{criteriaFilter?.checkOn &&
criteriaFilter?.checkOn === CheckOn.DiskUsagePercent && (
@@ -210,7 +246,14 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
{criteriaFilter?.checkOn &&
criteriaFilter?.checkOn === CheckOn.MetricValue && (
<div className="mt-1">
<FieldLabelElement title="Select Metric Variable" />
<FieldLabelElement
title={isMetricOnly ? "Metric" : "Select Metric Variable"}
description={
isMetricOnly
? "Which metric query should this alert rule check?"
: undefined
}
/>
<Dropdown
value={selectedMetricVariableOption}
options={metricVariableOptions}
@@ -232,7 +275,14 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
{criteriaFilter?.checkOn &&
criteriaFilter?.checkOn === CheckOn.MetricValue && (
<div className="mt-1">
<FieldLabelElement title="Select Aggregation" />
<FieldLabelElement
title={isMetricOnly ? "Aggregation" : "Select Aggregation"}
description={
isMetricOnly
? "How to combine multiple data points (e.g. Average, Max, Min)."
: undefined
}
/>
<Dropdown
value={metricAggregationValue}
options={metricAggregationOptions}
@@ -350,7 +400,12 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
{!criteriaFilter?.checkOn ||
(criteriaFilter?.checkOn && (
<div className="mt-1">
<FieldLabelElement title="Filter Condition" />
<FieldLabelElement
title={isMetricOnly ? "Condition" : "Filter Condition"}
description={
isMetricOnly ? "When should this alert trigger?" : undefined
}
/>
<Dropdown
value={filterConditionValue}
options={filterTypeOptions}
@@ -377,7 +432,12 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
checkOn: criteriaFilter?.checkOn,
}) && (
<div className="mt-1">
<FieldLabelElement title="Value" />
<FieldLabelElement
title={isMetricOnly ? "Threshold" : "Value"}
description={
isMetricOnly ? "The value to compare against." : undefined
}
/>
<Input
placeholder={valuePlaceholder}
value={criteriaFilter?.value?.toString()}
@@ -425,7 +485,7 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
<div className="mt-3 -mr-2 w-full flex justify-end">
<Button
title="Delete Filter"
title={isMetricOnly ? "Delete Rule" : "Delete Filter"}
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
icon={IconProp.Trash}
buttonSize={ButtonSize.Small}

View File

@@ -3,6 +3,7 @@ import IconProp from "Common/Types/Icon/IconProp";
import {
CheckOn,
CriteriaFilter,
EvaluateOverTimeType,
FilterType,
} from "Common/Types/Monitor/CriteriaFilter";
import MonitorStep from "Common/Types/Monitor/MonitorStep";
@@ -98,18 +99,39 @@ const CriteriaFilters: FunctionComponent<ComponentProps> = (
})}
<div className="mt-3 -ml-3">
<Button
title="Add Filter"
title={
props.monitorType === MonitorType.Kubernetes ||
props.monitorType === MonitorType.Metrics
? "Add Rule"
: "Add Filter"
}
buttonSize={ButtonSize.Small}
icon={IconProp.Add}
onClick={() => {
const newCriteriaFilters: Array<CriteriaFilter> = [
...criteriaFilters,
];
newCriteriaFilters.push({
checkOn: CheckOn.IsOnline,
filterType: FilterType.EqualTo,
value: "",
});
const isMetricOnly: boolean =
props.monitorType === MonitorType.Kubernetes ||
props.monitorType === MonitorType.Metrics;
newCriteriaFilters.push(
isMetricOnly
? {
checkOn: CheckOn.MetricValue,
filterType: FilterType.GreaterThan,
value: "",
metricMonitorOptions: {
metricAggregationType: EvaluateOverTimeType.AnyValue,
},
}
: {
checkOn: CheckOn.IsOnline,
filterType: FilterType.EqualTo,
value: "",
},
);
props.onChange?.(newCriteriaFilters);
}}
@@ -117,8 +139,18 @@ const CriteriaFilters: FunctionComponent<ComponentProps> = (
</div>
{showCantDeleteModal ? (
<ConfirmModal
description={`We need at least one filter for this criteria. We cant delete one remaining filter. If you don't need filters, please feel free to delete criteria instead.`}
title={`Cannot delete last remaining filter.`}
description={
props.monitorType === MonitorType.Kubernetes ||
props.monitorType === MonitorType.Metrics
? `At least one alert rule is required. If you don't need rules, you can delete the entire criteria instead.`
: `We need at least one filter for this criteria. We cant delete one remaining filter. If you don't need filters, please feel free to delete criteria instead.`
}
title={
props.monitorType === MonitorType.Kubernetes ||
props.monitorType === MonitorType.Metrics
? `Cannot delete last remaining rule.`
: `Cannot delete last remaining filter.`
}
onSubmit={() => {
setShowCantDeleteModal(false);
}}

View File

@@ -0,0 +1,95 @@
import React, { FunctionComponent, ReactElement } from "react";
import Dropdown, {
DropdownOption,
DropdownOptionGroup,
DropdownValue,
} from "Common/UI/Components/Dropdown/Dropdown";
import {
getAllKubernetesMetrics,
getAllKubernetesMetricCategories,
KubernetesMetricDefinition,
KubernetesMetricCategory,
} from "Common/Types/Monitor/KubernetesMetricCatalog";
export interface ComponentProps {
selectedMetricId?: string | undefined;
onMetricSelected: (metric: KubernetesMetricDefinition) => void;
}
const KubernetesMetricPicker: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const allMetrics: Array<KubernetesMetricDefinition> =
getAllKubernetesMetrics();
const allCategories: Array<KubernetesMetricCategory> =
getAllKubernetesMetricCategories();
const groupedOptions: Array<DropdownOptionGroup> = allCategories.map(
(category: KubernetesMetricCategory) => {
const categoryMetrics: Array<KubernetesMetricDefinition> =
allMetrics.filter((m: KubernetesMetricDefinition) => {
return m.category === category;
});
return {
label: category,
options: categoryMetrics.map((m: KubernetesMetricDefinition) => {
return {
label: `${m.friendlyName}${m.unit ? ` (${m.unit})` : ""}`,
value: m.id,
};
}),
};
},
);
const selectedMetric: KubernetesMetricDefinition | undefined =
props.selectedMetricId
? allMetrics.find((m: KubernetesMetricDefinition) => {
return m.id === props.selectedMetricId;
})
: undefined;
const selectedOption: DropdownOption | undefined = selectedMetric
? {
label: `${selectedMetric.friendlyName}${selectedMetric.unit ? ` (${selectedMetric.unit})` : ""}`,
value: selectedMetric.id,
}
: undefined;
return (
<div>
<Dropdown
options={groupedOptions}
value={selectedOption}
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
if (!value) {
return;
}
const metricId: string = value as string;
const metric: KubernetesMetricDefinition | undefined =
allMetrics.find((m: KubernetesMetricDefinition) => {
return m.id === metricId;
});
if (metric) {
props.onMetricSelected(metric);
}
}}
placeholder="Select a Kubernetes metric..."
/>
{selectedMetric && (
<p className="mt-2 text-xs text-gray-500">
{selectedMetric.description} Metric:{" "}
<code className="bg-gray-100 px-1 rounded text-xs">
{selectedMetric.metricName}
</code>
</p>
)}
</div>
);
};
export default KubernetesMetricPicker;

View File

@@ -0,0 +1,747 @@
import MonitorStepKubernetesMonitor, {
MonitorStepKubernetesMonitorUtil,
KubernetesResourceScope,
} from "Common/Types/Monitor/MonitorStepKubernetesMonitor";
import MonitorStep from "Common/Types/Monitor/MonitorStep";
import ObjectID from "Common/Types/ObjectID";
import React, { FunctionComponent, ReactElement, useEffect } from "react";
import MetricView from "../../../Metrics/MetricView";
import RollingTime from "Common/Types/RollingTime/RollingTime";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import RollingTimePicker from "Common/UI/Components/RollingTimePicker/RollingTimePicker";
import RollingTimeUtil from "Common/Types/RollingTime/RollingTimeUtil";
import FieldLabelElement from "Common/UI/Components/Forms/Fields/FieldLabel";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
import Dropdown, {
DropdownOption,
DropdownValue,
} from "Common/UI/Components/Dropdown/Dropdown";
import Input from "Common/UI/Components/Input/Input";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import ListResult from "Common/Types/BaseDatabase/ListResult";
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
import KubernetesTemplatePicker from "./KubernetesTemplatePicker";
import KubernetesMetricPicker from "./KubernetesMetricPicker";
import {
KubernetesAlertTemplate,
getKubernetesAlertTemplateById,
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";
export interface ComponentProps {
monitorStepKubernetesMonitor: MonitorStepKubernetesMonitor;
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> = [
{
label: "Cluster",
value: KubernetesResourceScope.Cluster,
},
{
label: "Namespace",
value: KubernetesResourceScope.Namespace,
},
{
label: "Workload",
value: KubernetesResourceScope.Workload,
},
{
label: "Node",
value: KubernetesResourceScope.Node,
},
{
label: "Pod",
value: KubernetesResourceScope.Pod,
},
];
const aggregationOptions: Array<DropdownOption> = [
{ label: "Average", value: MetricsAggregationType.Avg },
{ label: "Maximum", value: MetricsAggregationType.Max },
{ label: "Minimum", value: MetricsAggregationType.Min },
{ label: "Sum", value: MetricsAggregationType.Sum },
{ label: "Count", value: MetricsAggregationType.Count },
];
const KubernetesMonitorStepForm: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
// Read query params for template/cluster pre-fill
const urlTemplateId: string | undefined =
props.initialTemplateId ||
Navigation.getQueryStringByName("templateId") ||
undefined;
const urlClusterId: string | undefined =
props.initialClusterId ||
Navigation.getQueryStringByName("clusterId") ||
undefined;
const [, setMode] = React.useState<KubernetesFormMode>("quick");
const [rollingTime, setRollingTime] = React.useState<RollingTime | null>(
null,
);
const monitorStepKubernetesMonitor: MonitorStepKubernetesMonitor =
props.monitorStepKubernetesMonitor ||
MonitorStepKubernetesMonitorUtil.getDefault();
const [startAndEndTime, setStartAndEndTime] =
React.useState<InBetween<Date> | null>(null);
const [clusterOptions, setClusterOptions] = React.useState<
Array<DropdownOption>
>([]);
const [, setIsLoadingClusters] = React.useState<boolean>(true);
// Quick Setup state
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
string | undefined
>(urlTemplateId);
// Custom Metric state
const [selectedMetricId, setSelectedMetricId] = React.useState<
string | undefined
>(undefined);
const [customAggregation, setCustomAggregation] =
React.useState<MetricsAggregationType>(MetricsAggregationType.Avg);
const [customResourceScope, setCustomResourceScope] =
React.useState<KubernetesResourceScope>(KubernetesResourceScope.Cluster);
useEffect(() => {
// Load clusters
setIsLoadingClusters(true);
ModelAPI.getList<KubernetesCluster>({
modelType: KubernetesCluster,
query: {},
select: {
_id: true,
name: true,
clusterIdentifier: true,
},
sort: {
name: SortOrder.Ascending,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
})
.then((result: ListResult<KubernetesCluster>) => {
const options: Array<DropdownOption> = result.data.map(
(cluster: KubernetesCluster) => {
return {
label: cluster.name || cluster.clusterIdentifier || "Unknown",
value: cluster.clusterIdentifier || "",
};
},
);
setClusterOptions(options);
// Auto-select cluster if initialClusterId or URL param is provided
if (urlClusterId && !monitorStepKubernetesMonitor.clusterIdentifier) {
const matchedCluster: DropdownOption | undefined = options.find(
(o: DropdownOption) => {
return o.value === urlClusterId;
},
);
if (matchedCluster) {
props.onChange({
...monitorStepKubernetesMonitor,
clusterIdentifier: matchedCluster.value as string,
});
}
}
})
.catch((_err: Error) => {
setClusterOptions([]);
})
.finally(() => {
setIsLoadingClusters(false);
});
}, []);
// Handle initial template selection
useEffect(() => {
if (urlTemplateId && monitorStepKubernetesMonitor.clusterIdentifier) {
const template: KubernetesAlertTemplate | undefined =
getKubernetesAlertTemplateById(urlTemplateId);
if (template) {
handleTemplateSelection(template);
}
}
}, [props.initialTemplateId, monitorStepKubernetesMonitor.clusterIdentifier]);
useEffect(() => {
if (rollingTime === monitorStepKubernetesMonitor.rollingTime) {
return;
}
setRollingTime(monitorStepKubernetesMonitor.rollingTime);
setStartAndEndTime(
RollingTimeUtil.convertToStartAndEndDate(
monitorStepKubernetesMonitor.rollingTime || RollingTime.Past1Minute,
),
);
}, [monitorStepKubernetesMonitor.rollingTime]);
useEffect(() => {
setStartAndEndTime(
RollingTimeUtil.convertToStartAndEndDate(
monitorStepKubernetesMonitor.rollingTime || RollingTime.Past1Minute,
),
);
}, []);
const handleTemplateSelection: (template: KubernetesAlertTemplate) => void = (
template: KubernetesAlertTemplate,
): void => {
setSelectedTemplateId(template.id);
/*
* Build the kubernetes monitor config from the template's getMonitorStep
* We need the cluster identifier to build the config
*/
const clusterIdentifier: string =
monitorStepKubernetesMonitor.clusterIdentifier;
/*
* Use real monitor status and severity IDs if available,
* so the template criteria are properly configured
*/
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,
offlineMonitorStatusId,
defaultIncidentSeverityId,
defaultAlertSeverityId,
monitorName,
});
// Extract the kubernetes monitor config
if (templateStep.data?.kubernetesMonitor) {
props.onChange({
...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: (
metric: KubernetesMetricDefinition,
) => void = (metric: KubernetesMetricDefinition): void => {
setSelectedMetricId(metric.id);
setCustomAggregation(metric.defaultAggregation);
setCustomResourceScope(metric.defaultResourceScope);
const clusterIdentifier: string =
monitorStepKubernetesMonitor.clusterIdentifier;
const config: MonitorStepKubernetesMonitor = buildKubernetesMonitorConfig({
clusterIdentifier: clusterIdentifier || "",
metricName: metric.metricName,
metricAlias: metric.id.replace(/-/g, "_"),
resourceScope: metric.defaultResourceScope,
rollingTime:
monitorStepKubernetesMonitor.rollingTime || RollingTime.Past5Minutes,
aggregationType: metric.defaultAggregation,
});
props.onChange(config);
};
const showNamespaceFilter: boolean =
monitorStepKubernetesMonitor.resourceScope ===
KubernetesResourceScope.Namespace ||
monitorStepKubernetesMonitor.resourceScope ===
KubernetesResourceScope.Workload ||
monitorStepKubernetesMonitor.resourceScope === KubernetesResourceScope.Pod;
const showWorkloadFilter: boolean =
monitorStepKubernetesMonitor.resourceScope ===
KubernetesResourceScope.Workload;
const showNodeFilter: boolean =
monitorStepKubernetesMonitor.resourceScope === KubernetesResourceScope.Node;
const showPodFilter: boolean =
monitorStepKubernetesMonitor.resourceScope === KubernetesResourceScope.Pod;
const renderClusterDropdown: () => ReactElement = (): ReactElement => {
return (
<div className="mb-4">
<FieldLabelElement
title="Kubernetes Cluster"
description={"Select the Kubernetes cluster to monitor."}
required={true}
/>
<Dropdown
options={clusterOptions}
value={clusterOptions.find((option: DropdownOption) => {
return (
option.value === monitorStepKubernetesMonitor.clusterIdentifier
);
})}
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
props.onChange({
...monitorStepKubernetesMonitor,
clusterIdentifier: (value as string) || "",
});
}}
placeholder="Select a cluster..."
/>
</div>
);
};
const renderResourceFilters: () => ReactElement = (): ReactElement => {
return (
<>
{showNamespaceFilter && (
<div className="mt-3">
<FieldLabelElement
title="Namespace"
description={"Filter by namespace (optional)."}
required={false}
/>
<Input
value={
monitorStepKubernetesMonitor.resourceFilters.namespace || ""
}
onChange={(value: string) => {
props.onChange({
...monitorStepKubernetesMonitor,
resourceFilters: {
...monitorStepKubernetesMonitor.resourceFilters,
namespace: value || undefined,
},
});
}}
placeholder="e.g. default, production"
/>
</div>
)}
{showWorkloadFilter && (
<div className="mt-3">
<FieldLabelElement
title="Workload Name"
description={"Filter by workload name (optional)."}
required={false}
/>
<Input
value={
monitorStepKubernetesMonitor.resourceFilters.workloadName || ""
}
onChange={(value: string) => {
props.onChange({
...monitorStepKubernetesMonitor,
resourceFilters: {
...monitorStepKubernetesMonitor.resourceFilters,
workloadName: value || undefined,
},
});
}}
placeholder="e.g. my-deployment"
/>
</div>
)}
{showNodeFilter && (
<div className="mt-3">
<FieldLabelElement
title="Node Name"
description={"Filter by node name (optional)."}
required={false}
/>
<Input
value={
monitorStepKubernetesMonitor.resourceFilters.nodeName || ""
}
onChange={(value: string) => {
props.onChange({
...monitorStepKubernetesMonitor,
resourceFilters: {
...monitorStepKubernetesMonitor.resourceFilters,
nodeName: value || undefined,
},
});
}}
placeholder="e.g. node-1"
/>
</div>
)}
{showPodFilter && (
<div className="mt-3">
<FieldLabelElement
title="Pod Name"
description={"Filter by pod name (optional)."}
required={false}
/>
<Input
value={monitorStepKubernetesMonitor.resourceFilters.podName || ""}
onChange={(value: string) => {
props.onChange({
...monitorStepKubernetesMonitor,
resourceFilters: {
...monitorStepKubernetesMonitor.resourceFilters,
podName: value || undefined,
},
});
}}
placeholder="e.g. my-pod-abc123"
/>
</div>
)}
</>
);
};
const renderQuickSetup: () => ReactElement = (): ReactElement => {
return (
<div className="mt-4">
<KubernetesTemplatePicker
selectedTemplateId={selectedTemplateId}
onTemplateSelected={(template: KubernetesAlertTemplate) => {
handleTemplateSelection(template);
}}
/>
{selectedTemplateId && (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<h4 className="text-sm font-medium text-blue-900 mb-2">
Template Configuration
</h4>
<p className="text-xs text-blue-700 mb-3">
The following settings have been auto-configured. You can adjust
the time range below.
</p>
<FieldLabelElement
title="Time Range"
description={"Adjust the monitoring time range."}
required={true}
/>
<RollingTimePicker
value={monitorStepKubernetesMonitor.rollingTime}
onChange={(value: RollingTime) => {
if (value === monitorStepKubernetesMonitor.rollingTime) {
return;
}
props.onChange({
...monitorStepKubernetesMonitor,
rollingTime: value,
});
}}
/>
</div>
)}
</div>
);
};
const renderCustomMetric: () => ReactElement = (): ReactElement => {
return (
<div className="mt-4 space-y-4">
<div>
<FieldLabelElement
title="Kubernetes Metric"
description={
"Select a Kubernetes metric to monitor. Metrics are organized by resource type."
}
required={true}
/>
<KubernetesMetricPicker
selectedMetricId={selectedMetricId}
onMetricSelected={(metric: KubernetesMetricDefinition) => {
handleCustomMetricSelection(metric);
}}
/>
</div>
{selectedMetricId && (
<>
<div>
<FieldLabelElement
title="Resource Scope"
description={"Select the scope of resources to monitor."}
required={true}
/>
<Dropdown
options={resourceScopeOptions}
value={resourceScopeOptions.find((option: DropdownOption) => {
return option.value === customResourceScope;
})}
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
const newScope: KubernetesResourceScope =
(value as KubernetesResourceScope) ||
KubernetesResourceScope.Cluster;
setCustomResourceScope(newScope);
props.onChange({
...monitorStepKubernetesMonitor,
resourceScope: newScope,
resourceFilters: {},
});
}}
placeholder="Select resource scope..."
/>
</div>
{renderResourceFilters()}
<div>
<FieldLabelElement
title="Aggregation"
description={
"How should the metric values be aggregated over the time range."
}
required={true}
/>
<Dropdown
options={aggregationOptions}
value={aggregationOptions.find((option: DropdownOption) => {
return option.value === customAggregation;
})}
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
const newAgg: MetricsAggregationType =
(value as MetricsAggregationType) ||
MetricsAggregationType.Avg;
setCustomAggregation(newAgg);
// Rebuild the config with updated aggregation
if (
monitorStepKubernetesMonitor.metricViewConfig.queryConfigs
.length > 0
) {
const currentQueryConfig: MetricQueryConfigData =
monitorStepKubernetesMonitor.metricViewConfig
.queryConfigs[0]!;
if (currentQueryConfig) {
props.onChange({
...monitorStepKubernetesMonitor,
metricViewConfig: {
...monitorStepKubernetesMonitor.metricViewConfig,
queryConfigs: [
{
...currentQueryConfig,
metricQueryData: {
...currentQueryConfig.metricQueryData,
filterData: {
...currentQueryConfig.metricQueryData
.filterData,
aggegationType: newAgg,
},
},
},
],
},
});
}
}
}}
placeholder="Select aggregation..."
/>
</div>
<div>
<FieldLabelElement
title="Time Range"
description={
"Select the time range for the Kubernetes monitor."
}
required={true}
/>
<RollingTimePicker
value={monitorStepKubernetesMonitor.rollingTime}
onChange={(value: RollingTime) => {
if (value === monitorStepKubernetesMonitor.rollingTime) {
return;
}
props.onChange({
...monitorStepKubernetesMonitor,
rollingTime: value,
});
}}
/>
</div>
</>
)}
</div>
);
};
const renderAdvanced: () => ReactElement = (): ReactElement => {
return (
<div className="mt-4">
<div>
<FieldLabelElement
title="Resource Scope"
description={"Select the scope of resources to monitor."}
required={true}
/>
<Dropdown
options={resourceScopeOptions}
value={resourceScopeOptions.find((option: DropdownOption) => {
return (
option.value === monitorStepKubernetesMonitor.resourceScope
);
})}
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
props.onChange({
...monitorStepKubernetesMonitor,
resourceScope:
(value as KubernetesResourceScope) ||
KubernetesResourceScope.Cluster,
resourceFilters: {},
});
}}
placeholder="Select resource scope..."
/>
</div>
{renderResourceFilters()}
<div className="mt-3">
<FieldLabelElement
title="Time Range"
description={"Select the time range for the Kubernetes monitor."}
required={true}
/>
<RollingTimePicker
value={monitorStepKubernetesMonitor.rollingTime}
onChange={(value: RollingTime) => {
if (value === monitorStepKubernetesMonitor.rollingTime) {
return;
}
props.onChange({
...monitorStepKubernetesMonitor,
rollingTime: value,
});
}}
/>
</div>
<div className="mt-3">
<FieldLabelElement
title="Select Metrics"
description={
"Select the Kubernetes metrics to monitor. Use the query builder for full control over metric selection and filtering."
}
required={true}
/>
<div className="mt-3"></div>
<MetricView
hideStartAndEndDate={true}
data={{
startAndEndDate: startAndEndTime,
queryConfigs:
monitorStepKubernetesMonitor.metricViewConfig.queryConfigs,
formulaConfigs:
monitorStepKubernetesMonitor.metricViewConfig.formulaConfigs,
}}
hideCardInQueryElements={true}
hideCardInCharts={true}
chartCssClass="rounded-md border border-gray-200 mt-2 shadow-none"
onChange={(data: MetricViewData) => {
props.onChange({
...monitorStepKubernetesMonitor,
metricViewConfig: {
queryConfigs: data.queryConfigs,
formulaConfigs: data.formulaConfigs,
},
});
}}
/>
</div>
</div>
);
};
const tabs: Array<Tab> = [
{
name: "Quick Setup",
children: renderQuickSetup(),
},
{
name: "Custom Metric",
children: renderCustomMetric(),
},
{
name: "Advanced",
children: renderAdvanced(),
},
];
return (
<div>
{renderClusterDropdown()}
<Tabs
tabs={tabs}
onTabChange={(tab: Tab) => {
let newMode: KubernetesFormMode = "quick";
if (tab.name === "Quick Setup") {
newMode = "quick";
} else if (tab.name === "Custom Metric") {
newMode = "custom";
} else if (tab.name === "Advanced") {
newMode = "advanced";
}
setMode(newMode);
props.onModeChange?.(newMode);
}}
/>
</div>
);
};
export default KubernetesMonitorStepForm;

View File

@@ -0,0 +1,161 @@
import React, { FunctionComponent, ReactElement } from "react";
import {
getAllKubernetesAlertTemplates,
KubernetesAlertTemplate,
KubernetesAlertTemplateCategory,
} from "Common/Types/Monitor/KubernetesAlertTemplates";
import IconProp from "Common/Types/Icon/IconProp";
import Icon from "Common/UI/Components/Icon/Icon";
export interface ComponentProps {
selectedTemplateId?: string | undefined;
onTemplateSelected: (template: KubernetesAlertTemplate) => void;
}
const categories: Array<{
category: KubernetesAlertTemplateCategory;
label: string;
icon: IconProp;
description: string;
}> = [
{
category: "Workload",
label: "Workload",
icon: IconProp.Cube,
description:
"Monitor workload health including pod restarts, replica mismatches, and job failures.",
},
{
category: "Node",
label: "Node",
icon: IconProp.Server,
description:
"Monitor node health including CPU, memory, disk usage, and node readiness.",
},
{
category: "ControlPlane",
label: "Control Plane",
icon: IconProp.Settings,
description:
"Monitor Kubernetes control plane components including etcd, API server, and scheduler.",
},
{
category: "Storage",
label: "Storage",
icon: IconProp.Disc,
description: "Monitor storage resources including disk usage.",
},
{
category: "Scheduling",
label: "Scheduling",
icon: IconProp.Clock,
description:
"Monitor pod scheduling including pending pods and scheduler backlog.",
},
];
const KubernetesTemplatePicker: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const allTemplates: Array<KubernetesAlertTemplate> =
getAllKubernetesAlertTemplates();
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Select a pre-built alert template to quickly set up monitoring. The
template will auto-configure the metric, scope, aggregation, time range,
and thresholds.
</p>
{categories.map(
(cat: {
category: KubernetesAlertTemplateCategory;
label: string;
icon: IconProp;
description: string;
}) => {
const categoryTemplates: Array<KubernetesAlertTemplate> =
allTemplates.filter((t: KubernetesAlertTemplate) => {
return t.category === cat.category;
});
if (categoryTemplates.length === 0) {
return null;
}
return (
<div key={cat.category}>
<div className="flex items-center mb-2">
<Icon icon={cat.icon} className="mr-2 h-4 w-4 text-gray-500" />
<h4 className="text-sm font-semibold text-gray-700">
{cat.label}
</h4>
</div>
<p className="text-xs text-gray-400 mb-2">{cat.description}</p>
<div className="grid grid-cols-1 gap-2 mb-4">
{categoryTemplates.map((template: KubernetesAlertTemplate) => {
const isSelected: boolean =
props.selectedTemplateId === template.id;
return (
<div
key={template.id}
className={`cursor-pointer rounded-lg border p-3 transition-all hover:shadow-sm ${
isSelected
? "border-blue-500 bg-blue-50 ring-1 ring-blue-500"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
onClick={() => {
props.onTemplateSelected(template);
}}
role="button"
tabIndex={0}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
props.onTemplateSelected(template);
}
}}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center">
<span className="text-sm font-medium text-gray-900">
{template.name}
</span>
<span
className={`ml-2 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
template.severity === "Critical"
? "bg-red-100 text-red-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{template.severity}
</span>
</div>
<p className="mt-1 text-xs text-gray-500">
{template.description}
</p>
</div>
{isSelected && (
<div className="ml-3">
<Icon
icon={IconProp.CheckCircle}
className="h-5 w-5 text-blue-500"
/>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
},
)}
</div>
);
};
export default KubernetesTemplatePicker;

View File

@@ -247,8 +247,18 @@ const MonitorCriteriaInstanceElement: FunctionComponent<ComponentProps> = (
{/* Filters Section - Collapsible */}
<CollapsibleSection
title="Filters"
description="Add criteria for different monitor properties."
title={
props.monitorType === MonitorType.Kubernetes ||
props.monitorType === MonitorType.Metrics
? "Alert Rules"
: "Filters"
}
description={
props.monitorType === MonitorType.Kubernetes ||
props.monitorType === MonitorType.Metrics
? "Define when this alert should trigger based on metric values."
: "Add criteria for different monitor properties."
}
badge={filterSummary}
variant="bordered"
defaultCollapsed={false}
@@ -257,8 +267,18 @@ const MonitorCriteriaInstanceElement: FunctionComponent<ComponentProps> = (
<div>
<div className="mb-3">
<FieldLabelElement
title="Filter Condition"
description="Select All if you want all the criteria to be met. Select any if you like any criteria to be met."
title={
props.monitorType === MonitorType.Kubernetes ||
props.monitorType === MonitorType.Metrics
? "Match Condition"
: "Filter Condition"
}
description={
props.monitorType === MonitorType.Kubernetes ||
props.monitorType === MonitorType.Metrics
? "Should all rules match, or just any one of them?"
: "Select All if you want all the criteria to be met. Select any if you like any criteria to be met."
}
required={true}
/>
<Radio

View File

@@ -67,6 +67,10 @@ import MetricMonitorStepForm from "./MetricMonitor/MetricMonitorStepForm";
import MonitorStepMetricMonitor, {
MonitorStepMetricMonitorUtil,
} from "Common/Types/Monitor/MonitorStepMetricMonitor";
import KubernetesMonitorStepForm from "./KubernetesMonitor/KubernetesMonitorStepForm";
import MonitorStepKubernetesMonitor, {
MonitorStepKubernetesMonitorUtil,
} from "Common/Types/Monitor/MonitorStepKubernetesMonitor";
import Link from "Common/UI/Components/Link/Link";
import TinyFormDocumentation from "Common/UI/Components/TinyFormDocumentation/TinyFormDocumentation";
import ExceptionMonitorStepForm from "./ExceptionMonitor/ExceptionMonitorStepForm";
@@ -106,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> = (
@@ -247,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.
@@ -265,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/');
@@ -276,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');
@@ -742,6 +765,33 @@ return {
</Card>
)}
{props.monitorType === MonitorType.Kubernetes && (
<Card
title="Kubernetes Monitor Configuration"
description="Configure your Kubernetes cluster monitoring using templates, curated metrics, or the advanced query builder."
>
<KubernetesMonitorStepForm
monitorStepKubernetesMonitor={
monitorStep.data?.kubernetesMonitor ||
MonitorStepKubernetesMonitorUtil.getDefault()
}
onChange={(value: MonitorStepKubernetesMonitor) => {
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>
)}
{props.monitorType === MonitorType.Traces && (
<Card
title="Trace Monitor Configuration"

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

@@ -0,0 +1,313 @@
import React, {
FunctionComponent,
ReactElement,
useEffect,
useMemo,
useState,
} from "react";
import TelemetryIngestionKey from "Common/Models/DatabaseModels/TelemetryIngestionKey";
import ModelAPI, { ListResult } from "Common/UI/Utils/ModelAPI/ModelAPI";
import ProjectUtil from "Common/UI/Utils/Project";
import { HOST, HTTP_PROTOCOL } from "Common/UI/Config";
import ModelFormModal from "Common/UI/Components/ModelFormModal/ModelFormModal";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import { FormType } from "Common/UI/Components/Forms/ModelForm";
import API from "Common/UI/Utils/API/API";
import IconProp from "Common/Types/Icon/IconProp";
import Icon from "Common/UI/Components/Icon/Icon";
import Dropdown, {
DropdownOption,
DropdownValue,
} from "Common/UI/Components/Dropdown/Dropdown";
import Protocol from "Common/Types/API/Protocol";
import Card from "Common/UI/Components/Card/Card";
import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer";
import { getKubernetesInstallationMarkdown } from "../../Pages/Kubernetes/Utils/DocumentationMarkdown";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
export interface ComponentProps {
clusterName: string;
title: string;
description: string;
}
const KubernetesDocumentationCard: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
// Ingestion key state
const [ingestionKeys, setIngestionKeys] = useState<
Array<TelemetryIngestionKey>
>([]);
const [selectedKeyId, setSelectedKeyId] = useState<string>("");
const [isLoadingKeys, setIsLoadingKeys] = useState<boolean>(true);
const [showCreateModal, setShowCreateModal] = useState<boolean>(false);
const [keyError, setKeyError] = useState<string>("");
// Compute OneUptime URL
const httpProtocol: string =
HTTP_PROTOCOL === Protocol.HTTPS ? "https" : "http";
const oneuptimeUrl: string = HOST
? `${httpProtocol}://${HOST}`
: "<YOUR_ONEUPTIME_URL>";
// Fetch ingestion keys on mount
useEffect(() => {
loadIngestionKeys().catch(() => {});
}, []);
const loadIngestionKeys: () => Promise<void> = async (): Promise<void> => {
try {
setIsLoadingKeys(true);
setKeyError("");
const result: ListResult<TelemetryIngestionKey> =
await ModelAPI.getList<TelemetryIngestionKey>({
modelType: TelemetryIngestionKey,
query: {
projectId: ProjectUtil.getCurrentProjectId()!,
},
limit: 50,
skip: 0,
select: {
_id: true,
name: true,
secretKey: true,
description: true,
},
sort: {},
});
setIngestionKeys(result.data);
// Auto-select the first key if available and none selected
if (result.data.length > 0 && !selectedKeyId) {
setSelectedKeyId(result.data[0]!.id?.toString() || "");
}
} catch (err) {
setKeyError(API.getFriendlyErrorMessage(err as Error));
} finally {
setIsLoadingKeys(false);
}
};
// Get the selected key object
const selectedKey: TelemetryIngestionKey | undefined = useMemo(() => {
return ingestionKeys.find((k: TelemetryIngestionKey) => {
return k.id?.toString() === selectedKeyId;
});
}, [ingestionKeys, selectedKeyId]);
// Get API key for code snippets
const apiKeyValue: string =
selectedKey?.secretKey?.toString() || "<YOUR_API_KEY>";
const renderKeySelector: () => ReactElement = (): ReactElement => {
if (isLoadingKeys) {
return <PageLoader isVisible={true} />;
}
if (keyError) {
return <ErrorMessage message={keyError} />;
}
if (ingestionKeys.length === 0) {
return (
<div className="text-center py-6">
<p className="text-sm font-medium text-gray-900 mb-1">
No ingestion keys yet
</p>
<p className="text-xs text-gray-500 mb-4">
Create an ingestion key to authenticate your Kubernetes agent.
</p>
<button
type="button"
onClick={() => {
setShowCreateModal(true);
}}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm"
>
<Icon icon={IconProp.Add} className="w-4 h-4" />
Create Ingestion Key
</button>
</div>
);
}
return (
<div>
{/* Key selector row */}
<div className="flex items-center gap-2 mb-3">
<div className="flex-1">
<Dropdown
options={ingestionKeys.map(
(key: TelemetryIngestionKey): DropdownOption => {
return {
value: key.id?.toString() || "",
label: key.name || "Unnamed Key",
};
},
)}
value={
ingestionKeys
.filter((key: TelemetryIngestionKey) => {
return key.id?.toString() === selectedKeyId;
})
.map((key: TelemetryIngestionKey): DropdownOption => {
return {
value: key.id?.toString() || "",
label: key.name || "Unnamed Key",
};
})[0]
}
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
if (value) {
setSelectedKeyId(value.toString());
}
}}
placeholder="Select an ingestion key"
ariaLabel="Select ingestion key"
/>
</div>
<button
type="button"
onClick={() => {
setShowCreateModal(true);
}}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 hover:border-gray-400 transition-colors flex-shrink-0"
>
<Icon icon={IconProp.Add} className="w-4 h-4" />
New Key
</button>
</div>
{/* Credentials display */}
{selectedKey && (
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
<div className="grid grid-cols-1 divide-y divide-gray-100">
<div className="px-4 py-3 flex items-start gap-3">
<div className="w-8 h-8 rounded-md bg-blue-50 flex items-center justify-center flex-shrink-0 mt-0.5">
<Icon
icon={IconProp.Globe}
className="w-4 h-4 text-blue-600"
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider">
OneUptime URL
</div>
<div className="text-sm text-gray-900 font-mono mt-0.5 break-all select-all">
{oneuptimeUrl}
</div>
</div>
</div>
<div className="px-4 py-3 flex items-start gap-3">
<div className="w-8 h-8 rounded-md bg-amber-50 flex items-center justify-center flex-shrink-0 mt-0.5">
<Icon
icon={IconProp.Key}
className="w-4 h-4 text-amber-600"
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider">
API Key
</div>
<div className="text-sm text-gray-900 font-mono mt-0.5 break-all select-all">
{selectedKey.secretKey?.toString() || "—"}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
const installationMarkdown: string = getKubernetesInstallationMarkdown({
clusterName: props.clusterName,
oneuptimeUrl: oneuptimeUrl,
apiKey: apiKeyValue,
});
return (
<div>
<Card title={props.title} description={props.description}>
<div className="px-4 pb-6">
{/* Ingestion Key Section */}
<div className="mb-6">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Select Ingestion Key
</label>
{renderKeySelector()}
</div>
{/* Documentation */}
<MarkdownViewer text={installationMarkdown} />
</div>
</Card>
{/* Create Ingestion Key Modal */}
{showCreateModal && (
<ModelFormModal<TelemetryIngestionKey>
modelType={TelemetryIngestionKey}
name="Create Ingestion Key"
title="Create Ingestion Key"
description="Create a new telemetry ingestion key for authenticating your Kubernetes agent."
onClose={() => {
setShowCreateModal(false);
}}
submitButtonText="Create Key"
onSuccess={(item: TelemetryIngestionKey) => {
setShowCreateModal(false);
loadIngestionKeys()
.then(() => {
if (item.id) {
setSelectedKeyId(item.id.toString());
}
})
.catch(() => {});
}}
formProps={{
name: "Create Ingestion Key",
modelType: TelemetryIngestionKey,
id: "create-ingestion-key",
fields: [
{
field: {
name: true,
},
title: "Name",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "e.g. Kubernetes Agent Key",
validation: {
minLength: 2,
},
},
{
field: {
description: true,
},
title: "Description",
fieldType: FormFieldSchemaType.LongText,
required: false,
placeholder: "Optional description for this key",
},
],
formType: FormType.Create,
}}
onBeforeCreate={(
item: TelemetryIngestionKey,
): Promise<TelemetryIngestionKey> => {
item.projectId = ProjectUtil.getCurrentProjectId()!;
return Promise.resolve(item);
}}
/>
)}
</div>
);
};
export default KubernetesDocumentationCard;

View File

@@ -0,0 +1,437 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
import Card from "Common/UI/Components/Card/Card";
import DictionaryOfStringsViewer from "Common/UI/Components/Dictionary/DictionaryOfStingsViewer";
import {
KubernetesContainerPort,
KubernetesContainerSpec,
KubernetesContainerStatus,
} from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
import LocalTable from "Common/UI/Components/Table/LocalTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import type Columns from "Common/UI/Components/Table/Types/Columns";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
function formatK8sResourceValue(key: string, value: string): string {
if (!value) {
return value;
}
// CPU values: millicores (e.g. "250m" = 0.25 cores)
const cpuMilliMatch: RegExpMatchArray | null = value.match(/^(\d+)m$/);
if (cpuMilliMatch && key.toLowerCase() === "cpu") {
const millis: number = parseInt(cpuMilliMatch[1] || "0");
if (millis >= 1000) {
return `${value} (${millis / 1000} CPU cores)`;
}
return `${value} (${(millis / 1000).toFixed(2)} CPU cores)`;
}
// CPU whole cores (e.g. "2" = 2 cores)
const wholeNumberRegex: RegExp = /^\d+$/;
if (key.toLowerCase() === "cpu" && wholeNumberRegex.test(value)) {
const cores: number = parseInt(value);
return `${value} (${cores} CPU core${cores !== 1 ? "s" : ""})`;
}
// Memory values: Ki, Mi, Gi, Ti
const memMatch: RegExpMatchArray | null = value.match(/^(\d+)(Ki|Mi|Gi|Ti)$/);
if (memMatch) {
const num: number = parseInt(memMatch[1] || "0");
const unit: string = memMatch[2] || "";
const explanations: Record<string, string> = {
Ki: `${(num / 1024).toFixed(num >= 1024 ? 1 : 2)} MB`,
Mi: num >= 1024 ? `${(num / 1024).toFixed(1)} GB` : `${num} MB`,
Gi: `${num} GB`,
Ti: `${num} TB`,
};
const readable: string | undefined = explanations[unit];
if (readable) {
return `${value} (${readable})`;
}
}
// Ephemeral storage: same units
const storageMatch: RegExpMatchArray | null = value.match(/^(\d+)(K|M|G|T)$/);
if (storageMatch) {
const num: number = parseInt(storageMatch[1] || "0");
const unit: string = storageMatch[2] || "";
const explanations: Record<string, string> = {
K: `${(num / 1000).toFixed(num >= 1000 ? 1 : 2)} MB`,
M: num >= 1000 ? `${(num / 1000).toFixed(1)} GB` : `${num} MB`,
G: `${num} GB`,
T: `${num} TB`,
};
const readable: string | undefined = explanations[unit];
if (readable) {
return `${value} (${readable})`;
}
}
return value;
}
function annotateResourceValues(
resources: Record<string, string>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const key of Object.keys(resources)) {
result[key] = formatK8sResourceValue(key, resources[key] || "");
}
return result;
}
export interface ComponentProps {
containers: Array<KubernetesContainerSpec>;
initContainers: Array<KubernetesContainerSpec>;
containerStatuses?: Array<KubernetesContainerStatus> | undefined;
initContainerStatuses?: Array<KubernetesContainerStatus> | undefined;
}
interface ContainerCardProps {
container: KubernetesContainerSpec;
status?: KubernetesContainerStatus | undefined;
isInit: boolean;
}
interface VolumeMountRow {
name: string;
mountPath: string;
readOnly: string;
}
const volumeMountColumns: Columns<VolumeMountRow> = [
{
title: "Volume Name",
type: FieldType.Text,
key: "name",
},
{
title: "Mount Path",
type: FieldType.Element,
key: "mountPath",
getElement: (item: VolumeMountRow): ReactElement => {
return (
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded font-mono">
{item.mountPath}
</code>
);
},
},
{
title: "Access",
type: FieldType.Element,
key: "readOnly",
getElement: (item: VolumeMountRow): ReactElement => {
return (
<StatusBadge
text={item.readOnly === "true" ? "Read-Only" : "Read-Write"}
type={
item.readOnly === "true"
? StatusBadgeType.Warning
: StatusBadgeType.Neutral
}
/>
);
},
},
];
const ContainerCard: FunctionComponent<ContainerCardProps> = (
props: ContainerCardProps,
): ReactElement => {
const [showEnv, setShowEnv] = useState<boolean>(false);
const [showMounts, setShowMounts] = useState<boolean>(false);
const envRecord: Record<string, string> = {};
for (const env of props.container.env) {
envRecord[env.name] = env.value;
}
const hasResources: boolean =
Object.keys(props.container.resources.requests).length > 0 ||
Object.keys(props.container.resources.limits).length > 0;
return (
<Card
title={`${props.isInit ? "Init Container: " : "Container: "}${props.container.name}`}
description={props.container.image}
>
<div className="space-y-5">
{/* Status Info Cards */}
{props.status && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<InfoCard
title="State"
value={
<StatusBadge
text={props.status.state}
type={
props.status.state === "running"
? StatusBadgeType.Success
: props.status.state === "waiting"
? StatusBadgeType.Warning
: StatusBadgeType.Danger
}
/>
}
/>
<InfoCard
title="Ready"
value={
<StatusBadge
text={props.status.ready ? "Yes" : "No"}
type={
props.status.ready
? StatusBadgeType.Success
: StatusBadgeType.Danger
}
/>
}
/>
<InfoCard
title="Restarts"
value={
<StatusBadge
text={String(props.status.restartCount)}
type={
props.status.restartCount > 0
? StatusBadgeType.Warning
: StatusBadgeType.Neutral
}
/>
}
/>
</div>
)}
{/* Command & Args */}
{props.container.command.length > 0 && (
<div>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
Command
</div>
<code className="text-sm bg-gray-50 border border-gray-200 px-3 py-2 rounded-lg block font-mono text-gray-800">
{props.container.command.join(" ")}
</code>
</div>
)}
{props.container.args.length > 0 && (
<div>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
Args
</div>
<code className="text-sm bg-gray-50 border border-gray-200 px-3 py-2 rounded-lg block font-mono text-gray-800">
{props.container.args.join(" ")}
</code>
</div>
)}
{/* Ports */}
{props.container.ports.length > 0 && (
<div>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Ports
</div>
<div className="flex flex-wrap gap-1.5">
{props.container.ports.map(
(port: KubernetesContainerPort, idx: number) => {
return (
<StatusBadge
key={idx}
text={`${port.name ? `${port.name}: ` : ""}${port.containerPort}/${port.protocol}`}
type={StatusBadgeType.Info}
/>
);
},
)}
</div>
</div>
)}
{/* Resources */}
{hasResources && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Object.keys(props.container.resources.requests).length > 0 && (
<div>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Requests
</div>
<DictionaryOfStringsViewer
value={annotateResourceValues(
props.container.resources.requests,
)}
/>
</div>
)}
{Object.keys(props.container.resources.limits).length > 0 && (
<div>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Limits
</div>
<DictionaryOfStringsViewer
value={annotateResourceValues(
props.container.resources.limits,
)}
/>
</div>
)}
</div>
)}
{/* Environment Variables (expandable) */}
{props.container.env.length > 0 && (
<div>
<button
onClick={() => {
setShowEnv(!showEnv);
}}
className="flex items-center gap-1.5 text-sm text-indigo-600 hover:text-indigo-800 font-medium transition-colors"
>
<span className="text-xs">{showEnv ? "▼" : "▶"}</span>
Environment Variables ({props.container.env.length})
</button>
{showEnv && (
<div className="mt-3">
<DictionaryOfStringsViewer value={envRecord} />
</div>
)}
</div>
)}
{/* Volume Mounts (expandable with table) */}
{props.container.volumeMounts.length > 0 && (
<div>
<button
onClick={() => {
setShowMounts(!showMounts);
}}
className="flex items-center gap-1.5 text-sm text-indigo-600 hover:text-indigo-800 font-medium transition-colors"
>
<span className="text-xs">{showMounts ? "▼" : "▶"}</span>
Volume Mounts ({props.container.volumeMounts.length})
</button>
{showMounts && (
<div className="mt-3">
<LocalTable
id={`volume-mounts-${props.container.name}`}
data={props.container.volumeMounts.map(
(mount: {
name: string;
mountPath: string;
readOnly: boolean;
}): VolumeMountRow => {
return {
name: mount.name,
mountPath: mount.mountPath,
readOnly: String(mount.readOnly),
};
},
)}
columns={volumeMountColumns}
singularLabel="Mount"
pluralLabel="Mounts"
/>
</div>
)}
</div>
)}
</div>
</Card>
);
};
const KubernetesContainersTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
if (props.containers.length === 0 && props.initContainers.length === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No container information available.
</div>
);
}
const getStatus: (
name: string,
isInit: boolean,
) => KubernetesContainerStatus | undefined = (
name: string,
isInit: boolean,
): KubernetesContainerStatus | undefined => {
const statuses: Array<KubernetesContainerStatus> | undefined = isInit
? props.initContainerStatuses
: props.containerStatuses;
return statuses?.find((s: KubernetesContainerStatus) => {
return s.name === name;
});
};
// Sort containers: running first, then waiting, then terminated
function getStatePriority(state: string): number {
const s: string = state.toLowerCase();
if (s === "running") {
return 0;
}
if (s === "waiting") {
return 1;
}
if (s === "terminated") {
return 2;
}
return 3;
}
const sortedContainers: Array<{
container: KubernetesContainerSpec;
isInit: boolean;
}> = [
...props.initContainers.map((container: KubernetesContainerSpec) => {
return { container, isInit: true };
}),
...props.containers.map((container: KubernetesContainerSpec) => {
return { container, isInit: false };
}),
].sort(
(
a: { container: KubernetesContainerSpec; isInit: boolean },
b: { container: KubernetesContainerSpec; isInit: boolean },
) => {
const aStatus: KubernetesContainerStatus | undefined = getStatus(
a.container.name,
a.isInit,
);
const bStatus: KubernetesContainerStatus | undefined = getStatus(
b.container.name,
b.isInit,
);
const aPriority: number = getStatePriority(aStatus?.state || "unknown");
const bPriority: number = getStatePriority(bStatus?.state || "unknown");
return aPriority - bPriority;
},
);
return (
<div className="space-y-4">
{sortedContainers.map(
(
item: { container: KubernetesContainerSpec; isInit: boolean },
index: number,
) => {
return (
<ContainerCard
key={`${item.isInit ? "init" : "container"}-${index}`}
container={item.container}
status={getStatus(item.container.name, item.isInit)}
isInit={item.isInit}
/>
);
},
)}
</div>
);
};
export default KubernetesContainersTab;

View File

@@ -0,0 +1,285 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
import Card from "Common/UI/Components/Card/Card";
import {
KubernetesContainerEnvVar,
KubernetesContainerSpec,
KubernetesContainerStatus,
} from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
import LocalTable from "Common/UI/Components/Table/LocalTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import type Columns from "Common/UI/Components/Table/Types/Columns";
import Icon from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
import Input from "Common/UI/Components/Input/Input";
export interface ComponentProps {
containers: Array<KubernetesContainerSpec>;
initContainers: Array<KubernetesContainerSpec>;
containerStatuses?: Array<KubernetesContainerStatus> | undefined;
initContainerStatuses?: Array<KubernetesContainerStatus> | undefined;
}
interface EnvVarRow {
name: string;
value: string;
}
const KubernetesEnvVarsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [search, setSearch] = useState<string>("");
const getStatus: (
name: string,
isInit: boolean,
) => KubernetesContainerStatus | undefined = (
name: string,
isInit: boolean,
): KubernetesContainerStatus | undefined => {
const statuses: Array<KubernetesContainerStatus> | undefined = isInit
? props.initContainerStatuses
: props.containerStatuses;
return statuses?.find((s: KubernetesContainerStatus) => {
return s.name === name;
});
};
function getStatePriority(state: string): number {
const s: string = state.toLowerCase();
if (s === "running") {
return 0;
}
if (s === "waiting") {
return 1;
}
if (s === "terminated") {
return 2;
}
return 3;
}
const sortedContainers: Array<{
container: KubernetesContainerSpec;
isInit: boolean;
}> = [
...props.initContainers.map((container: KubernetesContainerSpec) => {
return { container, isInit: true };
}),
...props.containers.map((container: KubernetesContainerSpec) => {
return { container, isInit: false };
}),
].sort(
(
a: { container: KubernetesContainerSpec; isInit: boolean },
b: { container: KubernetesContainerSpec; isInit: boolean },
) => {
const aStatus: KubernetesContainerStatus | undefined = getStatus(
a.container.name,
a.isInit,
);
const bStatus: KubernetesContainerStatus | undefined = getStatus(
b.container.name,
b.isInit,
);
const aPriority: number = getStatePriority(aStatus?.state || "unknown");
const bPriority: number = getStatePriority(bStatus?.state || "unknown");
return aPriority - bPriority;
},
);
const allContainers: Array<KubernetesContainerSpec> = sortedContainers.map(
(item: { container: KubernetesContainerSpec; isInit: boolean }) => {
return item.container;
},
);
if (allContainers.length === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No container information available.
</div>
);
}
const totalEnvCount: number = allContainers.reduce(
(sum: number, c: KubernetesContainerSpec) => {
return sum + c.env.length;
},
0,
);
if (totalEnvCount === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No environment variables defined for any container.
</div>
);
}
const searchLower: string = search.toLowerCase();
const totalMatchCount: number = search
? allContainers.reduce((sum: number, c: KubernetesContainerSpec) => {
return (
sum +
c.env.filter((env: KubernetesContainerEnvVar) => {
return (
env.name.toLowerCase().includes(searchLower) ||
env.value.toLowerCase().includes(searchLower)
);
}).length
);
}, 0)
: totalEnvCount;
const columns: Columns<EnvVarRow> = [
{
title: "Name",
type: FieldType.Element,
key: "name",
getElement: (item: EnvVarRow): ReactElement => {
return (
<span className="font-mono font-medium text-gray-900">
{item.name}
</span>
);
},
},
{
title: "Value",
type: FieldType.Element,
key: "value",
getElement: (item: EnvVarRow): ReactElement => {
const isSecret: boolean =
item.value.startsWith("<Secret:") ||
item.value.startsWith("<ConfigMap:") ||
item.value.startsWith("<FieldRef:") ||
item.value.startsWith("<ResourceFieldRef:");
if (isSecret) {
return (
<StatusBadge
text={item.value}
type={
item.value.startsWith("<Secret:")
? StatusBadgeType.Warning
: StatusBadgeType.Info
}
/>
);
}
return (
<span className="font-mono text-gray-600">
{item.value || <span className="text-gray-400 italic">empty</span>}
</span>
);
},
},
];
return (
<div className="space-y-4">
{/* Search bar */}
<Card
title="Environment Variables"
description={`${totalEnvCount} variable${totalEnvCount !== 1 ? "s" : ""} across ${
allContainers.filter((c: KubernetesContainerSpec) => {
return c.env.length > 0;
}).length
} container${
allContainers.filter((c: KubernetesContainerSpec) => {
return c.env.length > 0;
}).length !== 1
? "s"
: ""
}`}
>
<div className="flex items-center gap-3">
<div className="relative flex-1">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Icon icon={IconProp.Search} className="h-4 w-4 text-gray-400" />
</div>
<Input
placeholder="Search environment variables..."
value={search}
onChange={(value: string) => {
setSearch(value);
}}
className="block w-full rounded-md border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm placeholder-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
{search && (
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-sm text-gray-500 tabular-nums">
{totalMatchCount} of {totalEnvCount}
</span>
<button
onClick={() => {
setSearch("");
}}
className="rounded-md p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<Icon icon={IconProp.Close} className="h-4 w-4" />
</button>
</div>
)}
</div>
</Card>
{sortedContainers.map(
(
item: { container: KubernetesContainerSpec; isInit: boolean },
containerIdx: number,
) => {
if (item.container.env.length === 0) {
return null;
}
const filteredEnv: Array<KubernetesContainerEnvVar> = search
? item.container.env.filter((env: KubernetesContainerEnvVar) => {
return (
env.name.toLowerCase().includes(searchLower) ||
env.value.toLowerCase().includes(searchLower)
);
})
: item.container.env;
if (filteredEnv.length === 0) {
return null;
}
const tableData: Array<EnvVarRow> = filteredEnv.map(
(env: KubernetesContainerEnvVar): EnvVarRow => {
return {
name: env.name,
value: env.value,
};
},
);
return (
<Card
key={containerIdx}
title={`${item.isInit ? "Init Container: " : ""}${item.container.name}`}
description={`${filteredEnv.length} environment variable${filteredEnv.length !== 1 ? "s" : ""}`}
>
<LocalTable
id={`env-vars-${containerIdx}`}
data={tableData}
columns={columns}
singularLabel="Variable"
pluralLabel="Variables"
/>
</Card>
);
},
)}
</div>
);
};
export default KubernetesEnvVarsTab;

View File

@@ -0,0 +1,227 @@
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import {
fetchK8sEventsForResource,
KubernetesEvent,
} from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import FilterButtons, {
type FilterButtonOption,
} from "Common/UI/Components/FilterButtons/FilterButtons";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
import ExpandableText from "Common/UI/Components/ExpandableText/ExpandableText";
import LocalTable from "Common/UI/Components/Table/LocalTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import type Columns from "Common/UI/Components/Table/Types/Columns";
export interface ComponentProps {
clusterIdentifier: string;
resourceKind: string; // "Pod", "Node", "Deployment", etc.
resourceName: string;
namespace?: string | undefined;
}
interface EventRow {
timestamp: string;
relativeTime: string;
type: string;
reason: string;
message: string;
}
function formatRelativeTime(timestamp: string): string {
if (!timestamp) {
return "-";
}
const date: Date = new Date(timestamp);
const now: Date = new Date();
const diffMs: number = now.getTime() - date.getTime();
if (diffMs < 0) {
return timestamp;
}
const diffSec: number = Math.floor(diffMs / 1000);
if (diffSec < 60) {
return `${diffSec}s ago`;
}
const diffMin: number = Math.floor(diffSec / 60);
if (diffMin < 60) {
return `${diffMin}m ago`;
}
const diffHrs: number = Math.floor(diffMin / 60);
if (diffHrs < 24) {
return `${diffHrs}h ago`;
}
const diffDays: number = Math.floor(diffHrs / 24);
return `${diffDays}d ago`;
}
const KubernetesEventsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [events, setEvents] = useState<Array<KubernetesEvent>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [typeFilter, setTypeFilter] = useState<string>("all");
useEffect(() => {
const fetchEvents: () => Promise<void> = async (): Promise<void> => {
setIsLoading(true);
try {
const result: Array<KubernetesEvent> = await fetchK8sEventsForResource({
clusterIdentifier: props.clusterIdentifier,
resourceKind: props.resourceKind,
resourceName: props.resourceName,
namespace: props.namespace,
});
setEvents(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch events");
}
setIsLoading(false);
};
fetchEvents().catch(() => {});
}, [
props.clusterIdentifier,
props.resourceKind,
props.resourceName,
props.namespace,
]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (events.length === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No events found for this {props.resourceKind.toLowerCase()} in the last
24 hours.
</div>
);
}
const warningCount: number = events.filter((e: KubernetesEvent) => {
return e.type.toLowerCase() === "warning";
}).length;
const normalCount: number = events.length - warningCount;
const filteredEvents: Array<KubernetesEvent> = events.filter(
(e: KubernetesEvent) => {
if (typeFilter === "warning") {
return e.type.toLowerCase() === "warning";
}
if (typeFilter === "normal") {
return e.type.toLowerCase() !== "warning";
}
return true;
},
);
const filterOptions: Array<FilterButtonOption> = [
{ label: "All", value: "all" },
{ label: "Warnings", value: "warning", badge: warningCount },
{ label: "Normal", value: "normal", badge: normalCount },
];
const tableData: Array<EventRow> = filteredEvents.map(
(event: KubernetesEvent): EventRow => {
return {
timestamp: event.timestamp,
relativeTime: formatRelativeTime(event.timestamp),
type: event.type,
reason: event.reason,
message: event.message,
};
},
);
const columns: Columns<EventRow> = [
{
title: "Time",
type: FieldType.Text,
key: "relativeTime",
tooltipText: (item: EventRow): string => {
return item.timestamp;
},
},
{
title: "Type",
type: FieldType.Element,
key: "type",
getElement: (item: EventRow): ReactElement => {
const isWarning: boolean = item.type.toLowerCase() === "warning";
return (
<StatusBadge
text={item.type}
type={isWarning ? StatusBadgeType.Warning : StatusBadgeType.Success}
/>
);
},
},
{
title: "Reason",
type: FieldType.Text,
key: "reason",
},
{
title: "Message",
type: FieldType.Element,
key: "message",
getElement: (item: EventRow): ReactElement => {
return <ExpandableText text={item.message} maxLength={120} />;
},
},
];
return (
<div>
{/* Summary and Filters */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<div className="text-sm text-gray-600">
<span className="font-medium">{events.length}</span> events
{warningCount > 0 && (
<span>
{" "}
(
<span className="text-amber-700 font-medium">
{warningCount}
</span>{" "}
warning{warningCount !== 1 ? "s" : ""},{" "}
<span className="text-emerald-700 font-medium">
{normalCount}
</span>{" "}
normal)
</span>
)}
</div>
<FilterButtons
options={filterOptions}
selectedValue={typeFilter}
onSelect={setTypeFilter}
/>
</div>
<LocalTable
id="kubernetes-events-table"
data={tableData}
columns={columns}
singularLabel="Event"
pluralLabel="Events"
/>
</div>
);
};
export default KubernetesEventsTab;

View File

@@ -0,0 +1,50 @@
import React, { FunctionComponent, ReactElement, useMemo } from "react";
import DashboardLogsViewer from "../Logs/LogsViewer";
import Query from "Common/Types/BaseDatabase/Query";
import Log from "Common/Models/AnalyticsModels/Log";
export interface ComponentProps {
clusterIdentifier: string;
podName: string;
containerName?: string | undefined;
namespace?: string | undefined;
}
const KubernetesLogsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const logQuery: Query<Log> = useMemo(() => {
const attributeFilters: Record<string, string> = {
"resource.k8s.cluster.name": props.clusterIdentifier,
"resource.k8s.pod.name": props.podName,
};
if (props.containerName) {
attributeFilters["resource.k8s.container.name"] = props.containerName;
}
if (props.namespace) {
attributeFilters["resource.k8s.namespace.name"] = props.namespace;
}
return {
attributes: attributeFilters,
} as Query<Log>;
}, [
props.clusterIdentifier,
props.podName,
props.containerName,
props.namespace,
]);
return (
<DashboardLogsViewer
id={`k8s-logs-${props.podName}`}
logQuery={logQuery}
showFilters={true}
noLogsMessage="No application logs found for this pod. Logs will appear here once the kubernetes-agent's filelog receiver is collecting data."
/>
);
};
export default KubernetesLogsTab;

View File

@@ -0,0 +1,78 @@
import React, {
FunctionComponent,
ReactElement,
useCallback,
useState,
} from "react";
import MetricView from "../../Components/Metrics/MetricView";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
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";
export interface ComponentProps {
queryConfigs: Array<MetricQueryConfigData>;
}
const KubernetesMetricsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [timeRange, setTimeRange] = useState<RangeStartAndEndDateTime>({
range: TimeRange.PAST_ONE_HOUR,
});
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate({
range: TimeRange.PAST_ONE_HOUR,
}),
queryConfigs: [],
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 (
<div>
<div className="flex items-center justify-end mb-4">
<RangeStartAndEndDateView
dashboardStartAndEndDate={timeRange}
onChange={handleTimeRangeChange}
/>
</div>
<MetricView
data={{
...metricViewData,
queryConfigs: props.queryConfigs,
}}
hideQueryElements={true}
hideStartAndEndDate={true}
hideCardInCharts={true}
onChange={(data: MetricViewData) => {
setMetricViewData({
...data,
queryConfigs: props.queryConfigs,
formulaConfigs: [],
});
}}
/>
</div>
);
};
export default KubernetesMetricsTab;

View File

@@ -0,0 +1,138 @@
import React, { FunctionComponent, ReactElement } from "react";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import DictionaryOfStringsViewer from "Common/UI/Components/Dictionary/DictionaryOfStingsViewer";
import { KubernetesCondition } from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ConditionsTable, {
type Condition,
} from "Common/UI/Components/ConditionsTable/ConditionsTable";
import ObjectID from "Common/Types/ObjectID";
import KubernetesResourceLink from "./KubernetesResourceLink";
export interface SummaryField {
title: string;
value: string | ReactElement;
}
export interface ComponentProps {
summaryFields: Array<SummaryField>;
labels: Record<string, string>;
annotations: Record<string, string>;
conditions?: Array<KubernetesCondition> | undefined;
ownerReferences?: Array<{ kind: string; name: string }> | undefined;
modelId?: ObjectID | undefined;
isLoading: boolean;
emptyMessage?: string | undefined;
}
const KubernetesOverviewTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
if (props.isLoading) {
return <PageLoader isVisible={true} />;
}
if (
props.summaryFields.length === 0 &&
Object.keys(props.labels).length === 0
) {
return (
<div className="text-gray-500 text-sm p-4">
{props.emptyMessage ||
"Resource details not yet available. Ensure the kubernetes-agent Helm chart has resourceSpecs.enabled set to true and wait for the next data pull (up to 5 minutes)."}
</div>
);
}
// Convert KubernetesCondition[] to generic Condition[] for ConditionsTable
const conditions: Array<Condition> | undefined = props.conditions?.map(
(c: KubernetesCondition): Condition => {
return {
type: c.type,
status: c.status,
reason: c.reason,
message: c.message,
lastTransitionTime: c.lastTransitionTime,
};
},
);
return (
<div className="space-y-6">
{/* Summary Info Cards */}
{props.summaryFields.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{props.summaryFields.map((field: SummaryField, index: number) => {
return (
<InfoCard key={index} title={field.title} value={field.value} />
);
})}
</div>
)}
{/* Owner References */}
{props.ownerReferences && props.ownerReferences.length > 0 && (
<Card
title="Owner References"
description="Resources that own this object."
>
<div className="space-y-1">
{props.ownerReferences.map(
(ref: { kind: string; name: string }, index: number) => {
return (
<div key={index} className="text-sm">
<span className="font-medium text-gray-700">
{ref.kind}:
</span>{" "}
{props.modelId ? (
<KubernetesResourceLink
modelId={props.modelId}
resourceKind={ref.kind}
resourceName={ref.name}
/>
) : (
<span className="text-gray-600">{ref.name}</span>
)}
</div>
);
},
)}
</div>
</Card>
)}
{/* Conditions */}
{conditions && conditions.length > 0 && (
<Card
title="Conditions"
description="Current status conditions of this resource."
>
<ConditionsTable conditions={conditions} />
</Card>
)}
{/* Labels */}
{Object.keys(props.labels).length > 0 && (
<Card
title="Labels"
description="Key-value labels attached to this resource."
>
<DictionaryOfStringsViewer value={props.labels} />
</Card>
)}
{/* Annotations */}
{Object.keys(props.annotations).length > 0 && (
<Card
title="Annotations"
description="Metadata annotations on this resource."
>
<DictionaryOfStringsViewer value={props.annotations} />
</Card>
)}
</div>
);
};
export default KubernetesOverviewTab;

View File

@@ -0,0 +1,57 @@
import React, { FunctionComponent, ReactElement } from "react";
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 ObjectID from "Common/Types/ObjectID";
// Maps Kubernetes resource kinds to their detail page PageMap entries
const kindToPageMap: Record<string, PageMap> = {
Pod: PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL,
Node: PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL,
Namespace: PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL,
Deployment: PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL,
StatefulSet: PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL,
DaemonSet: PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL,
Job: PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL,
CronJob: PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL,
PersistentVolumeClaim: PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL,
PersistentVolume: PageMap.KUBERNETES_CLUSTER_VIEW_PV_DETAIL,
ReplicaSet: PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL, // ReplicaSets are managed by Deployments
};
export interface ComponentProps {
modelId: ObjectID;
resourceKind: string;
resourceName: string;
className?: string | undefined;
}
const KubernetesResourceLink: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const pageMap: PageMap | undefined = kindToPageMap[props.resourceKind];
if (!pageMap) {
// No detail page for this kind — render as plain text
return <span className={props.className}>{props.resourceName}</span>;
}
return (
<span
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(RouteMap[pageMap] as Route, {
modelId: props.modelId,
subModelId: new ObjectID(props.resourceName),
}),
);
}}
className={`text-indigo-600 hover:text-indigo-800 cursor-pointer font-medium ${props.className || ""}`}
>
{props.resourceName}
</span>
);
};
export default KubernetesResourceLink;

View File

@@ -0,0 +1,505 @@
import React, {
FunctionComponent,
ReactElement,
useMemo,
useState,
} from "react";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../../Pages/Kubernetes/Utils/KubernetesResourceUtils";
import Card, { CardButtonSchema } from "Common/UI/Components/Card/Card";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import IconProp from "Common/Types/Icon/IconProp";
import { getRefreshButton } from "Common/UI/Components/Card/CardButtons/Refresh";
import Table from "Common/UI/Components/Table/Table";
import FieldType from "Common/UI/Components/Types/FieldType";
import Link from "Common/UI/Components/Link/Link";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import Route from "Common/Types/API/Route";
import Column from "Common/UI/Components/Table/Types/Column";
import Filter from "Common/UI/Components/Filters/Types/Filter";
import FilterData from "Common/UI/Components/Filters/Types/FilterData";
import Search from "Common/Types/BaseDatabase/Search";
import Includes from "Common/Types/BaseDatabase/Includes";
export interface ResourceColumn {
title: string;
key: string;
getValue?: (resource: KubernetesResource) => string;
}
export interface ComponentProps {
resources: Array<KubernetesResource>;
title: string;
description: string;
columns?: Array<ResourceColumn>;
showNamespace?: boolean;
showStatus?: boolean;
showResourceMetrics?: boolean;
getViewRoute?: (resource: KubernetesResource) => Route;
emptyMessage?: string;
isLoading?: boolean;
onRefreshClick?: (() => void) | undefined;
}
const PAGE_SIZE: number = 25;
function getStatusBadgeClass(status: string): string {
const s: string = status.toLowerCase();
if (
s === "running" ||
s === "ready" ||
s === "active" ||
s === "bound" ||
s === "succeeded" ||
s === "available" ||
s === "true"
) {
return "bg-green-50 text-green-700";
}
if (
s === "pending" ||
s === "unknown" ||
s === "waiting" ||
s === "terminating"
) {
return "bg-yellow-50 text-yellow-700";
}
if (
s === "failed" ||
s === "crashloopbackoff" ||
s === "error" ||
s === "lost" ||
s === "notready" ||
s === "imagepullbackoff" ||
s === "false"
) {
return "bg-red-50 text-red-700";
}
return "bg-gray-50 text-gray-700";
}
function getCpuBarColor(pct: number): string {
if (pct > 80) {
return "bg-red-500";
}
if (pct > 60) {
return "bg-yellow-500";
}
return "bg-green-500";
}
function getMemoryBarColor(pct: number): string {
if (pct > 85) {
return "bg-red-500";
}
if (pct > 70) {
return "bg-yellow-500";
}
return "bg-blue-500";
}
const KubernetesResourceTable: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const showNamespace: boolean = props.showNamespace !== false;
const showStatus: boolean = props.showStatus !== false;
const showResourceMetrics: boolean = props.showResourceMetrics !== false;
const [currentPage, setCurrentPage] = useState<number>(1);
const [sortBy, setSortBy] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<SortOrder>(SortOrder.Ascending);
const [showFilterModal, setShowFilterModal] = useState<boolean>(false);
const [filterData, setFilterData] = useState<FilterData<KubernetesResource>>(
{},
);
// Build filter definitions from data
const filters: Array<Filter<KubernetesResource>> = useMemo(() => {
const result: Array<Filter<KubernetesResource>> = [
{
title: "Name",
key: "name",
type: FieldType.Text,
},
];
if (showNamespace) {
const namespaces: Array<string> = Array.from(
new Set(
props.resources
.map((r: KubernetesResource) => {
return r.namespace;
})
.filter(Boolean),
),
).sort();
result.push({
title: "Namespace",
key: "namespace",
type: FieldType.Dropdown,
filterDropdownOptions: namespaces.map((ns: string) => {
return { label: ns, value: ns };
}),
});
}
if (showStatus) {
const statuses: Array<string> = Array.from(
new Set(
props.resources
.map((r: KubernetesResource) => {
return r.status;
})
.filter(Boolean),
),
).sort();
result.push({
title: "Status",
key: "status",
type: FieldType.Dropdown,
filterDropdownOptions: statuses.map((s: string) => {
return { label: s, value: s };
}),
});
}
return result;
}, [props.resources, showNamespace, showStatus]);
// Filter and sort data client-side
const processedData: Array<KubernetesResource> = useMemo(() => {
let data: Array<KubernetesResource> = [...props.resources];
// Apply filters from filterData
for (const key of Object.keys(filterData) as Array<
keyof KubernetesResource
>) {
const value: unknown = filterData[key];
if (!value) {
continue;
}
if (value instanceof Search) {
const searchText: string = value.toString().toLowerCase();
data = data.filter((r: KubernetesResource) => {
const fieldValue: string = (r[key] as string) || "";
return fieldValue.toLowerCase().includes(searchText);
});
} else if (value instanceof Includes) {
const includeValues: Array<string> = value.values as Array<string>;
data = data.filter((r: KubernetesResource) => {
const fieldValue: string = (r[key] as string) || "";
return includeValues.includes(fieldValue);
});
} else if (typeof value === "string") {
// Dropdown single selection stores as plain string
data = data.filter((r: KubernetesResource) => {
const fieldValue: string = (r[key] as string) || "";
return fieldValue === value;
});
} else if (Array.isArray(value)) {
// Dropdown multi-selection stores as plain array
const includeValues: Array<string> = value.map((v: unknown) => {
return String(v);
});
data = data.filter((r: KubernetesResource) => {
const fieldValue: string = (r[key] as string) || "";
return includeValues.includes(fieldValue);
});
}
}
// Sort
if (sortBy) {
data.sort((a: KubernetesResource, b: KubernetesResource) => {
let cmp: number = 0;
if (sortBy === "name") {
cmp = a.name.localeCompare(b.name);
} else if (sortBy === "namespace") {
cmp = a.namespace.localeCompare(b.namespace);
} else if (sortBy === "status") {
cmp = a.status.localeCompare(b.status);
} else if (sortBy === "cpuUtilization") {
cmp = (a.cpuUtilization ?? -1) - (b.cpuUtilization ?? -1);
} else if (sortBy === "memoryUsageBytes") {
cmp = (a.memoryUsageBytes ?? -1) - (b.memoryUsageBytes ?? -1);
} else if (sortBy === "age") {
cmp = a.age.localeCompare(b.age);
}
return sortOrder === SortOrder.Descending ? -cmp : cmp;
});
}
return data;
}, [props.resources, filterData, sortBy, sortOrder]);
// Paginate
const paginatedData: Array<KubernetesResource> = useMemo(() => {
const start: number = (currentPage - 1) * PAGE_SIZE;
return processedData.slice(start, start + PAGE_SIZE);
}, [processedData, currentPage]);
const tableColumns: Array<Column<KubernetesResource>> = [
{
title: "Name",
type: FieldType.Element,
key: "name",
getElement: (resource: KubernetesResource): ReactElement => {
return (
<span className="font-medium text-gray-900">{resource.name}</span>
);
},
},
];
if (showNamespace) {
tableColumns.push({
title: "Namespace",
type: FieldType.Element,
key: "namespace",
getElement: (resource: KubernetesResource): ReactElement => {
return (
<span className="inline-flex px-2 py-0.5 text-xs font-medium rounded bg-blue-50 text-blue-700">
{resource.namespace || "default"}
</span>
);
},
});
}
if (showStatus) {
tableColumns.push({
title: "Status",
type: FieldType.Element,
key: "status",
getElement: (resource: KubernetesResource): ReactElement => {
if (!resource.status) {
return <span className="text-gray-400">-</span>;
}
return (
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${getStatusBadgeClass(resource.status)}`}
>
{resource.status}
</span>
);
},
});
}
if (props.columns) {
for (const col of props.columns) {
tableColumns.push({
title: col.title,
type: FieldType.Element,
key: col.key as keyof KubernetesResource,
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
const value: string = col.getValue
? col.getValue(resource)
: resource.additionalAttributes[col.key] || "";
return <span>{value}</span>;
},
});
}
}
if (showResourceMetrics) {
tableColumns.push(
{
title: "CPU",
type: FieldType.Element,
key: "cpuUtilization",
getElement: (resource: KubernetesResource): ReactElement => {
if (
resource.cpuUtilization === null ||
resource.cpuUtilization === undefined
) {
return <span className="text-gray-400">N/A</span>;
}
const pct: number = Math.min(resource.cpuUtilization, 100);
return (
<div className="flex items-center gap-2 min-w-[120px]">
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getCpuBarColor(pct)}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-gray-600 whitespace-nowrap w-10 text-right">
{KubernetesResourceUtils.formatCpuValue(
resource.cpuUtilization,
)}
</span>
</div>
);
},
},
{
title: "Memory",
type: FieldType.Element,
key: "memoryUsageBytes",
getElement: (resource: KubernetesResource): ReactElement => {
if (
resource.memoryUsageBytes === null ||
resource.memoryUsageBytes === undefined
) {
return <span className="text-gray-400">N/A</span>;
}
if (
resource.memoryLimitBytes !== null &&
resource.memoryLimitBytes !== undefined &&
resource.memoryLimitBytes > 0
) {
const pct: number = Math.min(
(resource.memoryUsageBytes / resource.memoryLimitBytes) * 100,
100,
);
return (
<div className="min-w-[140px]">
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getMemoryBarColor(pct)}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-gray-600 whitespace-nowrap">
{Math.round(pct)}%
</span>
</div>
<div className="text-xs text-gray-500 mt-0.5">
{KubernetesResourceUtils.formatMemoryValue(
resource.memoryUsageBytes,
)}{" "}
/{" "}
{KubernetesResourceUtils.formatMemoryValue(
resource.memoryLimitBytes,
)}
</div>
</div>
);
}
return (
<span className="text-sm text-gray-700">
{KubernetesResourceUtils.formatMemoryValue(
resource.memoryUsageBytes,
)}
</span>
);
},
},
);
}
if (showStatus) {
tableColumns.push({
title: "Age",
type: FieldType.Element,
key: "age",
getElement: (resource: KubernetesResource): ReactElement => {
if (!resource.age) {
return <span className="text-gray-400">-</span>;
}
return <span className="text-sm text-gray-600">{resource.age}</span>;
},
});
}
if (props.getViewRoute) {
tableColumns.push({
title: "",
type: FieldType.Element,
key: "name",
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
return (
<Link
to={props.getViewRoute!(resource)}
className="text-indigo-600 hover:text-indigo-900 font-medium"
>
View
</Link>
);
},
});
}
const hasActiveFilters: boolean = Object.keys(filterData).length > 0;
const cardButtons: Array<CardButtonSchema> = [];
if (props.onRefreshClick) {
cardButtons.push({
...getRefreshButton(),
className: "py-0 pr-0 pl-0 mt-1",
onClick: props.onRefreshClick,
});
}
cardButtons.push({
title: "",
buttonStyle: ButtonStyleType.ICON,
className: "py-0 pr-0 pl-1 mt-1",
onClick: () => {
setShowFilterModal(true);
},
icon: IconProp.Filter,
});
return (
<Card
title={props.title}
description={props.description}
buttons={cardButtons}
>
<Table<KubernetesResource>
id={`kubernetes-${props.title.toLowerCase().replace(/\s+/g, "-")}-table`}
columns={tableColumns}
data={paginatedData}
singularLabel={props.title}
pluralLabel={props.title}
isLoading={props.isLoading || false}
error=""
currentPageNumber={currentPage}
totalItemsCount={processedData.length}
itemsOnPage={paginatedData.length}
onNavigateToPage={(page: number) => {
setCurrentPage(page);
}}
sortBy={sortBy as keyof KubernetesResource | null}
sortOrder={sortOrder}
onSortChanged={(
newSortBy: keyof KubernetesResource | null,
newSortOrder: SortOrder,
) => {
setSortBy(newSortBy as string | null);
setSortOrder(newSortOrder);
}}
filters={filters}
showFilterModal={showFilterModal}
filterData={filterData}
onFilterChanged={(newFilterData: FilterData<KubernetesResource>) => {
setFilterData(newFilterData);
setCurrentPage(1);
}}
onFilterModalOpen={() => {
setShowFilterModal(true);
}}
onFilterModalClose={() => {
setShowFilterModal(false);
}}
noItemsMessage={
hasActiveFilters
? "No resources match the current filters."
: props.emptyMessage ||
"No resources found. Resources will appear here once the kubernetes-agent is sending data."
}
/>
</Card>
);
};
export default KubernetesResourceTable;

View File

@@ -0,0 +1,296 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
import Card from "Common/UI/Components/Card/Card";
import {
KubernetesContainerSpec,
KubernetesContainerStatus,
} from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
import LocalTable from "Common/UI/Components/Table/LocalTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import type Columns from "Common/UI/Components/Table/Types/Columns";
import Icon from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
import Input from "Common/UI/Components/Input/Input";
export interface ComponentProps {
containers: Array<KubernetesContainerSpec>;
initContainers: Array<KubernetesContainerSpec>;
containerStatuses?: Array<KubernetesContainerStatus> | undefined;
initContainerStatuses?: Array<KubernetesContainerStatus> | undefined;
}
interface VolumeMountRow {
name: string;
mountPath: string;
readOnly: string;
}
const KubernetesVolumeMountsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [search, setSearch] = useState<string>("");
const getStatus: (
name: string,
isInit: boolean,
) => KubernetesContainerStatus | undefined = (
name: string,
isInit: boolean,
): KubernetesContainerStatus | undefined => {
const statuses: Array<KubernetesContainerStatus> | undefined = isInit
? props.initContainerStatuses
: props.containerStatuses;
return statuses?.find((s: KubernetesContainerStatus) => {
return s.name === name;
});
};
function getStatePriority(state: string): number {
const s: string = state.toLowerCase();
if (s === "running") {
return 0;
}
if (s === "waiting") {
return 1;
}
if (s === "terminated") {
return 2;
}
return 3;
}
const sortedContainers: Array<{
container: KubernetesContainerSpec;
isInit: boolean;
}> = [
...props.initContainers.map((container: KubernetesContainerSpec) => {
return { container, isInit: true };
}),
...props.containers.map((container: KubernetesContainerSpec) => {
return { container, isInit: false };
}),
].sort(
(
a: { container: KubernetesContainerSpec; isInit: boolean },
b: { container: KubernetesContainerSpec; isInit: boolean },
) => {
const aStatus: KubernetesContainerStatus | undefined = getStatus(
a.container.name,
a.isInit,
);
const bStatus: KubernetesContainerStatus | undefined = getStatus(
b.container.name,
b.isInit,
);
const aPriority: number = getStatePriority(aStatus?.state || "unknown");
const bPriority: number = getStatePriority(bStatus?.state || "unknown");
return aPriority - bPriority;
},
);
const allContainers: Array<KubernetesContainerSpec> = sortedContainers.map(
(item: { container: KubernetesContainerSpec; isInit: boolean }) => {
return item.container;
},
);
if (allContainers.length === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No container information available.
</div>
);
}
const totalMountCount: number = allContainers.reduce(
(sum: number, c: KubernetesContainerSpec) => {
return sum + c.volumeMounts.length;
},
0,
);
if (totalMountCount === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No volume mounts defined for any container.
</div>
);
}
const searchLower: string = search.toLowerCase();
const totalMatchCount: number = search
? allContainers.reduce((sum: number, c: KubernetesContainerSpec) => {
return (
sum +
c.volumeMounts.filter(
(m: { name: string; mountPath: string; readOnly: boolean }) => {
return (
m.name.toLowerCase().includes(searchLower) ||
m.mountPath.toLowerCase().includes(searchLower)
);
},
).length
);
}, 0)
: totalMountCount;
const columns: Columns<VolumeMountRow> = [
{
title: "Volume Name",
type: FieldType.Element,
key: "name",
getElement: (item: VolumeMountRow): ReactElement => {
return (
<span className="font-mono font-medium text-gray-900">
{item.name}
</span>
);
},
},
{
title: "Mount Path",
type: FieldType.Element,
key: "mountPath",
getElement: (item: VolumeMountRow): ReactElement => {
return (
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded font-mono">
{item.mountPath}
</code>
);
},
},
{
title: "Access",
type: FieldType.Element,
key: "readOnly",
getElement: (item: VolumeMountRow): ReactElement => {
return (
<StatusBadge
text={item.readOnly === "true" ? "Read-Only" : "Read-Write"}
type={
item.readOnly === "true"
? StatusBadgeType.Warning
: StatusBadgeType.Neutral
}
/>
);
},
},
];
return (
<div className="space-y-4">
{/* Search bar */}
<Card
title="Volume Mounts"
description={`${totalMountCount} mount${totalMountCount !== 1 ? "s" : ""} across ${
allContainers.filter((c: KubernetesContainerSpec) => {
return c.volumeMounts.length > 0;
}).length
} container${
allContainers.filter((c: KubernetesContainerSpec) => {
return c.volumeMounts.length > 0;
}).length !== 1
? "s"
: ""
}`}
>
<div className="flex items-center gap-3">
<div className="relative flex-1">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Icon icon={IconProp.Search} className="h-4 w-4 text-gray-400" />
</div>
<Input
placeholder="Search by volume name or mount path..."
value={search}
onChange={(value: string) => {
setSearch(value);
}}
className="block w-full rounded-md border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm placeholder-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
{search && (
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-sm text-gray-500 tabular-nums">
{totalMatchCount} of {totalMountCount}
</span>
<button
onClick={() => {
setSearch("");
}}
className="rounded-md p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<Icon icon={IconProp.Close} className="h-4 w-4" />
</button>
</div>
)}
</div>
</Card>
{sortedContainers.map(
(
item: { container: KubernetesContainerSpec; isInit: boolean },
containerIdx: number,
) => {
if (item.container.volumeMounts.length === 0) {
return null;
}
const filteredMounts: Array<{
name: string;
mountPath: string;
readOnly: boolean;
}> = search
? item.container.volumeMounts.filter(
(m: { name: string; mountPath: string; readOnly: boolean }) => {
return (
m.name.toLowerCase().includes(searchLower) ||
m.mountPath.toLowerCase().includes(searchLower)
);
},
)
: item.container.volumeMounts;
if (filteredMounts.length === 0) {
return null;
}
const tableData: Array<VolumeMountRow> = filteredMounts.map(
(mount: {
name: string;
mountPath: string;
readOnly: boolean;
}): VolumeMountRow => {
return {
name: mount.name,
mountPath: mount.mountPath,
readOnly: String(mount.readOnly),
};
},
);
return (
<Card
key={containerIdx}
title={`${item.isInit ? "Init Container: " : ""}${item.container.name}`}
description={`${filteredMounts.length} volume mount${filteredMounts.length !== 1 ? "s" : ""}`}
>
<LocalTable
id={`volume-mounts-${containerIdx}`}
data={tableData}
columns={columns}
singularLabel="Mount"
pluralLabel="Mounts"
/>
</Card>
);
},
)}
</div>
);
};
export default KubernetesVolumeMountsTab;

View File

@@ -0,0 +1,321 @@
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import Card from "Common/UI/Components/Card/Card";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { fetchRawK8sObject } from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import IconProp from "Common/Types/Icon/IconProp";
export interface ComponentProps {
clusterIdentifier: string;
resourceType: string;
resourceName: string;
namespace?: string | undefined;
}
/**
* Convert a JavaScript object to YAML string.
*/
function toYaml(obj: unknown, indent: number = 0): string {
const prefix: string = " ".repeat(indent);
if (obj === null || obj === undefined) {
return "null";
}
if (typeof obj === "string") {
// Quote strings that contain special chars or look like numbers
if (
obj.includes(":") ||
obj.includes("#") ||
obj.includes("\n") ||
obj.includes("'") ||
obj.includes('"') ||
obj === "" ||
obj === "true" ||
obj === "false" ||
obj === "null" ||
new RegExp("^\\d").test(obj)
) {
return `"${obj.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
return obj;
}
if (typeof obj === "number" || typeof obj === "boolean") {
return String(obj);
}
if (Array.isArray(obj)) {
if (obj.length === 0) {
return "[]";
}
const lines: Array<string> = [];
for (const item of obj) {
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
const entries: Array<[string, unknown]> = Object.entries(
item as Record<string, unknown>,
);
if (entries.length > 0) {
const [firstKey, firstVal] = entries[0]!;
lines.push(`${prefix}- ${firstKey}: ${toYaml(firstVal, indent + 2)}`);
for (let i: number = 1; i < entries.length; i++) {
const [key, val] = entries[i]!;
lines.push(`${prefix} ${key}: ${toYaml(val, indent + 2)}`);
}
} else {
lines.push(`${prefix}- {}`);
}
} else {
lines.push(`${prefix}- ${toYaml(item, indent + 1)}`);
}
}
return "\n" + lines.join("\n");
}
if (typeof obj === "object") {
const record: Record<string, unknown> = obj as Record<string, unknown>;
const keys: Array<string> = Object.keys(record);
if (keys.length === 0) {
return "{}";
}
const lines: Array<string> = [];
for (const key of keys) {
const val: unknown = record[key];
if (
val !== null &&
val !== undefined &&
typeof val === "object" &&
!Array.isArray(val) &&
Object.keys(val as Record<string, unknown>).length > 0
) {
lines.push(`${prefix}${key}:`);
lines.push(toYaml(val, indent + 1));
} else if (Array.isArray(val) && val.length > 0) {
lines.push(`${prefix}${key}:${toYaml(val, indent + 1)}`);
} else {
lines.push(`${prefix}${key}: ${toYaml(val, indent + 1)}`);
}
}
return lines.join("\n");
}
return String(obj);
}
/**
* Remove noisy internal Kubernetes fields that are not useful for users.
* - managedFields: internal API server field ownership tracking
* - resourceVersion: internal etcd revision
* - uid: internal object UUID
* - generation: internal object version counter
* - selfLink: deprecated API field
*/
function cleanK8sObject(obj: Record<string, unknown>): Record<string, unknown> {
const cleaned: Record<string, unknown> = { ...obj };
// Clean metadata sub-fields
if (
cleaned["metadata"] &&
typeof cleaned["metadata"] === "object" &&
!Array.isArray(cleaned["metadata"])
) {
const metadata: Record<string, unknown> = {
...(cleaned["metadata"] as Record<string, unknown>),
};
delete metadata["managedFields"];
delete metadata["uid"];
delete metadata["resourceVersion"];
delete metadata["generation"];
delete metadata["selfLink"];
cleaned["metadata"] = metadata;
}
return cleaned;
}
const KubernetesYamlTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [yamlContent, setYamlContent] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [copied, setCopied] = useState<boolean>(false);
useEffect(() => {
const fetchData: () => Promise<void> = async (): Promise<void> => {
setIsLoading(true);
setError("");
try {
const result: Record<string, unknown> | null = await fetchRawK8sObject({
clusterIdentifier: props.clusterIdentifier,
resourceType: props.resourceType,
resourceName: props.resourceName,
namespace: props.namespace,
});
if (result && Object.keys(result).length > 0) {
// Remove noisy internal Kubernetes fields before rendering
const cleaned: Record<string, unknown> = cleanK8sObject(result);
const yaml: string = toYaml(cleaned);
setYamlContent(yaml);
} else {
setYamlContent("");
}
} catch {
setError("Failed to fetch resource data.");
} finally {
setIsLoading(false);
}
};
fetchData();
}, [
props.clusterIdentifier,
props.resourceType,
props.resourceName,
props.namespace,
]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!yamlContent) {
return (
<ErrorMessage message="No resource spec data available. Ensure the kubernetes-agent has resourceSpecs.enabled set to true in the Helm values." />
);
}
const lines: Array<string> = yamlContent.split("\n");
/**
* Simple YAML syntax highlighter.
* Returns an array of React elements with colored spans for keys, values, etc.
*/
const highlightYamlLine: (line: string) => ReactElement = (
line: string,
): ReactElement => {
// Empty or whitespace-only line
if (line.trim() === "") {
return <span>{line}</span>;
}
// Comment lines
if (line.trimStart().startsWith("#")) {
return <span className="text-gray-400 italic">{line}</span>;
}
// Array item prefix " - "
const arrayMatch: RegExpMatchArray | null = line.match(/^(\s*)(- )(.*)$/);
if (arrayMatch) {
const [, indent, dash, rest] = arrayMatch;
// Check if rest has a key: value pattern
const kvMatch: RegExpMatchArray | null = (rest || "").match(
/^([^:]+):\s*(.*)$/,
);
if (kvMatch) {
const [, key, val] = kvMatch;
return (
<span>
{indent}
<span className="text-gray-500">{dash}</span>
<span className="text-indigo-700 font-medium">{key}</span>
<span className="text-gray-500">: </span>
<span className="text-emerald-700">{val}</span>
</span>
);
}
return (
<span>
{indent}
<span className="text-gray-500">{dash}</span>
<span className="text-emerald-700">{rest}</span>
</span>
);
}
// Key: value lines
const kvLineMatch: RegExpMatchArray | null = line.match(
/^(\s*)([^:]+):\s*(.+)$/,
);
if (kvLineMatch) {
const [, indent, key, val] = kvLineMatch;
return (
<span>
{indent}
<span className="text-indigo-700 font-medium">{key}</span>
<span className="text-gray-500">: </span>
<span className="text-emerald-700">{val}</span>
</span>
);
}
// Key-only lines (e.g., "metadata:")
const keyOnlyMatch: RegExpMatchArray | null =
line.match(/^(\s*)([^:]+):(\s*)$/);
if (keyOnlyMatch) {
const [, indent, key] = keyOnlyMatch;
return (
<span>
{indent}
<span className="text-indigo-700 font-medium">{key}</span>
<span className="text-gray-500">:</span>
</span>
);
}
// Fallback
return <span className="text-gray-800">{line}</span>;
};
return (
<Card
title="Resource Specification"
description="Full resource specification as collected by the kubernetes-agent."
buttons={[
{
title: copied ? "Copied!" : "Copy",
buttonStyle: ButtonStyleType.NORMAL,
icon: IconProp.Copy,
onClick: () => {
navigator.clipboard.writeText(yamlContent);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
},
},
]}
>
<div className="overflow-x-auto bg-gray-50 rounded-lg border border-gray-200">
<table className="w-full">
<tbody>
{lines.map((line: string, index: number) => {
return (
<tr key={index} className="hover:bg-gray-100/50">
<td className="px-4 py-0 text-right text-xs text-gray-400 select-none w-12 align-top font-mono border-r border-gray-200">
{index + 1}
</td>
<td className="px-4 py-0 text-sm font-mono whitespace-pre">
{highlightYamlLine(line)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
);
};
export default KubernetesYamlTab;

View File

@@ -0,0 +1,279 @@
import React, { FunctionComponent, ReactElement } from "react";
import Dropdown, {
DropdownOption,
DropdownValue,
} from "Common/UI/Components/Dropdown/Dropdown";
import Input, { InputType } from "Common/UI/Components/Input/Input";
import IconProp from "Common/Types/Icon/IconProp";
import Button, {
ButtonSize,
ButtonStyleType,
} from "Common/UI/Components/Button/Button";
export interface FilterConditionData {
field: string;
operator: string;
value: string;
}
export interface ComponentProps {
condition: FilterConditionData;
onChange: (condition: FilterConditionData) => void;
onDelete: () => void;
canDelete: boolean;
index: number;
connector: string;
isLast: boolean;
}
const fieldOptions: Array<DropdownOption> = [
{
value: "severityText",
label: "Severity",
description: "Log severity level (e.g. ERROR, WARNING, INFO)",
},
{
value: "body",
label: "Log Body",
description: "The log message content",
},
{
value: "serviceId",
label: "Service ID",
description: "The service that produced the log",
},
];
const operatorOptions: Array<DropdownOption> = [
{ value: "=", label: "equals" },
{ value: "!=", label: "does not equal" },
{ value: "LIKE", label: "contains", description: "Use % as wildcard" },
{ value: "IN", label: "is one of", description: "Comma-separated values" },
];
const severityOptions: Array<DropdownOption> = [
{ value: "TRACE", label: "TRACE" },
{ value: "DEBUG", label: "DEBUG" },
{ value: "INFO", label: "INFO" },
{ value: "WARNING", label: "WARNING" },
{ value: "ERROR", label: "ERROR" },
{ value: "FATAL", label: "FATAL" },
];
const FilterConditionElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const { condition } = props;
const isAttributeField: boolean = condition.field.startsWith("attributes.");
const selectedFieldOption: DropdownOption | undefined = isAttributeField
? undefined
: fieldOptions.find((opt: DropdownOption) => {
return opt.value === condition.field;
});
const selectedOperatorOption: DropdownOption | undefined =
operatorOptions.find((opt: DropdownOption) => {
return opt.value === condition.operator;
});
const operatorHint: string | undefined =
condition.operator === "LIKE"
? "Use % as wildcard (e.g. %error%)"
: condition.operator === "IN"
? "Comma-separated values"
: undefined;
const isFirst: boolean = props.index === 0;
const connectorColor: string =
props.connector === "AND" ? "text-indigo-600" : "text-amber-600";
const connectorBgColor: string =
props.connector === "AND"
? "bg-indigo-50 border-indigo-200"
: "bg-amber-50 border-amber-200";
const lineColor: string =
props.connector === "AND" ? "bg-indigo-200" : "bg-amber-200";
return (
<div className="relative flex">
{/* Timeline column */}
<div className="flex-shrink-0 w-16 flex flex-col items-center relative">
{/* Top line segment (hidden for first) */}
{!isFirst && <div className={`w-0.5 h-3 ${lineColor}`} />}
{isFirst && <div className="h-3" />}
{/* Node: "Where" dot or connector badge */}
{isFirst ? (
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-gray-100 border-2 border-gray-300">
<svg
className="w-3 h-3 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
</div>
) : (
<div
className={`flex items-center justify-center px-1.5 py-0.5 rounded-full text-[10px] font-bold border ${connectorBgColor} ${connectorColor}`}
>
{props.connector}
</div>
)}
{/* Bottom line segment (hidden for last) */}
{!props.isLast ? (
<div className={`w-0.5 flex-1 ${lineColor}`} />
) : (
<div className="flex-1" />
)}
</div>
{/* Condition row */}
<div className="flex-1 group pb-3 pt-0.5">
<div className="flex items-start gap-2 rounded-lg border border-transparent hover:border-gray-200 hover:bg-gray-50/50 transition-all duration-150 p-2 -ml-1">
{/* Field */}
<div className="w-40 flex-shrink-0">
<label className="block text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1">
Field
</label>
<Dropdown
options={[
...fieldOptions,
{
value: "__custom_attribute__",
label: "Custom Attribute...",
description: "Filter on a custom log attribute",
},
]}
value={selectedFieldOption}
placeholder="Select field..."
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
if (value === "__custom_attribute__") {
props.onChange({
...condition,
field: "attributes.",
});
} else {
props.onChange({
...condition,
field: value?.toString() || "",
});
}
}}
/>
</div>
{/* Custom attribute name */}
{isAttributeField && (
<div className="w-28 flex-shrink-0">
<label className="block text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1">
Attribute
</label>
<Input
type={InputType.TEXT}
placeholder="attr name"
value={condition.field.replace("attributes.", "")}
onChange={(value: string) => {
props.onChange({
...condition,
field: `attributes.${value}`,
});
}}
/>
</div>
)}
{/* Operator */}
<div className="w-40 flex-shrink-0">
<label className="block text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1">
Operator
</label>
<Dropdown
options={operatorOptions}
value={selectedOperatorOption}
placeholder="Select..."
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
props.onChange({
...condition,
operator: value?.toString() || "=",
});
}}
/>
</div>
{/* Value */}
<div className="flex-1 min-w-[140px]">
<label className="block text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1">
Value
</label>
{condition.field === "severityText" ? (
<Dropdown
options={severityOptions}
value={
condition.value
? { value: condition.value, label: condition.value }
: undefined
}
placeholder="Select severity..."
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
props.onChange({
...condition,
value: value?.toString() || "",
});
}}
/>
) : (
<div>
<Input
type={InputType.TEXT}
placeholder="Enter value..."
value={condition.value}
onChange={(value: string) => {
props.onChange({ ...condition, value });
}}
/>
{operatorHint && (
<p className="mt-0.5 text-[10px] text-gray-400 leading-tight">
{operatorHint}
</p>
)}
</div>
)}
</div>
{/* Delete - uses same label spacer as other columns to align */}
<div className="flex-shrink-0">
<label className="block text-[10px] font-medium text-transparent uppercase tracking-wider mb-1">
&nbsp;
</label>
{props.canDelete ? (
<Button
icon={IconProp.Trash}
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
buttonSize={ButtonSize.Small}
onClick={props.onDelete}
tooltip="Remove condition"
/>
) : (
<div className="w-8" />
)}
</div>
</div>
</div>
</div>
);
};
export default FilterConditionElement;

View File

@@ -0,0 +1,613 @@
import React, {
FunctionComponent,
ReactElement,
useState,
useEffect,
useCallback,
} from "react";
import Card from "Common/UI/Components/Card/Card";
import Button, {
ButtonSize,
ButtonStyleType,
} from "Common/UI/Components/Button/Button";
import Modal, { ModalWidth } from "Common/UI/Components/Modal/Modal";
import IconProp from "Common/Types/Icon/IconProp";
import ObjectID from "Common/Types/ObjectID";
import BaseModel from "Common/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
import FilterConditionElement, { FilterConditionData } from "./FilterCondition";
export interface ComponentProps {
modelType: { new (): BaseModel };
modelId: ObjectID;
title?: string | undefined;
description?: string | undefined;
}
type LogicalConnector = "AND" | "OR";
const fieldLabels: Record<string, string> = {
severityText: "Severity",
body: "Log Body",
serviceId: "Service ID",
};
const operatorLabels: Record<string, string> = {
"=": "equals",
"!=": "does not equal",
LIKE: "contains",
IN: "is one of",
};
function getFieldLabel(field: string): string {
if (field.startsWith("attributes.")) {
return field;
}
return fieldLabels[field] || field;
}
function getOperatorLabel(operator: string): string {
return operatorLabels[operator] || operator;
}
function parseFilterQuery(query: string): {
conditions: Array<FilterConditionData>;
connector: LogicalConnector;
} {
const defaultResult: {
conditions: Array<FilterConditionData>;
connector: LogicalConnector;
} = {
conditions: [{ field: "severityText", operator: "=", value: "" }],
connector: "AND",
};
if (!query || !query.trim()) {
return defaultResult;
}
const connector: LogicalConnector = query.includes(" OR ") ? "OR" : "AND";
const connectorRegex: RegExp = connector === "AND" ? / AND /i : / OR /i;
const parts: Array<string> = query.split(connectorRegex);
const conditions: Array<FilterConditionData> = [];
for (const part of parts) {
const trimmed: string = part.trim().replace(/^\(|\)$/g, "");
const likeMatch: RegExpMatchArray | null = trimmed.match(
/^(\S+)\s+(LIKE)\s+'([^']*)'$/i,
);
const inMatch: RegExpMatchArray | null = trimmed.match(
/^(\S+)\s+(IN)\s+\(([^)]*)\)$/i,
);
const eqMatch: RegExpMatchArray | null = trimmed.match(
/^(\S+)\s*(=|!=)\s*'([^']*)'$/,
);
if (likeMatch) {
conditions.push({
field: likeMatch[1]!,
operator: "LIKE",
value: likeMatch[3]!,
});
} else if (inMatch) {
conditions.push({
field: inMatch[1]!,
operator: "IN",
value: inMatch[3]!.replace(/'/g, "").trim(),
});
} else if (eqMatch) {
conditions.push({
field: eqMatch[1]!,
operator: eqMatch[2]!,
value: eqMatch[3]!,
});
}
}
if (conditions.length === 0) {
return defaultResult;
}
return { conditions, connector };
}
function buildFilterQuery(
conditions: Array<FilterConditionData>,
connector: LogicalConnector,
): string {
const parts: Array<string> = conditions
.filter((c: FilterConditionData) => {
return c.field && c.operator && c.value;
})
.map((c: FilterConditionData) => {
if (c.operator === "LIKE") {
return `${c.field} LIKE '${c.value}'`;
}
if (c.operator === "IN") {
const values: string = c.value
.split(",")
.map((v: string) => {
return `'${v.trim()}'`;
})
.join(", ");
return `${c.field} IN (${values})`;
}
return `${c.field} ${c.operator} '${c.value}'`;
});
return parts.join(` ${connector} `);
}
function getSeverityColor(value: string): string {
const v: string = value.toUpperCase();
if (v === "FATAL") {
return "bg-red-100 text-red-800 ring-red-600/20";
}
if (v === "ERROR") {
return "bg-red-50 text-red-700 ring-red-600/10";
}
if (v === "WARNING") {
return "bg-amber-50 text-amber-700 ring-amber-600/10";
}
if (v === "INFO") {
return "bg-blue-50 text-blue-700 ring-blue-700/10";
}
if (v === "DEBUG") {
return "bg-gray-50 text-gray-600 ring-gray-500/10";
}
if (v === "TRACE") {
return "bg-gray-50 text-gray-500 ring-gray-500/10";
}
return "bg-gray-50 text-gray-600 ring-gray-500/10";
}
const FilterQueryBuilder: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [conditions, setConditions] = useState<Array<FilterConditionData>>([
{ field: "severityText", operator: "=", value: "" },
]);
const [connector, setConnector] = useState<LogicalConnector>("AND");
const [isSaving, setIsSaving] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [showModal, setShowModal] = useState<boolean>(false);
const [modalConditions, setModalConditions] = useState<
Array<FilterConditionData>
>([]);
const [modalConnector, setModalConnector] = useState<LogicalConnector>("AND");
const loadModel: () => Promise<void> =
useCallback(async (): Promise<void> => {
setIsLoading(true);
try {
const item: BaseModel | null = await ModelAPI.getItem({
modelType: props.modelType,
id: props.modelId,
select: { filterQuery: true } as any,
});
if (item && (item as any).filterQuery) {
const parsed: {
conditions: Array<FilterConditionData>;
connector: LogicalConnector;
} = parseFilterQuery((item as any).filterQuery as string);
setConditions(parsed.conditions);
setConnector(parsed.connector);
}
} catch {
setError("Failed to load filter conditions.");
} finally {
setIsLoading(false);
}
}, [props.modelId, props.modelType]);
useEffect(() => {
loadModel().catch(() => {
// error handled in loadModel
});
}, [loadModel]);
const handleSave: () => Promise<void> = async (): Promise<void> => {
setIsSaving(true);
setError("");
const query: string = buildFilterQuery(modalConditions, modalConnector);
try {
await ModelAPI.updateById({
modelType: props.modelType,
id: props.modelId,
data: { filterQuery: query || "" },
});
setConditions(modalConditions);
setConnector(modalConnector);
setShowModal(false);
} catch {
setError("Failed to save filter conditions.");
} finally {
setIsSaving(false);
}
};
const openModal: () => void = (): void => {
setModalConditions(
conditions.map((c: FilterConditionData) => {
return { ...c };
}),
);
setModalConnector(connector);
setError("");
setShowModal(true);
};
const closeModal: () => void = (): void => {
setShowModal(false);
setError("");
};
const cardTitle: string = props.title || "Filter Conditions";
const cardDescription: string =
props.description ||
"Define which logs this rule applies to. Only logs that match these conditions will be affected. Leave empty to match all logs.";
const savedConditions: Array<FilterConditionData> = conditions.filter(
(c: FilterConditionData) => {
return c.field && c.operator && c.value;
},
);
const hasConditions: boolean = savedConditions.length > 0;
const connectorLineColor: string =
connector === "AND" ? "bg-indigo-200" : "bg-amber-200";
const connectorBadgeStyle: string =
connector === "AND"
? "bg-indigo-50 text-indigo-600 ring-indigo-500/20"
: "bg-amber-50 text-amber-600 ring-amber-500/20";
if (isLoading) {
return (
<Card title={cardTitle} description={cardDescription}>
<div className="p-10 flex items-center justify-center">
<div className="flex items-center gap-3 text-gray-400">
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span className="text-sm">Loading...</span>
</div>
</div>
</Card>
);
}
return (
<>
<Card
title={cardTitle}
description={cardDescription}
buttons={[
{
title: "Edit",
buttonStyle: ButtonStyleType.NORMAL,
onClick: openModal,
icon: IconProp.Edit,
},
]}
>
<div className="px-5 pt-4 pb-5">
{hasConditions ? (
<div>
{/* Read-only conditions with vertical timeline */}
<div className="relative">
{savedConditions.map(
(condition: FilterConditionData, index: number) => {
const isSeverity: boolean =
condition.field === "severityText";
const isFirst: boolean = index === 0;
const isLast: boolean =
index === savedConditions.length - 1;
return (
<div key={index} className="relative flex">
{/* Timeline column */}
<div className="flex-shrink-0 w-10 flex flex-col items-center">
{/* Top line */}
{!isFirst && (
<div className={`w-px h-2 ${connectorLineColor}`} />
)}
{isFirst && <div className="h-2" />}
{/* Node */}
{isFirst ? (
<div className="w-5 h-5 rounded-full bg-gray-100 border-2 border-gray-300 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-gray-400" />
</div>
) : (
<div
className={`flex items-center justify-center rounded-full px-1 h-5 text-[9px] font-bold ring-1 ring-inset ${connectorBadgeStyle}`}
>
{connector}
</div>
)}
{/* Bottom line */}
{!isLast ? (
<div
className={`w-px flex-1 ${connectorLineColor}`}
/>
) : (
<div className="flex-1" />
)}
</div>
{/* Condition content */}
<div className="flex-1 pb-2 pt-0">
<div className="flex items-center gap-2 py-1 pl-2 rounded-md hover:bg-gray-50 transition-colors duration-100 cursor-default">
<span className="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 text-xs font-semibold text-gray-700 tracking-tight">
{getFieldLabel(condition.field)}
</span>
<span className="text-xs text-gray-400 italic">
{getOperatorLabel(condition.operator)}
</span>
{isSeverity ? (
<span
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-bold ring-1 ring-inset ${getSeverityColor(condition.value)}`}
>
{condition.value || "(empty)"}
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded bg-indigo-50 text-xs font-mono font-medium text-indigo-700 ring-1 ring-inset ring-indigo-700/10">
{condition.value || "(empty)"}
</span>
)}
</div>
</div>
</div>
);
},
)}
</div>
{/* Summary footer */}
{savedConditions.length > 1 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-400">
{savedConditions.length} conditions joined with{" "}
<span
className={`font-semibold ${connector === "AND" ? "text-indigo-500" : "text-amber-500"}`}
>
{connector}
</span>
{" \u2014 "}
{connector === "AND"
? "log must match all"
: "log must match at least one"}
</span>
</div>
)}
</div>
) : (
/* Empty state */
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="relative mb-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-gray-50 to-gray-100 border border-gray-200 flex items-center justify-center">
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
</div>
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-green-100 border-2 border-white flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
</div>
</div>
<p className="text-sm font-medium text-gray-600">
No filter conditions
</p>
<p className="text-xs text-gray-400 mt-1 max-w-xs">
This rule matches all incoming logs. Add conditions to target
specific logs.
</p>
</div>
)}
</div>
</Card>
{/* Edit modal */}
{showModal && (
<Modal
title="Edit Filter Conditions"
description="Build filter rules to target specific logs. Conditions are evaluated in order."
onClose={closeModal}
modalWidth={ModalWidth.Large}
submitButtonText="Save Changes"
onSubmit={() => {
handleSave().catch(() => {
// error handled inside handleSave
});
}}
isLoading={isSaving}
disableSubmitButton={isSaving}
>
<div>
{error && (
<div className="mb-4">
<Alert
type={AlertType.DANGER}
title={error}
onClose={() => {
setError("");
}}
/>
</div>
)}
{/* Connector toggle */}
{modalConditions.length > 1 && (
<div className="mb-5 flex items-center gap-3">
<span className="text-sm text-gray-500">Log must match</span>
<div className="inline-flex rounded-lg border border-gray-200 p-0.5 bg-gray-50">
<button
type="button"
className={`px-3.5 py-1.5 text-xs font-semibold rounded-md transition-all duration-150 ${
modalConnector === "AND"
? "bg-white text-indigo-700 shadow-sm ring-1 ring-black/5"
: "text-gray-400 hover:text-gray-600"
}`}
onClick={() => {
setModalConnector("AND");
}}
>
All conditions
</button>
<button
type="button"
className={`px-3.5 py-1.5 text-xs font-semibold rounded-md transition-all duration-150 ${
modalConnector === "OR"
? "bg-white text-amber-700 shadow-sm ring-1 ring-black/5"
: "text-gray-400 hover:text-gray-600"
}`}
onClick={() => {
setModalConnector("OR");
}}
>
Any condition
</button>
</div>
</div>
)}
{/* Condition builder with timeline */}
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div className="px-4 pt-2">
{modalConditions.map(
(condition: FilterConditionData, index: number) => {
return (
<FilterConditionElement
key={index}
condition={condition}
canDelete={modalConditions.length > 1}
index={index}
connector={modalConnector}
isLast={index === modalConditions.length - 1}
onChange={(updated: FilterConditionData) => {
const newConditions: Array<FilterConditionData> = [
...modalConditions,
];
newConditions[index] = updated;
setModalConditions(newConditions);
}}
onDelete={() => {
const newConditions: Array<FilterConditionData> =
modalConditions.filter(
(_: FilterConditionData, i: number) => {
return i !== index;
},
);
setModalConditions(newConditions);
}}
/>
);
},
)}
</div>
{/* Add condition footer */}
<div className="px-4 py-3 bg-gray-50/50 border-t border-gray-100">
<div className="flex items-center gap-2">
<Button
title="Add condition"
icon={IconProp.Add}
buttonStyle={ButtonStyleType.OUTLINE}
buttonSize={ButtonSize.Small}
onClick={() => {
setModalConditions([
...modalConditions,
{ field: "severityText", operator: "=", value: "" },
]);
}}
/>
{modalConditions.length > 1 && (
<Button
title="Clear all"
icon={IconProp.Close}
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
buttonSize={ButtonSize.Small}
onClick={() => {
setModalConditions([
{ field: "severityText", operator: "=", value: "" },
]);
setModalConnector("AND");
}}
/>
)}
</div>
</div>
</div>
{/* Query preview */}
{buildFilterQuery(modalConditions, modalConnector) && (
<div className="mt-4">
<details className="group">
<summary className="flex items-center gap-1.5 cursor-pointer text-xs text-gray-400 hover:text-gray-500 transition-colors select-none list-none">
<svg
className="w-3 h-3 transition-transform duration-150 group-open:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
<span className="font-medium">Preview query</span>
</summary>
<div className="mt-2 rounded-lg bg-gray-900 p-3.5 overflow-x-auto">
<code className="text-[13px] text-emerald-400 font-mono break-all leading-relaxed whitespace-pre-wrap">
{buildFilterQuery(modalConditions, modalConnector)}
</code>
</div>
</details>
</div>
)}
</div>
</Modal>
)}
</>
);
};
export default FilterQueryBuilder;

View File

@@ -0,0 +1,706 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
import Button, {
ButtonSize,
ButtonStyleType,
} from "Common/UI/Components/Button/Button";
import Input, { InputType } from "Common/UI/Components/Input/Input";
import Dropdown, {
DropdownOption,
DropdownValue,
} from "Common/UI/Components/Dropdown/Dropdown";
import Toggle from "Common/UI/Components/Toggle/Toggle";
import IconProp from "Common/Types/Icon/IconProp";
import ObjectID from "Common/Types/ObjectID";
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
import LogPipelineProcessor from "Common/Models/DatabaseModels/LogPipelineProcessor";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import FieldLabelElement from "Common/UI/Components/Detail/FieldLabel";
import SeverityMappingRow, { SeverityMapping } from "./SeverityMappingRow";
import { JSONObject, JSONValue } from "Common/Types/JSON";
import Modal, { ModalWidth } from "Common/UI/Components/Modal/Modal";
export interface ComponentProps {
pipelineId: ObjectID;
onProcessorCreated: () => void;
onCancel: () => void;
}
type ProcessorType =
| "SeverityRemapper"
| "AttributeRemapper"
| "CategoryProcessor"
| "";
const processorTypeOptions: Array<DropdownOption> = [
{
value: "SeverityRemapper",
label: "Severity Remapper",
description:
"Reads a raw value (e.g. 'warn') from a log attribute and maps it to a standard severity level (e.g. WARNING)",
},
{
value: "AttributeRemapper",
label: "Attribute Remapper",
description:
"Renames or copies a log attribute key to a new key (e.g. rename 'src_ip' to 'source_ip')",
},
{
value: "CategoryProcessor",
label: "Category Processor",
description:
"Tags logs with a category name based on filter rules. Stored in log attributes for easy searching.",
},
];
interface CategoryRule {
name: string;
filterQuery: string;
}
const ProcessorForm: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
// Common fields
const [name, setName] = useState<string>("");
const [processorType, setProcessorType] = useState<ProcessorType>("");
const [isEnabled, setIsEnabled] = useState<boolean>(true);
// Severity Remapper fields
const [severitySourceKey, setSeveritySourceKey] = useState<string>("level");
const [severityMappings, setSeverityMappings] = useState<
Array<SeverityMapping>
>([{ matchValue: "", severityText: "", severityNumber: 0 }]);
// Attribute Remapper fields
const [attrSourceKey, setAttrSourceKey] = useState<string>("");
const [attrTargetKey, setAttrTargetKey] = useState<string>("");
const [preserveSource, setPreserveSource] = useState<boolean>(false);
const [overrideOnConflict, setOverrideOnConflict] = useState<boolean>(true);
// Category Processor fields
const [categoryTargetKey, setCategoryTargetKey] =
useState<string>("category");
const [categories, setCategories] = useState<Array<CategoryRule>>([
{ name: "", filterQuery: "" },
]);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const buildConfiguration: () => JSONObject = (): JSONObject => {
switch (processorType) {
case "SeverityRemapper":
return {
sourceKey: severitySourceKey,
mappings: severityMappings.filter((m: SeverityMapping) => {
return m.matchValue && m.severityText;
}) as unknown as JSONValue,
};
case "AttributeRemapper":
return {
sourceKey: attrSourceKey,
targetKey: attrTargetKey,
preserveSource,
overrideOnConflict,
};
case "CategoryProcessor":
return {
targetKey: categoryTargetKey,
categories: categories.filter((c: CategoryRule) => {
return c.name && c.filterQuery;
}) as unknown as JSONValue,
};
default:
return {};
}
};
const validate: () => string | null = (): string | null => {
if (!name.trim()) {
return "Name is required.";
}
if (!processorType) {
return "Please select a processor type.";
}
switch (processorType) {
case "SeverityRemapper": {
if (!severitySourceKey.trim()) {
return "Source key is required for Severity Remapper.";
}
const validMappings: Array<SeverityMapping> = severityMappings.filter(
(m: SeverityMapping) => {
return m.matchValue && m.severityText;
},
);
if (validMappings.length === 0) {
return "At least one severity mapping is required.";
}
break;
}
case "AttributeRemapper":
if (!attrSourceKey.trim()) {
return "Source key is required.";
}
if (!attrTargetKey.trim()) {
return "Target key is required.";
}
break;
case "CategoryProcessor": {
if (!categoryTargetKey.trim()) {
return "Target key is required.";
}
const validCategories: Array<CategoryRule> = categories.filter(
(c: CategoryRule) => {
return c.name && c.filterQuery;
},
);
if (validCategories.length === 0) {
return "At least one category rule is required.";
}
break;
}
}
return null;
};
const handleSave: () => Promise<void> = async (): Promise<void> => {
const validationError: string | null = validate();
if (validationError) {
setError(validationError);
return;
}
setIsSaving(true);
setError("");
try {
const processor: LogPipelineProcessor = new LogPipelineProcessor();
processor.name = name;
processor.processorType = processorType;
processor.configuration = buildConfiguration();
processor.isEnabled = isEnabled;
processor.logPipelineId = props.pipelineId;
processor.sortOrder = 1;
await ModelAPI.create({
model: processor,
modelType: LogPipelineProcessor,
});
props.onProcessorCreated();
} catch {
setError("Failed to create processor. Please try again.");
} finally {
setIsSaving(false);
}
};
return (
<Modal
title="Add Processor"
description="Processors transform logs as they flow through the pipeline. They run in order after the filter conditions match. Each processor modifies the log before it is stored."
modalWidth={ModalWidth.Large}
submitButtonText="Create Processor"
onSubmit={handleSave}
isLoading={isSaving}
onClose={props.onCancel}
>
<div className="p-2 space-y-5">
{error && (
<Alert
type={AlertType.DANGER}
title={error}
onClose={() => {
setError("");
}}
/>
)}
{/* Name */}
<div>
<FieldLabelElement title="Processor Name" />
<div className="mt-1">
<Input
type={InputType.TEXT}
placeholder="e.g. Remap severity levels"
value={name}
onChange={setName}
/>
</div>
</div>
{/* Processor Type */}
<div>
<FieldLabelElement
title="Processor Type"
description="Choose what this processor does"
/>
<div className="mt-1">
<Dropdown
options={processorTypeOptions}
value={
processorType
? processorTypeOptions.find((opt: DropdownOption) => {
return opt.value === processorType;
})
: undefined
}
placeholder="Select processor type..."
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
setProcessorType((value?.toString() as ProcessorType) || "");
}}
/>
</div>
</div>
{/* === Severity Remapper Configuration === */}
{processorType === "SeverityRemapper" && (
<div className="border border-indigo-200 rounded-lg p-4 bg-indigo-50/30">
<h4 className="text-sm font-semibold text-gray-700 mb-1">
Severity Remapper Configuration
</h4>
<p className="text-xs text-gray-500 mb-3">
Normalizes raw severity values from your logs into standard levels
(TRACE, DEBUG, INFO, WARNING, ERROR, FATAL). This processor reads
a value from a log attribute and maps it to the log&apos;s{" "}
<code className="px-1 py-0.5 bg-indigo-100 rounded text-indigo-700 text-[11px]">
severityText
</code>{" "}
field.
</p>
{/* How it works */}
<div className="mb-4 p-3 bg-white rounded-md border border-indigo-100">
<p className="text-xs font-semibold text-gray-600 mb-1.5">
How it works
</p>
<div className="text-xs text-gray-500 space-y-1">
<p>
1. The processor reads the value from the Source Attribute in
your log&apos;s{" "}
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
attributes
</code>{" "}
object.
</p>
<p>2. It looks up the value in your mappings below.</p>
<p>
3. If a match is found, the log&apos;s{" "}
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
severityText
</code>{" "}
is updated to the mapped severity level.
</p>
</div>
<div className="mt-2 p-2 bg-gray-900 rounded text-[11px] font-mono text-gray-300 leading-relaxed">
<span className="text-gray-500">// Example: incoming log</span>
<br />
<span className="text-amber-400">attributes</span>: {"{"}{" "}
<span className="text-emerald-400">&quot;level&quot;</span>:{" "}
<span className="text-sky-400">&quot;warn&quot;</span> {"}"}
<br />
<span className="text-gray-500">
// After processing (with mapping: warn → WARNING)
</span>
<br />
<span className="text-amber-400">severityText</span>:{" "}
<span className="text-sky-400">&quot;WARNING&quot;</span>
</div>
</div>
<div className="mb-4">
<FieldLabelElement
title="Source Attribute"
description={
'The key in your log\'s attributes object that contains the raw severity value. Many logging libraries (Pino, Winston, Bunyan) use "level" by default.'
}
/>
<div className="mt-1 w-64">
<Input
type={InputType.TEXT}
placeholder="e.g. level"
value={severitySourceKey}
onChange={setSeveritySourceKey}
/>
</div>
<p className="mt-1 text-[11px] text-gray-400">
Common values: <code className="text-gray-500">level</code>,{" "}
<code className="text-gray-500">log_level</code>,{" "}
<code className="text-gray-500">severity</code>,{" "}
<code className="text-gray-500">priority</code>
</p>
</div>
<div>
<FieldLabelElement
title="Mappings"
description="Define how raw attribute values map to standard severity levels. The match value should be exactly what your application emits."
/>
<div className="mt-2 space-y-2">
{severityMappings.map(
(mapping: SeverityMapping, index: number) => {
return (
<SeverityMappingRow
key={index}
mapping={mapping}
canDelete={severityMappings.length > 1}
onChange={(updated: SeverityMapping) => {
const newMappings: Array<SeverityMapping> = [
...severityMappings,
];
newMappings[index] = updated;
setSeverityMappings(newMappings);
}}
onDelete={() => {
setSeverityMappings(
severityMappings.filter(
(_: SeverityMapping, i: number) => {
return i !== index;
},
),
);
}}
/>
);
},
)}
</div>
<div className="mt-2">
<Button
title="Add Mapping"
icon={IconProp.Add}
buttonStyle={ButtonStyleType.OUTLINE}
buttonSize={ButtonSize.Small}
onClick={() => {
setSeverityMappings([
...severityMappings,
{
matchValue: "",
severityText: "",
severityNumber: 0,
},
]);
}}
/>
</div>
</div>
</div>
)}
{/* === Attribute Remapper Configuration === */}
{processorType === "AttributeRemapper" && (
<div className="border border-indigo-200 rounded-lg p-4 bg-indigo-50/30">
<h4 className="text-sm font-semibold text-gray-700 mb-1">
Attribute Remapper Configuration
</h4>
<p className="text-xs text-gray-500 mb-3">
Renames or copies a key inside the log&apos;s{" "}
<code className="px-1 py-0.5 bg-indigo-100 rounded text-indigo-700 text-[11px]">
attributes
</code>{" "}
object. Useful for standardizing attribute names across services
or cleaning up legacy key names.
</p>
{/* How it works */}
<div className="mb-4 p-3 bg-white rounded-md border border-indigo-100">
<p className="text-xs font-semibold text-gray-600 mb-1.5">
How it works
</p>
<div className="text-xs text-gray-500 space-y-1">
<p>
1. Reads the value from{" "}
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
attributes[sourceKey]
</code>
.
</p>
<p>
2. Writes that value to{" "}
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
attributes[targetKey]
</code>
.
</p>
<p>
3. Optionally removes the original source key (if Preserve
Source is off).
</p>
</div>
<div className="mt-2 p-2 bg-gray-900 rounded text-[11px] font-mono text-gray-300 leading-relaxed">
<span className="text-gray-500">
// Before: attributes has &quot;src_ip&quot;
</span>
<br />
<span className="text-amber-400">attributes</span>: {"{"}{" "}
<span className="text-emerald-400">&quot;src_ip&quot;</span>:{" "}
<span className="text-sky-400">&quot;10.0.1.5&quot;</span> {"}"}
<br />
<span className="text-gray-500">
// After: renamed to &quot;source_ip&quot;
</span>
<br />
<span className="text-amber-400">attributes</span>: {"{"}{" "}
<span className="text-emerald-400">&quot;source_ip&quot;</span>:{" "}
<span className="text-sky-400">&quot;10.0.1.5&quot;</span> {"}"}
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<FieldLabelElement
title="Source Key"
description="The attribute key to read the value from"
/>
<div className="mt-1">
<Input
type={InputType.TEXT}
placeholder="e.g. src_ip"
value={attrSourceKey}
onChange={setAttrSourceKey}
/>
</div>
</div>
<div>
<FieldLabelElement
title="Target Key"
description="The new attribute key to write the value to"
/>
<div className="mt-1">
<Input
type={InputType.TEXT}
placeholder="e.g. source_ip"
value={attrTargetKey}
onChange={setAttrTargetKey}
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Toggle
title="Preserve Source"
description="Keep the original source attribute after remapping. If off, the source key is removed."
value={preserveSource}
onChange={setPreserveSource}
/>
<Toggle
title="Override on Conflict"
description="If the target key already exists, overwrite its value. If off and the target exists, the remap is skipped."
value={overrideOnConflict}
onChange={setOverrideOnConflict}
/>
</div>
</div>
)}
{/* === Category Processor Configuration === */}
{processorType === "CategoryProcessor" && (
<div className="border border-indigo-200 rounded-lg p-4 bg-indigo-50/30">
<h4 className="text-sm font-semibold text-gray-700 mb-1">
Category Processor Configuration
</h4>
<p className="text-xs text-gray-500 mb-3">
Tags each log with a category name based on filter rules. The
category value is stored in the log&apos;s{" "}
<code className="px-1 py-0.5 bg-indigo-100 rounded text-indigo-700 text-[11px]">
attributes
</code>{" "}
object under the Target Attribute key. Rules are evaluated in
order and <strong>the first matching rule wins</strong>.
</p>
{/* How it works */}
<div className="mb-4 p-3 bg-white rounded-md border border-indigo-100">
<p className="text-xs font-semibold text-gray-600 mb-1.5">
How it works
</p>
<div className="text-xs text-gray-500 space-y-1">
<p>
1. Each category rule has a filter condition (e.g.{" "}
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
severityText = &apos;ERROR&apos;
</code>
).
</p>
<p>
2. The processor evaluates rules top to bottom. The first rule
that matches the log is applied.
</p>
<p>
3. The category name is stored at{" "}
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
attributes[targetAttribute]
</code>{" "}
on the log.
</p>
<p>
4. You can then filter and search logs by this attribute in
the Logs Viewer.
</p>
</div>
<div className="mt-2 p-2 bg-gray-900 rounded text-[11px] font-mono text-gray-300 leading-relaxed">
<span className="text-gray-500">
// Rule: &quot;Critical Errors&quot; when severityText =
&apos;ERROR&apos;
</span>
<br />
<span className="text-gray-500">
// Target Attribute: &quot;category&quot;
</span>
<br />
<br />
<span className="text-gray-500">// Before processing</span>
<br />
<span className="text-amber-400">severityText</span>:{" "}
<span className="text-sky-400">&quot;ERROR&quot;</span>,{" "}
<span className="text-amber-400">attributes</span>: {"{"} {"}"}
<br />
<span className="text-gray-500">// After processing</span>
<br />
<span className="text-amber-400">severityText</span>:{" "}
<span className="text-sky-400">&quot;ERROR&quot;</span>,{" "}
<span className="text-amber-400">attributes</span>: {"{"}{" "}
<span className="text-emerald-400">&quot;category&quot;</span>:{" "}
<span className="text-sky-400">
&quot;Critical Errors&quot;
</span>{" "}
{"}"}
</div>
</div>
<div className="mb-4">
<FieldLabelElement
title="Target Attribute"
description={
"The key in the log's attributes where the matched category name will be stored. You can search logs by this attribute in the Logs Viewer."
}
/>
<div className="mt-1 w-64">
<Input
type={InputType.TEXT}
placeholder="e.g. category"
value={categoryTargetKey}
onChange={setCategoryTargetKey}
/>
</div>
<p className="mt-1 text-[11px] text-gray-400">
The category will be accessible as{" "}
<code className="text-gray-500">
attributes.{categoryTargetKey || "category"}
</code>{" "}
in your logs.
</p>
</div>
<div>
<FieldLabelElement
title="Category Rules"
description="Define categories and the filter conditions that trigger them. Rules are evaluated top to bottom — the first match wins."
/>
<div className="mt-2 space-y-2">
{categories.map((cat: CategoryRule, index: number) => {
return (
<div
key={index}
className="grid grid-cols-12 gap-3 items-center p-3 bg-gray-50 rounded-md border border-gray-200"
>
<div className="col-span-4">
<Input
type={InputType.TEXT}
placeholder="Category name (e.g. Error)"
value={cat.name}
onChange={(value: string) => {
const newCats: Array<CategoryRule> = [
...categories,
];
newCats[index] = {
...cat,
name: value,
};
setCategories(newCats);
}}
/>
</div>
<div className="col-span-1 flex justify-center">
<span className="text-gray-400 text-sm font-medium">
when
</span>
</div>
<div className="col-span-6">
<Input
type={InputType.TEXT}
placeholder="e.g. severityText = 'ERROR'"
value={cat.filterQuery}
onChange={(value: string) => {
const newCats: Array<CategoryRule> = [
...categories,
];
newCats[index] = {
...cat,
filterQuery: value,
};
setCategories(newCats);
}}
/>
</div>
<div className="col-span-1 flex justify-end">
<Button
icon={IconProp.Trash}
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
buttonSize={ButtonSize.Small}
onClick={() => {
setCategories(
categories.filter(
(_: CategoryRule, i: number) => {
return i !== index;
},
),
);
}}
disabled={categories.length <= 1}
/>
</div>
</div>
);
})}
</div>
<div className="mt-2">
<Button
title="Add Category Rule"
icon={IconProp.Add}
buttonStyle={ButtonStyleType.OUTLINE}
buttonSize={ButtonSize.Small}
onClick={() => {
setCategories([
...categories,
{ name: "", filterQuery: "" },
]);
}}
/>
</div>
</div>
</div>
)}
{/* Enabled toggle */}
{processorType && (
<div>
<Toggle
title="Enabled"
description="Enable this processor to start processing logs"
value={isEnabled}
onChange={setIsEnabled}
/>
</div>
)}
</div>
</Modal>
);
};
export default ProcessorForm;

View File

@@ -0,0 +1,102 @@
import React, { FunctionComponent, ReactElement } from "react";
import Input, { InputType } from "Common/UI/Components/Input/Input";
import Dropdown, {
DropdownOption,
DropdownValue,
} from "Common/UI/Components/Dropdown/Dropdown";
import Button, {
ButtonSize,
ButtonStyleType,
} from "Common/UI/Components/Button/Button";
import IconProp from "Common/Types/Icon/IconProp";
export interface SeverityMapping {
matchValue: string;
severityText: string;
severityNumber: number;
}
export interface ComponentProps {
mapping: SeverityMapping;
onChange: (mapping: SeverityMapping) => void;
onDelete: () => void;
canDelete: boolean;
}
const severityOptions: Array<DropdownOption> = [
{ value: "TRACE", label: "TRACE" },
{ value: "DEBUG", label: "DEBUG" },
{ value: "INFO", label: "INFO" },
{ value: "WARNING", label: "WARNING" },
{ value: "ERROR", label: "ERROR" },
{ value: "FATAL", label: "FATAL" },
];
const severityNumberMap: Record<string, number> = {
TRACE: 1,
DEBUG: 5,
INFO: 9,
WARNING: 13,
ERROR: 17,
FATAL: 21,
};
const SeverityMappingRow: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const { mapping } = props;
return (
<div className="grid grid-cols-12 gap-3 items-center p-3 bg-gray-50 rounded-md border border-gray-200">
<div className="col-span-5">
<Input
type={InputType.TEXT}
placeholder='Value to match (e.g. "warn", "err")'
value={mapping.matchValue}
onChange={(value: string) => {
props.onChange({ ...mapping, matchValue: value });
}}
/>
</div>
<div className="col-span-1 flex justify-center">
<span className="text-gray-400 text-sm font-medium">maps to</span>
</div>
<div className="col-span-5">
<Dropdown
options={severityOptions}
value={
mapping.severityText
? {
value: mapping.severityText,
label: mapping.severityText,
}
: undefined
}
placeholder="Select severity..."
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
const text: string = value?.toString() || "";
props.onChange({
...mapping,
severityText: text,
severityNumber: severityNumberMap[text] || 0,
});
}}
/>
</div>
<div className="col-span-1 flex justify-end">
<Button
icon={IconProp.Trash}
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
buttonSize={ButtonSize.Small}
onClick={props.onDelete}
disabled={!props.canDelete}
/>
</div>
</div>
);
};
export default SeverityMappingRow;

View File

@@ -8,10 +8,25 @@ import LogsViewer, {
HistogramBucket,
FacetData,
ActiveFilter,
LogsViewMode,
} from "Common/UI/Components/LogsViewer/LogsViewer";
import {
DEFAULT_LOGS_TABLE_COLUMNS,
LogsSavedViewOption,
normalizeLogsTableColumns,
} from "Common/UI/Components/LogsViewer/types";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import ModelFormModal from "Common/UI/Components/ModelFormModal/ModelFormModal";
import { FormType } from "Common/UI/Components/Forms/ModelForm";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import LogSeverity from "Common/Types/Log/LogSeverity";
import LogSavedView from "Common/Models/DatabaseModels/LogSavedView";
import API from "Common/UI/Utils/API/API";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import LocalStorage from "Common/UI/Utils/LocalStorage";
import ModelAPI, {
ListResult as ModelListResult,
} from "Common/UI/Utils/ModelAPI/ModelAPI";
import AnalyticsModelAPI, {
ListResult,
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
@@ -36,6 +51,7 @@ 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 JSONFunctions from "Common/Types/JSONFunctions";
import { APP_API_URL } from "Common/UI/Config";
import ProjectUtil from "Common/UI/Utils/Project";
import RangeStartAndEndDateTime, {
@@ -54,10 +70,101 @@ export interface ComponentProps {
noLogsMessage?: string | undefined;
logQuery?: Query<Log> | undefined;
limit?: number | undefined;
onCountChange?: ((count: number) => void) | undefined;
onShowDocumentation?: (() => void) | undefined;
}
const DEFAULT_PAGE_SIZE: number = 100;
const LIVE_POLL_INTERVAL_MS: number = 10000;
const SAVED_VIEWS_LIMIT: number = 100;
const FACET_FILTER_KEYS: Array<string> = [
"severityText",
"serviceId",
"traceId",
"spanId",
];
function getColumnsStorageKey(viewerId: string): string {
const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
return `logs-columns:${projectId?.toString() || "global"}:${viewerId}`;
}
function loadSelectedColumns(viewerId: string): Array<string> {
const savedValue: unknown = LocalStorage.getItem(
getColumnsStorageKey(viewerId),
);
if (Array.isArray(savedValue)) {
return normalizeLogsTableColumns(
savedValue.filter((value: unknown): value is string => {
return typeof value === "string";
}),
);
}
return [...DEFAULT_LOGS_TABLE_COLUMNS];
}
function getQueryValues(value: unknown): Array<string> {
if (value instanceof Includes) {
return value.values.map((item: string | number | ObjectID) => {
return item.toString();
});
}
if (
typeof value === "string" ||
typeof value === "number" ||
value instanceof ObjectID
) {
return [value.toString()];
}
return [];
}
function buildFacetFiltersFromQuery(
query: Query<Log>,
baseQuery: Query<Log>,
): Map<string, Set<string>> {
const nextFilters: Map<string, Set<string>> = new Map();
for (const facetKey of FACET_FILTER_KEYS) {
if ((baseQuery as any)[facetKey] !== undefined) {
continue;
}
const values: Array<string> = getQueryValues((query as any)[facetKey]);
if (values.length > 0) {
nextFilters.set(facetKey, new Set(values));
}
}
return nextFilters;
}
function resolveSavedTimeRange(
query: Query<Log>,
): RangeStartAndEndDateTime | undefined {
const timeFilter: unknown = (query as any).time;
if (!timeFilter || !(timeFilter instanceof InBetween)) {
return undefined;
}
const startTime: Date = new Date(timeFilter.startValue as string | Date);
const endTime: Date = new Date(timeFilter.endValue as string | Date);
if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime())) {
return undefined;
}
return {
range: TimeRange.CUSTOM,
startAndEndDate: new InBetween<Date>(startTime, endTime),
};
}
function buildBaseQuery(props: ComponentProps): Query<Log> {
const query: Query<Log> = {};
@@ -137,8 +244,28 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
const [sortOrder, setSortOrder] = useState<SortOrder>(SortOrder.Descending);
const [isLiveEnabled, setIsLiveEnabled] = useState<boolean>(false);
const [isLiveUpdating, setIsLiveUpdating] = useState<boolean>(false);
const [savedViews, setSavedViews] = useState<Array<LogSavedView>>([]);
const [selectedSavedViewId, setSelectedSavedViewId] = useState<string | null>(
null,
);
const [selectedColumns, setSelectedColumns] = useState<Array<string>>(() => {
return loadSelectedColumns(props.id);
});
const [showCreateSavedViewModal, setShowCreateSavedViewModal] =
useState<boolean>(false);
const [savedViewToEdit, setSavedViewToEdit] = useState<
LogSavedView | undefined
>(undefined);
const [savedViewToDelete, setSavedViewToDelete] = useState<
LogSavedView | undefined
>(undefined);
const [isSavedViewLoading, setIsSavedViewLoading] = useState<boolean>(false);
const [viewMode, setViewMode] = useState<LogsViewMode>("list");
const liveRequestInFlight: React.MutableRefObject<boolean> =
useRef<boolean>(false);
const hasAppliedInitialSavedView: React.MutableRefObject<boolean> =
useRef<boolean>(false);
// Histogram state
const [histogramBuckets, setHistogramBuckets] = useState<
@@ -196,8 +323,91 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
});
}, [props.serviceIds]);
// Extract attribute filters from logQuery for histogram/facets API calls
const logQueryAttributes: Record<string, string> | undefined = useMemo(() => {
if (!props.logQuery) {
return undefined;
}
const attributes: Record<string, string> | undefined = (
props.logQuery as any
).attributes as Record<string, string> | undefined;
if (!attributes || Object.keys(attributes).length === 0) {
return undefined;
}
return attributes;
}, [props.logQuery]);
const savedViewOptions: Array<LogsSavedViewOption> = useMemo(() => {
return [...savedViews]
.sort((left: LogSavedView, right: LogSavedView) => {
if (Boolean(left.isDefault) !== Boolean(right.isDefault)) {
return left.isDefault ? -1 : 1;
}
return (left.name || "").localeCompare(right.name || "");
})
.map((savedView: LogSavedView): LogsSavedViewOption => {
return {
id: savedView.id?.toString() || "",
name: savedView.name || "Untitled View",
isDefault: Boolean(savedView.isDefault),
};
});
}, [savedViews]);
const selectedSavedView: LogSavedView | undefined = useMemo(() => {
return savedViews.find((savedView: LogSavedView) => {
return savedView.id?.toString() === selectedSavedViewId;
});
}, [savedViews, selectedSavedViewId]);
// --- Fetch logs ---
const fetchSavedViews: () => Promise<void> =
useCallback(async (): Promise<void> => {
try {
setIsSavedViewLoading(true);
const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
if (!projectId) {
setSavedViews([]);
return;
}
const result: ModelListResult<LogSavedView> = await ModelAPI.getList({
modelType: LogSavedView,
query: {
projectId: projectId,
},
limit: SAVED_VIEWS_LIMIT,
skip: 0,
select: {
name: true,
query: true,
columns: true,
sortField: true,
sortOrder: true,
pageSize: true,
isDefault: true,
createdByUserId: true,
},
sort: {
name: SortOrder.Ascending,
},
});
setSavedViews(result.data);
} catch (err) {
setError(API.getFriendlyMessage(err));
} finally {
setIsSavedViewLoading(false);
}
}, []);
type FetchOptions = {
skipLoadingState?: boolean;
};
@@ -220,10 +430,32 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
}
try {
/*
* When live polling, recompute the time range so the query window
* slides forward to "now" and new logs become visible.
*/
let query: Query<Log> = filterOptions;
if (
skipLoadingState &&
isLiveEnabled &&
timeRange.range !== TimeRange.CUSTOM
) {
const freshRange: InBetween<Date> =
RangeStartAndEndDateTimeUtil.getStartAndEndDate(timeRange);
query = {
...filterOptions,
time: new InBetween<Date>(
freshRange.startValue,
freshRange.endValue,
),
};
}
const listResult: ListResult<Log> =
await AnalyticsModelAPI.getList<Log>({
modelType: Log,
query: filterOptions,
query: query,
limit: pageSize,
skip: (page - 1) * pageSize,
select: select,
@@ -236,6 +468,10 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
setLogs(listResult.data);
setTotalCount(listResult.count);
if (props.onCountChange) {
props.onCountChange(listResult.count);
}
const maximumPage: number = Math.max(
1,
Math.ceil(listResult.count / Math.max(pageSize, 1)),
@@ -255,7 +491,16 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
}
}
},
[filterOptions, page, pageSize, select, sortField, sortOrder],
[
filterOptions,
isLiveEnabled,
page,
pageSize,
select,
sortField,
sortOrder,
timeRange,
],
);
// --- Fetch histogram ---
@@ -308,6 +553,10 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
(requestData as any)["spanIds"] = Array.from(spanFilterValues);
}
if (logQueryAttributes) {
(requestData as any)["attributes"] = logQueryAttributes;
}
const response: HTTPResponse<JSONObject> = await postApi(
"/telemetry/logs/histogram",
requestData,
@@ -323,7 +572,7 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
} finally {
setHistogramLoading(false);
}
}, [serviceIdStrings, appliedFacetFilters, timeRange]);
}, [serviceIdStrings, appliedFacetFilters, timeRange, logQueryAttributes]);
// --- Fetch facets ---
@@ -346,6 +595,10 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
(requestData as any)["serviceIds"] = serviceIdStrings;
}
if (logQueryAttributes) {
(requestData as any)["attributes"] = logQueryAttributes;
}
const response: HTTPResponse<JSONObject> = await postApi(
"/telemetry/logs/facets",
requestData,
@@ -361,7 +614,51 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
} finally {
setFacetLoading(false);
}
}, [serviceIdStrings, timeRange]);
}, [serviceIdStrings, timeRange, logQueryAttributes]);
// --- Handlers (defined before effects that reference them) ---
const disableLiveMode: () => void = useCallback((): void => {
if (isLiveEnabled) {
setIsLiveEnabled(false);
liveRequestInFlight.current = false;
setIsLiveUpdating(false);
}
}, [isLiveEnabled]);
const applySavedView: (savedView: LogSavedView) => void = useCallback(
(savedView: LogSavedView): void => {
const baseQuery: Query<Log> = buildBaseQuery(props);
const rawQuery: JSONObject =
(savedView.query as unknown as JSONObject) || {};
const savedQuery: Query<Log> = (JSONFunctions.deserialize(
JSONFunctions.serialize(rawQuery),
) || {}) as Query<Log>;
const mergedQuery: Query<Log> = {
...(savedQuery as unknown as JSONObject),
...(baseQuery as unknown as JSONObject),
} as unknown as Query<Log>;
const nextTimeRange: RangeStartAndEndDateTime | undefined =
resolveSavedTimeRange(savedQuery);
if (nextTimeRange) {
setTimeRange(nextTimeRange);
}
setAppliedFacetFilters(
buildFacetFiltersFromQuery(mergedQuery, baseQuery),
);
setFilterOptions(mergedQuery);
setPage(1);
setPageSize(savedView.pageSize || DEFAULT_PAGE_SIZE);
setSortField((savedView.sortField as LogsSortField) || "time");
setSortOrder(savedView.sortOrder || SortOrder.Descending);
setSelectedColumns(normalizeLogsTableColumns(savedView.columns || []));
setSelectedSavedViewId(savedView.id?.toString() || null);
disableLiveMode();
},
[disableLiveMode, props],
);
// --- Effects ---
@@ -379,6 +676,46 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
void fetchFacets();
}, [fetchFacets]);
useEffect(() => {
void fetchSavedViews();
}, [fetchSavedViews]);
useEffect(() => {
LocalStorage.setItem(getColumnsStorageKey(props.id), selectedColumns);
}, [props.id, selectedColumns]);
useEffect(() => {
if (hasAppliedInitialSavedView.current || isSavedViewLoading) {
return;
}
hasAppliedInitialSavedView.current = true;
const defaultSavedView: LogSavedView | undefined = savedViews.find(
(savedView: LogSavedView) => {
return Boolean(savedView.isDefault);
},
);
if (defaultSavedView) {
applySavedView(defaultSavedView);
}
}, [applySavedView, isSavedViewLoading, savedViews]);
useEffect(() => {
if (!selectedSavedViewId) {
return;
}
const exists: boolean = savedViews.some((savedView: LogSavedView) => {
return savedView.id?.toString() === selectedSavedViewId;
});
if (!exists) {
setSelectedSavedViewId(null);
}
}, [savedViews, selectedSavedViewId]);
// Live polling
useEffect(() => {
if (
@@ -465,14 +802,6 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
[page, sortField, sortOrder],
);
const disableLiveMode: () => void = useCallback((): void => {
if (isLiveEnabled) {
setIsLiveEnabled(false);
liveRequestInFlight.current = false;
setIsLiveUpdating(false);
}
}, [isLiveEnabled]);
const handleFilterChanged: (newFilter: Query<Log>) => void = useCallback(
(newFilter: Query<Log>): void => {
setFilterOptions(newFilter);
@@ -761,6 +1090,68 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
[handleFacetInclude],
);
// Build read-only base filter chips from props (serviceIds, traceIds, spanIds, logQuery attributes)
const baseActiveFilters: Array<ActiveFilter> = useMemo(() => {
const filters: Array<ActiveFilter> = [];
if (props.serviceIds && props.serviceIds.length > 0) {
for (const serviceId of props.serviceIds) {
filters.push({
facetKey: "serviceId",
value: serviceId.toString(),
displayKey: "Service",
displayValue: serviceId.toString(),
readOnly: true,
});
}
}
if (props.traceIds && props.traceIds.length > 0) {
for (const traceId of props.traceIds) {
filters.push({
facetKey: "traceId",
value: traceId,
displayKey: "Trace",
displayValue: traceId,
readOnly: true,
});
}
}
if (props.spanIds && props.spanIds.length > 0) {
for (const spanId of props.spanIds) {
filters.push({
facetKey: "spanId",
value: spanId,
displayKey: "Span",
displayValue: spanId,
readOnly: true,
});
}
}
if (logQueryAttributes) {
const attributeDisplayNames: Record<string, string> = {
"resource.k8s.cluster.name": "Cluster",
"resource.k8s.pod.name": "Pod",
"resource.k8s.container.name": "Container",
"resource.k8s.namespace.name": "Namespace",
};
for (const [attrKey, attrValue] of Object.entries(logQueryAttributes)) {
filters.push({
facetKey: `attributes.${attrKey}`,
value: attrValue,
displayKey: attributeDisplayNames[attrKey] || attrKey,
displayValue: attrValue,
readOnly: true,
});
}
}
return filters;
}, [props.serviceIds, props.traceIds, props.spanIds, logQueryAttributes]);
// Build activeFilters array for UI display
const activeFilters: Array<ActiveFilter> = useMemo(() => {
const filters: Array<ActiveFilter> = [];
@@ -793,46 +1184,257 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
}
return (
<div id={props.id}>
<LogsViewer
isLoading={isLoading}
onFilterChanged={handleFilterChanged}
filterData={filterOptions}
logs={logs}
showFilters={props.showFilters}
noLogsMessage={props.noLogsMessage}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={handleSortChange}
liveOptions={{
isLive: isLiveEnabled,
onToggle: handleLiveToggle,
isDisabled: isLiveUpdating,
}}
getTraceRoute={getTraceRoute}
getSpanRoute={getSpanRoute}
histogramBuckets={histogramBuckets}
histogramLoading={histogramLoading}
onHistogramTimeRangeSelect={handleHistogramTimeRangeSelect}
facetData={facetData}
facetLoading={facetLoading}
onFacetInclude={handleFacetInclude}
onFacetExclude={handleFacetExclude}
showFacetSidebar={true}
activeFilters={activeFilters}
onRemoveFilter={handleRemoveFilter}
onClearAllFilters={handleClearAllFilters}
valueSuggestions={valueSuggestions}
onFieldValueSelect={handleFieldValueSelect}
timeRange={timeRange}
onTimeRangeChange={handleTimeRangeChange}
/>
</div>
<>
{showCreateSavedViewModal && (
<ModelFormModal<LogSavedView>
modelType={LogSavedView}
name="Save Log View"
title="Save Log View"
description="Save the current log explorer state as a reusable view."
onClose={() => {
setShowCreateSavedViewModal(false);
}}
submitButtonText="Save View"
onBeforeCreate={async (savedView: LogSavedView) => {
savedView.query = filterOptions;
savedView.columns = selectedColumns;
savedView.sortField = sortField;
savedView.sortOrder = sortOrder;
savedView.pageSize = pageSize;
return savedView;
}}
onSuccess={async (savedView: LogSavedView) => {
setShowCreateSavedViewModal(false);
await fetchSavedViews();
applySavedView(savedView);
}}
formProps={{
name: "Save Log View",
modelType: LogSavedView,
id: "save-log-view",
fields: [
{
field: {
name: true,
},
fieldType: FormFieldSchemaType.Text,
title: "Name",
description: "Choose a name for this saved log view.",
placeholder: "Errors in checkout",
required: true,
},
{
field: {
isDefault: true,
},
fieldType: FormFieldSchemaType.Checkbox,
title: "Set as default",
description: "Automatically apply this view when opening logs.",
required: false,
},
],
formType: FormType.Create,
}}
/>
)}
{savedViewToEdit && (
<ModelFormModal<LogSavedView>
modelType={LogSavedView}
modelIdToEdit={savedViewToEdit.id!}
name="Edit Log View"
title="Edit Log View"
description="Rename this saved view or change whether it loads by default."
onClose={() => {
setSavedViewToEdit(undefined);
}}
submitButtonText="Save Changes"
onSuccess={async () => {
setSavedViewToEdit(undefined);
await fetchSavedViews();
}}
formProps={{
name: "Edit Log View",
modelType: LogSavedView,
id: "edit-log-view",
fields: [
{
field: {
name: true,
},
fieldType: FormFieldSchemaType.Text,
title: "Name",
description: "Update the name of this saved view.",
placeholder: "Errors in checkout",
required: true,
},
{
field: {
isDefault: true,
},
fieldType: FormFieldSchemaType.Checkbox,
title: "Set as default",
description: "Automatically apply this view when opening logs.",
required: false,
},
],
formType: FormType.Update,
}}
/>
)}
{savedViewToDelete && (
<ConfirmModal
title={`Delete ${savedViewToDelete.name || "saved view"}`}
description={`Are you sure you want to delete ${savedViewToDelete.name || "this saved view"}?`}
isLoading={isSavedViewLoading}
submitButtonText="Delete"
submitButtonType={ButtonStyleType.DANGER}
onSubmit={async () => {
if (!savedViewToDelete.id) {
setSavedViewToDelete(undefined);
return;
}
setIsSavedViewLoading(true);
try {
await ModelAPI.deleteItem({
modelType: LogSavedView,
id: savedViewToDelete.id,
});
if (savedViewToDelete.id.toString() === selectedSavedViewId) {
setSelectedSavedViewId(null);
}
await fetchSavedViews();
setSavedViewToDelete(undefined);
} catch (err) {
setError(API.getFriendlyMessage(err));
} finally {
setIsSavedViewLoading(false);
}
}}
onClose={() => {
setSavedViewToDelete(undefined);
}}
/>
)}
<div id={props.id}>
<LogsViewer
isLoading={isLoading}
onFilterChanged={handleFilterChanged}
filterData={filterOptions}
logs={logs}
showFilters={props.showFilters}
noLogsMessage={props.noLogsMessage}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={handleSortChange}
liveOptions={{
isLive: isLiveEnabled,
onToggle: handleLiveToggle,
isDisabled: isLiveUpdating,
}}
getTraceRoute={getTraceRoute}
getSpanRoute={getSpanRoute}
histogramBuckets={histogramBuckets}
histogramLoading={histogramLoading}
onHistogramTimeRangeSelect={handleHistogramTimeRangeSelect}
facetData={facetData}
facetLoading={facetLoading}
onFacetInclude={handleFacetInclude}
onFacetExclude={handleFacetExclude}
showFacetSidebar={true}
activeFilters={activeFilters}
baseActiveFilters={baseActiveFilters}
onRemoveFilter={handleRemoveFilter}
onClearAllFilters={handleClearAllFilters}
valueSuggestions={valueSuggestions}
onFieldValueSelect={handleFieldValueSelect}
timeRange={timeRange}
onTimeRangeChange={handleTimeRangeChange}
onShowDocumentation={props.onShowDocumentation}
selectedColumns={selectedColumns}
onSelectedColumnsChange={(columns: Array<string>) => {
setSelectedColumns(normalizeLogsTableColumns(columns));
}}
savedViews={savedViewOptions}
selectedSavedViewId={selectedSavedViewId}
onSavedViewSelect={(viewId: string) => {
const savedView: LogSavedView | undefined = savedViews.find(
(item: LogSavedView) => {
return item.id?.toString() === viewId;
},
);
if (savedView) {
applySavedView(savedView);
}
}}
onCreateSavedView={() => {
setShowCreateSavedViewModal(true);
}}
onEditSavedView={(viewId: string) => {
const savedView: LogSavedView | undefined = savedViews.find(
(item: LogSavedView) => {
return item.id?.toString() === viewId;
},
);
setSavedViewToEdit(savedView);
}}
onDeleteSavedView={(viewId: string) => {
const savedView: LogSavedView | undefined = savedViews.find(
(item: LogSavedView) => {
return item.id?.toString() === viewId;
},
);
setSavedViewToDelete(savedView);
}}
viewMode={viewMode}
onViewModeChange={setViewMode}
analyticsServiceIds={serviceIdStrings}
projectId={ProjectUtil.getCurrentProjectId() || undefined}
analyticsAppliedFacetFilters={appliedFacetFilters}
onUpdateCurrentSavedView={async () => {
if (!selectedSavedView?.id) {
return;
}
setIsSavedViewLoading(true);
try {
await ModelAPI.updateById({
modelType: LogSavedView,
id: selectedSavedView.id,
data: JSONFunctions.serialize({
query: filterOptions,
columns: selectedColumns,
sortField: sortField,
sortOrder: sortOrder,
pageSize: pageSize,
} as JSONObject) as JSONObject,
});
await fetchSavedViews();
} catch (err) {
setError(API.getFriendlyMessage(err));
} finally {
setIsSavedViewLoading(false);
}
}}
/>
</div>
</>
);
};

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;
@@ -159,11 +158,79 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
});
}
// Determine chart type - use BAR for bar chart type, LINE for everything else
const chartType: ChartType =
queryConfig.chartType === MetricChartType.BAR
? ChartType.BAR
: ChartType.LINE;
let chartType: ChartType;
if (queryConfig.chartType === MetricChartType.BAR) {
chartType = ChartType.BAR;
} else if (queryConfig.chartType === MetricChartType.AREA) {
chartType = ChartType.AREA;
} else if (queryConfig.chartType === MetricChartType.LINE) {
chartType = ChartType.LINE;
} else {
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(),
@@ -173,6 +240,7 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
queryConfig.metricQueryData.filterData.metricName?.toString() ||
"",
description: queryConfig.metricAliasData?.description || "",
metricInfo,
props: {
data: chartSeries,
xAxis: {
@@ -192,28 +260,25 @@ 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) => {
const metricType: MetricType | undefined =
props.metricTypes.find((m: MetricType) => {
return (
m.name ===
queryConfig.metricQueryData.filterData.metricName
);
});
if (queryConfig.yAxisValueFormatter) {
return queryConfig.yAxisValueFormatter(value);
}
return `${value} ${queryConfig.metricAliasData?.legendUnit || metricType?.unit || ""}`;
return ValueFormatter.formatValue(value, unit);
},
precision: YAxisPrecision.NoDecimals,
max: "auto",
min: "auto",
},
},
curve: ChartCurve.LINEAR,
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,100 +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="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

@@ -17,6 +17,9 @@ import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType"
export interface ComponentProps {
serviceIds?: Array<ObjectID> | undefined;
onFetchSuccess?:
| ((data: Array<MetricType>, totalCount: number) => void)
| undefined;
}
const MetricsTable: FunctionComponent<ComponentProps> = (
@@ -40,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(
@@ -113,6 +116,7 @@ const MetricsTable: FunctionComponent<ComponentProps> = (
}}
showViewIdButton={false}
noItemsMessage={"No metrics found for this service."}
onFetchSuccess={props.onFetchSuccess}
showRefreshButton={true}
viewPageRoute={Navigation.getCurrentRoute()}
filters={[

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

@@ -1,13 +1,12 @@
import React, {
FunctionComponent,
ReactElement,
useCallback,
useEffect,
useState,
} from "react";
import ObjectID from "Common/Types/ObjectID";
import MonitorMetricTypeUtil from "Common/Utils/Monitor/MonitorMetricType";
import OneUptimeDate from "Common/Types/Date";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import MetricView from "../Metrics/MetricView";
import ProjectUtil from "Common/UI/Utils/Project";
import MonitorMetricType from "Common/Types/Monitor/MonitorMetricType";
@@ -29,6 +28,13 @@ import MetricQueryConfigData, {
ChartSeries,
} 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;
@@ -85,11 +91,9 @@ const MonitorMetricsElement: FunctionComponent<ComponentProps> = (
const monitorMetricTypesByMonitor: Array<MonitorMetricType> =
MonitorMetricTypeUtil.getMonitorMetricTypesByMonitorType(monitorType);
// set it to past 1 hour
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -1);
const startAndEndDate: InBetween<Date> = new InBetween(startDate, endDate);
const [timeRange, setTimeRange] = useState<RangeStartAndEndDateTime>({
range: TimeRange.PAST_ONE_HOUR,
});
type GetQueryConfigByMonitorMetricTypesFunction =
() => Array<MetricQueryConfigData>;
@@ -269,11 +273,27 @@ const MonitorMetricsElement: FunctionComponent<ComponentProps> = (
};
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
startAndEndDate: startAndEndDate,
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate({
range: TimeRange.PAST_ONE_HOUR,
}),
queryConfigs: getQueryConfigByMonitorMetricTypes(),
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,
};
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
@@ -287,10 +307,21 @@ const MonitorMetricsElement: FunctionComponent<ComponentProps> = (
}
return (
<div>
<Card
title="Monitor Metrics"
description="Performance metrics collected from this monitor."
rightElement={
<RangeStartAndEndDateView
dashboardStartAndEndDate={timeRange}
onChange={handleTimeRangeChange}
/>
}
>
<MetricView
data={metricViewData}
hideQueryElements={true}
hideStartAndEndDate={true}
hideCardInCharts={true}
onChange={(data: MetricViewData) => {
setMetricViewData({
...data,
@@ -299,7 +330,7 @@ const MonitorMetricsElement: FunctionComponent<ComponentProps> = (
});
}}
/>
</div>
</Card>
);
};

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,
),
@@ -144,6 +153,17 @@ const DashboardNavbar: FunctionComponent<ComponentProps> = (
iconColor: "indigo",
category: "Observability",
},
{
title: "Kubernetes",
description: "Monitor Kubernetes clusters.",
route: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTERS] as Route,
),
activeRoute: RouteMap[PageMap.KUBERNETES_CLUSTERS],
icon: IconProp.Kubernetes,
iconColor: "blue",
category: "Observability",
},
// Automation & Analytics
{
title: "Dashboards",

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

@@ -28,6 +28,7 @@ import Log from "Common/Models/AnalyticsModels/Log";
import Span, {
SpanEvent,
SpanEventType,
SpanLink,
} from "Common/Models/AnalyticsModels/Span";
import Service from "Common/Models/DatabaseModels/Service";
import React, { FunctionComponent, ReactElement, useEffect } from "react";
@@ -37,6 +38,11 @@ import ExceptionInstance from "Common/Models/AnalyticsModels/ExceptionInstance";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
import PageMap from "../../Utils/PageMap";
import Route from "Common/Types/API/Route";
import Link from "Common/UI/Components/Link/Link";
import CriticalPathUtil, {
SpanData,
SpanSelfTime,
} from "Common/Utils/Traces/CriticalPath";
export interface ComponentProps {
id: string;
@@ -45,6 +51,7 @@ export interface ComponentProps {
onClose: () => void;
telemetryService: Service;
divisibilityFactor: DivisibilityFactor;
allTraceSpans?: Span[];
}
const SpanViewer: FunctionComponent<ComponentProps> = (
@@ -76,7 +83,9 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
serviceId: true,
spanId: true,
traceId: true,
parentSpanId: true,
events: true,
links: true,
startTime: true,
endTime: true,
startTimeUnixNano: true,
@@ -169,6 +178,31 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
setIsLoading(false);
};
// Compute self-time for this span (must be before early returns to preserve hook order)
const selfTimeInfo: SpanSelfTime | null = React.useMemo(() => {
if (!span || !props.allTraceSpans || props.allTraceSpans.length === 0) {
return null;
}
const spanDataList: SpanData[] = props.allTraceSpans.map(
(s: Span): SpanData => {
return {
spanId: s.spanId!,
parentSpanId: s.parentSpanId || undefined,
startTimeUnixNano: s.startTimeUnixNano!,
endTimeUnixNano: s.endTimeUnixNano!,
durationUnixNano: s.durationUnixNano!,
serviceId: s.serviceId?.toString(),
name: s.name,
};
},
);
const selfTimes: Map<string, SpanSelfTime> =
CriticalPathUtil.computeSelfTimes(spanDataList);
return selfTimes.get(span.spanId!) || null;
}, [span, props.allTraceSpans]);
if (error) {
return <ErrorMessage message={error} />;
}
@@ -533,6 +567,83 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
);
};
const getLinksContentElement: GetReactElementFunction = (): ReactElement => {
if (!span) {
return <ErrorMessage message="Span not found" />;
}
const links: Array<SpanLink> | undefined = span.links;
if (!links || links.length === 0) {
return <ErrorMessage message="No linked spans found." />;
}
return (
<div className="space-y-2">
{links.map((link: SpanLink, index: number) => {
const traceRoute: Route = RouteUtil.populateRouteParams(
RouteMap[PageMap.TRACE_VIEW]!,
{
modelId: link.traceId,
},
);
const routeWithSpanId: Route = new Route(traceRoute.toString());
routeWithSpanId.addQueryParams({ spanId: link.spanId });
return (
<div
key={index}
className="rounded-md border border-gray-200 p-3 space-y-2"
>
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-gray-700">
Link {index + 1}
</div>
<Link
to={routeWithSpanId}
className="text-xs font-medium text-indigo-600 hover:text-indigo-700 hover:underline"
openInNewTab={true}
>
View Trace
</Link>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<div className="text-gray-500 font-medium">Trace ID</div>
<code className="text-gray-800 font-mono text-[11px] break-all">
{link.traceId}
</code>
</div>
<div>
<div className="text-gray-500 font-medium">Span ID</div>
<code className="text-gray-800 font-mono text-[11px] break-all">
{link.spanId}
</code>
</div>
</div>
{link.attributes && Object.keys(link.attributes).length > 0 ? (
<div>
<div className="text-xs text-gray-500 font-medium mb-1">
Attributes
</div>
<JSONTable
json={JSONFunctions.nestJson(
(link.attributes as any) || {},
)}
title="Link Attributes"
/>
</div>
) : (
<></>
)}
</div>
);
})}
</div>
);
};
const getBasicInfo: GetReactElementFunction = (): ReactElement => {
if (!span) {
return <ErrorMessage message="Span not found" />;
@@ -664,6 +775,33 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
);
},
},
...(selfTimeInfo
? [
{
key: "selfTime" as keyof Span,
title: "Self Time",
description:
"Time spent in this span excluding child span durations.",
fieldType: FieldType.Element,
getElement: () => {
return (
<div className="flex items-center space-x-2">
<span>
{SpanUtil.getSpanDurationAsString({
divisibilityFactor: props.divisibilityFactor,
spanDurationInUnixNano:
selfTimeInfo.selfTimeUnixNano,
})}
</span>
<span className="text-[10px] text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
{selfTimeInfo.selfTimePercent.toFixed(1)}% of span
</span>
</div>
);
},
},
]
: []),
{
key: "kind",
title: "Span Kind",
@@ -710,6 +848,12 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
return event.name === SpanEventType.Exception.toLowerCase();
}).length,
},
{
name: "Links",
children: getLinksContentElement(),
countBadge: span?.links?.length || 0,
tabType: TabType.Info,
},
]}
onTabChange={() => {}}
/>

View File

@@ -0,0 +1,398 @@
import SpanUtil from "../../Utils/SpanUtil";
import CriticalPathUtil, {
SpanData,
SpanSelfTime,
} from "Common/Utils/Traces/CriticalPath";
import Span from "Common/Models/AnalyticsModels/Span";
import Service from "Common/Models/DatabaseModels/Service";
import Color from "Common/Types/Color";
import { Black } from "Common/Types/BrandColors";
import React, { FunctionComponent, ReactElement } from "react";
export interface FlameGraphProps {
spans: Span[];
telemetryServices: Service[];
onSpanSelect?: (spanId: string) => void;
selectedSpanId: string | undefined;
}
interface FlameGraphNode {
span: Span;
children: FlameGraphNode[];
depth: number;
startTimeUnixNano: number;
endTimeUnixNano: number;
durationUnixNano: number;
selfTimeUnixNano: number;
serviceColor: Color;
serviceName: string;
}
const MIN_BLOCK_WIDTH_PX: number = 2;
const FlameGraph: FunctionComponent<FlameGraphProps> = (
props: FlameGraphProps,
): ReactElement => {
const { spans, telemetryServices, onSpanSelect, selectedSpanId } = props;
const [hoveredSpanId, setHoveredSpanId] = React.useState<string | null>(null);
const [focusedSpanId, setFocusedSpanId] = React.useState<string | null>(null);
const containerRef: React.RefObject<HTMLDivElement | null> =
React.useRef<HTMLDivElement>(null);
// Build span data for critical path utility
const spanDataList: SpanData[] = React.useMemo(() => {
return spans.map((span: Span): SpanData => {
return {
spanId: span.spanId!,
parentSpanId: span.parentSpanId || undefined,
startTimeUnixNano: span.startTimeUnixNano!,
endTimeUnixNano: span.endTimeUnixNano!,
durationUnixNano: span.durationUnixNano!,
serviceId: span.serviceId?.toString(),
name: span.name,
};
});
}, [spans]);
// Compute self-times
const selfTimes: Map<string, SpanSelfTime> = React.useMemo(() => {
return CriticalPathUtil.computeSelfTimes(spanDataList);
}, [spanDataList]);
// Build tree structure
const { rootNodes, traceStart, traceEnd } = React.useMemo(() => {
if (spans.length === 0) {
return { rootNodes: [], traceStart: 0, traceEnd: 0 };
}
const spanMap: Map<string, Span> = new Map();
const childrenMap: Map<string, Span[]> = new Map();
const allSpanIds: Set<string> = new Set();
let tStart: number = spans[0]!.startTimeUnixNano!;
let tEnd: number = spans[0]!.endTimeUnixNano!;
for (const span of spans) {
spanMap.set(span.spanId!, span);
allSpanIds.add(span.spanId!);
if (span.startTimeUnixNano! < tStart) {
tStart = span.startTimeUnixNano!;
}
if (span.endTimeUnixNano! > tEnd) {
tEnd = span.endTimeUnixNano!;
}
}
for (const span of spans) {
if (span.parentSpanId && allSpanIds.has(span.parentSpanId)) {
const children: Span[] = childrenMap.get(span.parentSpanId) || [];
children.push(span);
childrenMap.set(span.parentSpanId, children);
}
}
const getServiceInfo: (span: Span) => { color: Color; name: string } = (
span: Span,
): { color: Color; name: string } => {
const service: Service | undefined = telemetryServices.find(
(s: Service) => {
return s._id?.toString() === span.serviceId?.toString();
},
);
return {
color: (service?.serviceColor as Color) || Black,
name: service?.name || "Unknown",
};
};
const buildNode: (span: Span, depth: number) => FlameGraphNode = (
span: Span,
depth: number,
): FlameGraphNode => {
const children: Span[] = childrenMap.get(span.spanId!) || [];
const selfTime: SpanSelfTime | undefined = selfTimes.get(span.spanId!);
const serviceInfo: { color: Color; name: string } = getServiceInfo(span);
// Sort children by start time
children.sort((a: Span, b: Span) => {
return a.startTimeUnixNano! - b.startTimeUnixNano!;
});
return {
span,
children: children.map((child: Span) => {
return buildNode(child, depth + 1);
}),
depth,
startTimeUnixNano: span.startTimeUnixNano!,
endTimeUnixNano: span.endTimeUnixNano!,
durationUnixNano: span.durationUnixNano!,
selfTimeUnixNano: selfTime
? selfTime.selfTimeUnixNano
: span.durationUnixNano!,
serviceColor: serviceInfo.color,
serviceName: serviceInfo.name,
};
};
// Find root spans
const roots: Span[] = spans.filter((span: Span) => {
const p: string | undefined = span.parentSpanId;
if (!p || p.trim() === "") {
return true;
}
if (!allSpanIds.has(p)) {
return true;
}
return false;
});
const effectiveRoots: Span[] = roots.length > 0 ? roots : [spans[0]!];
return {
rootNodes: effectiveRoots.map((root: Span) => {
return buildNode(root, 0);
}),
traceStart: tStart,
traceEnd: tEnd,
};
}, [spans, telemetryServices, selfTimes]);
// Find max depth for height calculation
const maxDepth: number = React.useMemo(() => {
let max: number = 0;
const traverse: (node: FlameGraphNode) => void = (
node: FlameGraphNode,
): void => {
if (node.depth > max) {
max = node.depth;
}
for (const child of node.children) {
traverse(child);
}
};
for (const root of rootNodes) {
traverse(root);
}
return max;
}, [rootNodes]);
// Find the focused subtree range for zoom
const { viewStart, viewEnd } = React.useMemo(() => {
if (!focusedSpanId) {
return { viewStart: traceStart, viewEnd: traceEnd };
}
const findNode: (nodes: FlameGraphNode[]) => FlameGraphNode | null = (
nodes: FlameGraphNode[],
): FlameGraphNode | null => {
for (const node of nodes) {
if (node.span.spanId === focusedSpanId) {
return node;
}
const found: FlameGraphNode | null = findNode(node.children);
if (found) {
return found;
}
}
return null;
};
const focused: FlameGraphNode | null = findNode(rootNodes);
if (focused) {
return {
viewStart: focused.startTimeUnixNano,
viewEnd: focused.endTimeUnixNano,
};
}
return { viewStart: traceStart, viewEnd: traceEnd };
}, [focusedSpanId, rootNodes, traceStart, traceEnd]);
const totalDuration: number = viewEnd - viewStart;
const rowHeight: number = 24;
const chartHeight: number = (maxDepth + 1) * rowHeight + 8;
if (spans.length === 0) {
return (
<div className="p-8 text-center text-gray-500 text-sm">
No spans to display
</div>
);
}
const renderNode: (node: FlameGraphNode) => ReactElement | null = (
node: FlameGraphNode,
): ReactElement | null => {
// Calculate position relative to view
const nodeStart: number = Math.max(node.startTimeUnixNano, viewStart);
const nodeEnd: number = Math.min(node.endTimeUnixNano, viewEnd);
if (nodeEnd <= nodeStart) {
return null; // Not in view
}
const leftPercent: number =
totalDuration > 0 ? ((nodeStart - viewStart) / totalDuration) * 100 : 0;
const widthPercent: number =
totalDuration > 0 ? ((nodeEnd - nodeStart) / totalDuration) * 100 : 0;
const isHovered: boolean = hoveredSpanId === node.span.spanId;
const isSelected: boolean = selectedSpanId === node.span.spanId;
const isFocused: boolean = focusedSpanId === node.span.spanId;
const durationStr: string = SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: node.durationUnixNano,
divisibilityFactor: SpanUtil.getDivisibilityFactor(totalDuration),
});
const selfTimeStr: string = SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: node.selfTimeUnixNano,
divisibilityFactor: SpanUtil.getDivisibilityFactor(totalDuration),
});
const colorStr: string = String(node.serviceColor);
return (
<React.Fragment key={node.span.spanId}>
<div
className={`absolute cursor-pointer border border-white/30 transition-opacity overflow-hidden ${
isSelected
? "ring-2 ring-indigo-500 ring-offset-1 z-10"
: isHovered
? "ring-1 ring-gray-400 z-10"
: ""
} ${isFocused ? "ring-2 ring-amber-400 z-10" : ""}`}
style={{
left: `${leftPercent}%`,
width: `${Math.max(widthPercent, 0.1)}%`,
top: `${node.depth * rowHeight}px`,
height: `${rowHeight - 2}px`,
backgroundColor: colorStr,
opacity: isHovered || isSelected ? 1 : 0.85,
minWidth: `${MIN_BLOCK_WIDTH_PX}px`,
}}
onMouseEnter={() => {
setHoveredSpanId(node.span.spanId!);
}}
onMouseLeave={() => {
setHoveredSpanId(null);
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
if (onSpanSelect) {
onSpanSelect(node.span.spanId!);
}
}}
onDoubleClick={(e: React.MouseEvent) => {
e.stopPropagation();
setFocusedSpanId((prev: string | null) => {
return prev === node.span.spanId! ? null : node.span.spanId!;
});
}}
title={`${node.span.name} (${node.serviceName})\nDuration: ${durationStr}\nSelf Time: ${selfTimeStr}`}
>
{widthPercent > 3 ? (
<div className="px-1 text-[10px] font-medium text-white truncate leading-snug pt-0.5">
{node.span.name}
</div>
) : (
<></>
)}
</div>
{node.children.map((child: FlameGraphNode) => {
return renderNode(child);
})}
</React.Fragment>
);
};
const hoveredNode: FlameGraphNode | null = React.useMemo(() => {
if (!hoveredSpanId) {
return null;
}
const findNode: (nodes: FlameGraphNode[]) => FlameGraphNode | null = (
nodes: FlameGraphNode[],
): FlameGraphNode | null => {
for (const node of nodes) {
if (node.span.spanId === hoveredSpanId) {
return node;
}
const found: FlameGraphNode | null = findNode(node.children);
if (found) {
return found;
}
}
return null;
};
return findNode(rootNodes);
}, [hoveredSpanId, rootNodes]);
return (
<div className="flame-graph" ref={containerRef}>
{/* Controls */}
<div className="flex items-center justify-between mb-2 px-1">
<div className="text-[11px] text-gray-500">
Click a span to view details. Double-click to zoom into a subtree.
</div>
{focusedSpanId ? (
<button
type="button"
onClick={() => {
setFocusedSpanId(null);
}}
className="text-[11px] font-medium text-indigo-600 hover:text-indigo-700 hover:underline"
>
Reset Zoom
</button>
) : (
<></>
)}
</div>
{/* Flame graph */}
<div
className="relative overflow-hidden rounded border border-gray-200 bg-gray-50"
style={{ height: `${chartHeight}px` }}
>
{rootNodes.map((root: FlameGraphNode) => {
return renderNode(root);
})}
</div>
{/* Tooltip */}
{hoveredNode ? (
<div className="mt-2 px-3 py-2 rounded-md border border-gray-200 bg-white/90 text-xs space-y-1">
<div className="font-semibold text-gray-800">
{hoveredNode.span.name}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-gray-600">
<div>
<span className="font-medium text-gray-700">Service: </span>
{hoveredNode.serviceName}
</div>
<div>
<span className="font-medium text-gray-700">Duration: </span>
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: hoveredNode.durationUnixNano,
divisibilityFactor:
SpanUtil.getDivisibilityFactor(totalDuration),
})}
</div>
<div>
<span className="font-medium text-gray-700">Self Time: </span>
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: hoveredNode.selfTimeUnixNano,
divisibilityFactor:
SpanUtil.getDivisibilityFactor(totalDuration),
})}
</div>
</div>
</div>
) : (
<></>
)}
</div>
);
};
export default FlameGraph;

View File

@@ -1,17 +1,27 @@
import DashboardLogsViewer from "../Logs/LogsViewer";
import SpanStatusElement from "../Span/SpanStatusElement";
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,
IntervalUnit,
} from "../../Utils/SpanUtil";
import CriticalPathUtil, {
SpanData,
CriticalPathResult,
ServiceBreakdown,
} from "Common/Utils/Traces/CriticalPath";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import Color from "Common/Types/Color";
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import OneUptimeDate from "Common/Types/Date";
import BadDataException from "Common/Types/Exception/BadDataException";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import ObjectID from "Common/Types/ObjectID";
import Card from "Common/UI/Components/Card/Card";
@@ -33,6 +43,12 @@ import Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
import Service from "Common/Models/DatabaseModels/Service";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
enum TraceViewMode {
Waterfall = "Waterfall",
FlameGraph = "Flame Graph",
ServiceMap = "Service Map",
}
const INITIAL_SPAN_FETCH_SIZE: number = 500;
const SPAN_PAGE_SIZE: number = 500;
const MAX_SPAN_FETCH_BATCH: number = LIMIT_PER_PROJECT;
@@ -86,6 +102,12 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
// UI State Enhancements
const [showErrorsOnly, setShowErrorsOnly] = React.useState<boolean>(false);
const [viewMode, setViewMode] = React.useState<TraceViewMode>(
TraceViewMode.Waterfall,
);
const [spanSearchText, setSpanSearchText] = React.useState<string>("");
const [showCriticalPath, setShowCriticalPath] =
React.useState<boolean>(false);
const [traceId, setTraceId] = React.useState<string | null>(null);
@@ -329,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>
);
@@ -654,7 +692,7 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
}
}, [servicesInTrace, selectedServiceIds]);
// Final spans after applying filters
// Final spans after applying filters (including search)
const displaySpans: Span[] = React.useMemo(() => {
let filtered: Span[] = spans;
if (showErrorsOnly) {
@@ -669,8 +707,83 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
: false;
});
}
if (spanSearchText.trim().length > 0) {
const searchLower: string = spanSearchText.trim().toLowerCase();
filtered = filtered.filter((s: Span): boolean => {
// Match against span name
if (s.name?.toLowerCase().includes(searchLower)) {
return true;
}
// Match against span ID
if (s.spanId?.toLowerCase().includes(searchLower)) {
return true;
}
// Match against service name
const service: Service | undefined = telemetryServices.find(
(svc: Service) => {
return svc._id?.toString() === s.serviceId?.toString();
},
);
if (service?.name?.toLowerCase().includes(searchLower)) {
return true;
}
return false;
});
}
return filtered;
}, [spans, showErrorsOnly, selectedServiceIds]);
}, [
spans,
showErrorsOnly,
selectedServiceIds,
spanSearchText,
telemetryServices,
]);
// Search match count for display
const searchMatchCount: number = React.useMemo(() => {
if (spanSearchText.trim().length === 0) {
return 0;
}
return displaySpans.length;
}, [displaySpans, spanSearchText]);
// Critical path computation
const criticalPathResult: CriticalPathResult | null = React.useMemo(() => {
if (!showCriticalPath || spans.length === 0) {
return null;
}
const spanDataList: SpanData[] = spans.map((s: Span): SpanData => {
return {
spanId: s.spanId!,
parentSpanId: s.parentSpanId || undefined,
startTimeUnixNano: s.startTimeUnixNano!,
endTimeUnixNano: s.endTimeUnixNano!,
durationUnixNano: s.durationUnixNano!,
serviceId: s.serviceId?.toString(),
name: s.name,
};
});
return CriticalPathUtil.computeCriticalPath(spanDataList);
}, [showCriticalPath, spans]);
// Service latency breakdown
const serviceBreakdown: ServiceBreakdown[] = React.useMemo(() => {
if (spans.length === 0) {
return [];
}
const spanDataList: SpanData[] = spans.map((s: Span): SpanData => {
return {
spanId: s.spanId!,
parentSpanId: s.parentSpanId || undefined,
startTimeUnixNano: s.startTimeUnixNano!,
endTimeUnixNano: s.endTimeUnixNano!,
durationUnixNano: s.durationUnixNano!,
serviceId: s.serviceId?.toString(),
name: s.name,
};
});
return CriticalPathUtil.computeServiceBreakdown(spanDataList);
}, [spans]);
const spanStats: {
totalSpans: number;
@@ -846,12 +959,28 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
}),
);
const highlightableSpanIds: string[] = highlightSpanIds.filter(
// Combine highlight span IDs with critical path span IDs
let allHighlightSpanIds: string[] = highlightSpanIds.filter(
(spanId: string) => {
return displaySpanIds.has(spanId);
},
);
if (
criticalPathResult &&
criticalPathResult.criticalPathSpanIds.length > 0
) {
const criticalPathIds: string[] =
criticalPathResult.criticalPathSpanIds.filter((spanId: string) => {
return displaySpanIds.has(spanId);
});
allHighlightSpanIds = [
...new Set([...allHighlightSpanIds, ...criticalPathIds]),
];
}
const highlightableSpanIds: string[] = allHighlightSpanIds;
const ganttChart: GanttChartProps = {
id: "chart",
selectedBarIds: selectedSpans,
@@ -869,7 +998,7 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
};
setGanttChart(ganttChart);
}, [displaySpans, selectedSpans, highlightSpanIds]);
}, [displaySpans, selectedSpans, highlightSpanIds, criticalPathResult]);
if (isLoading && spans.length === 0) {
return <PageLoader isVisible={true} />;
@@ -1086,56 +1215,134 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
</div>
</div>
{/* Toolbar */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
<div className="flex items-center space-x-2">
<button
type="button"
onClick={() => {
return setShowErrorsOnly(false);
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all ${
!showErrorsOnly
? "bg-indigo-600 text-white border-indigo-600 shadow-sm"
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
}`}
>
All Spans
</button>
<button
type="button"
onClick={() => {
return setShowErrorsOnly(true);
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all flex items-center space-x-1 ${
showErrorsOnly
? "bg-red-600 text-white border-red-600 shadow-sm"
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
}`}
>
<span>Errors Only</span>
{spanStats.errorSpans > 0 ? (
<span className="text-[10px] bg-white/20 rounded px-1">
{spanStats.errorSpans}
</span>
{/* View Mode Toggle */}
<div className="flex flex-col gap-3 mb-4">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="flex items-center space-x-1 bg-gray-100 rounded-lg p-0.5">
{Object.values(TraceViewMode).map((mode: TraceViewMode) => {
return (
<button
key={mode}
type="button"
onClick={() => {
setViewMode(mode);
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md transition-all ${
viewMode === mode
? "bg-white text-gray-800 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{mode}
</button>
);
})}
</div>
{/* Search Bar */}
<div className="relative flex items-center">
<input
type="text"
placeholder="Search spans by name, ID, or service..."
value={spanSearchText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSpanSearchText(e.target.value);
}}
className="text-xs border border-gray-200 rounded-md px-3 py-1.5 w-64 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent placeholder-gray-400"
/>
{spanSearchText.length > 0 ? (
<div className="absolute right-2 flex items-center space-x-1">
<span className="text-[10px] text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
{searchMatchCount} of {spans.length}
</span>
<button
type="button"
onClick={() => {
setSpanSearchText("");
}}
className="text-gray-400 hover:text-gray-600 text-xs"
>
x
</button>
</div>
) : (
<></>
)}
</button>
</div>
</div>
<div className="flex items-center space-x-3 text-xs text-gray-500">
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-rose-500" />
<span>Error</span>
{/* Toolbar Row 2: Filters & Controls */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="flex items-center space-x-2">
<button
type="button"
onClick={() => {
return setShowErrorsOnly(false);
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all ${
!showErrorsOnly
? "bg-indigo-600 text-white border-indigo-600 shadow-sm"
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
}`}
>
All Spans
</button>
<button
type="button"
onClick={() => {
return setShowErrorsOnly(true);
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all flex items-center space-x-1 ${
showErrorsOnly
? "bg-red-600 text-white border-red-600 shadow-sm"
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
}`}
>
<span>Errors Only</span>
{spanStats.errorSpans > 0 ? (
<span className="text-[10px] bg-white/20 rounded px-1">
{spanStats.errorSpans}
</span>
) : (
<></>
)}
</button>
{/* Critical Path Toggle */}
{viewMode === TraceViewMode.Waterfall ? (
<button
type="button"
onClick={() => {
setShowCriticalPath((prev: boolean) => {
return !prev;
});
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all flex items-center space-x-1 ${
showCriticalPath
? "bg-amber-500 text-white border-amber-500 shadow-sm"
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
}`}
>
<span>Critical Path</span>
</button>
) : (
<></>
)}
</div>
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-emerald-500" />
<span>OK</span>
</div>
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<span>Other</span>
<div className="flex items-center space-x-3 text-xs text-gray-500">
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-rose-500" />
<span>Error</span>
</div>
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-emerald-500" />
<span>OK</span>
</div>
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<span>Other</span>
</div>
</div>
</div>
</div>
@@ -1236,13 +1443,124 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
<></>
)}
<div className="overflow-x-auto rounded-lg border border-gray-200">
{ganttChart ? (
<GanttChart chart={ganttChart} />
) : (
<div className="p-8">
<ErrorMessage message={"No spans found"} />
{/* Service Latency Breakdown */}
{serviceBreakdown.length > 1 ? (
<div className="mb-4 border border-gray-100 rounded-lg p-3 bg-gradient-to-br from-gray-50/60 to-white">
<div className="text-[11px] uppercase tracking-wide text-gray-500 font-medium mb-2">
Latency Breakdown by Service
</div>
<div className="space-y-1.5">
{serviceBreakdown.map((breakdown: ServiceBreakdown) => {
const service: Service | undefined = telemetryServices.find(
(s: Service) => {
return s._id?.toString() === breakdown.serviceId;
},
);
const serviceName: string = service?.name || "Unknown";
const serviceColor: string = String(
(service?.serviceColor as unknown as string) || "#6366f1",
);
const percent: number = Math.min(
breakdown.percentOfTrace,
100,
);
return (
<div
key={breakdown.serviceId}
className="flex items-center space-x-2"
>
<span
className="h-2.5 w-2.5 rounded-sm ring-1 ring-black/10 flex-shrink-0"
style={{
backgroundColor: serviceColor,
}}
/>
<span className="text-[11px] font-medium text-gray-700 w-24 truncate">
{serviceName}
</span>
<div className="flex-1 h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.max(percent, 1)}%`,
backgroundColor: serviceColor,
opacity: 0.7,
}}
/>
</div>
<span className="text-[10px] text-gray-500 w-20 text-right">
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: breakdown.selfTimeUnixNano,
divisibilityFactor: divisibilityFactor,
})}{" "}
({percent.toFixed(1)}%)
</span>
</div>
);
})}
</div>
</div>
) : (
<></>
)}
{/* Critical Path Info */}
{showCriticalPath && criticalPathResult ? (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
<span className="font-medium">Critical Path:</span>{" "}
{criticalPathResult.criticalPathSpanIds.length} spans,{" "}
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano:
criticalPathResult.criticalPathDurationUnixNano,
divisibilityFactor: divisibilityFactor,
})}{" "}
of{" "}
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano:
criticalPathResult.totalTraceDurationUnixNano,
divisibilityFactor: divisibilityFactor,
})}{" "}
total trace duration (highlighted in waterfall)
</div>
) : (
<></>
)}
{/* Main Visualization */}
<div className="overflow-x-auto rounded-lg border border-gray-200">
{viewMode === TraceViewMode.Waterfall ? (
<>
{ganttChart ? (
<GanttChart chart={ganttChart} />
) : (
<div className="p-8">
<ErrorMessage message={"No spans found"} />
</div>
)}
</>
) : viewMode === TraceViewMode.FlameGraph ? (
<div className="p-4">
<FlameGraph
spans={displaySpans}
telemetryServices={telemetryServices}
onSpanSelect={(spanId: string) => {
setSelectedSpans([spanId]);
}}
selectedSpanId={
selectedSpans.length > 0 ? selectedSpans[0] : undefined
}
/>
</div>
) : viewMode === TraceViewMode.ServiceMap ? (
<div className="p-4">
<TraceServiceMap
spans={displaySpans}
telemetryServices={telemetryServices}
/>
</div>
) : (
<></>
)}
</div>
</Card>
@@ -1261,43 +1579,50 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
<></>
)}
{selectedSpans.length > 0 ? (
<SideOver
title="View Span"
description="View the span details."
onClose={() => {
setSelectedSpans([]);
}}
size={SideOverSize.Large}
>
<SpanViewer
id={"span-viewer"}
openTelemetrySpanId={selectedSpans[0] as string}
traceStartTimeInUnixNano={spans[0]!.startTimeUnixNano!}
onClose={() => {
setSelectedSpans([]);
}}
telemetryService={
telemetryServices.find((service: Service) => {
const selectedSpan: Span | undefined = spans.find(
(span: Span) => {
return span.spanId?.toString() === selectedSpans[0]!;
},
);
{selectedSpans.length > 0 && spans.length > 0 ? (
(() => {
const selectedSpan: Span | undefined = spans.find((span: Span) => {
return span.spanId?.toString() === selectedSpans[0]!;
});
if (!selectedSpan) {
throw new BadDataException("Selected span not found");
}
if (!selectedSpan) {
return <></>;
}
return (
service._id?.toString() ===
selectedSpan.serviceId?.toString()
);
})!
}
divisibilityFactor={divisibilityFactor}
/>
</SideOver>
const telemetryService: Service | undefined =
telemetryServices.find((service: Service) => {
return (
service._id?.toString() === selectedSpan.serviceId?.toString()
);
});
if (!telemetryService) {
return <></>;
}
return (
<SideOver
title="View Span"
description="View the span details."
onClose={() => {
setSelectedSpans([]);
}}
size={SideOverSize.Large}
>
<SpanViewer
id={"span-viewer"}
openTelemetrySpanId={selectedSpans[0] as string}
traceStartTimeInUnixNano={spans[0]!.startTimeUnixNano!}
onClose={() => {
setSelectedSpans([]);
}}
telemetryService={telemetryService}
divisibilityFactor={divisibilityFactor}
allTraceSpans={spans}
/>
</SideOver>
);
})()
) : (
<></>
)}

View File

@@ -0,0 +1,398 @@
import SpanUtil, { DivisibilityFactor } from "../../Utils/SpanUtil";
import Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
import Service from "Common/Models/DatabaseModels/Service";
import React, { FunctionComponent, ReactElement } from "react";
export interface TraceServiceMapProps {
spans: Span[];
telemetryServices: Service[];
}
interface ServiceNode {
serviceId: string;
serviceName: string;
serviceColor: string;
spanCount: number;
errorCount: number;
totalDurationUnixNano: number;
}
interface ServiceEdge {
fromServiceId: string;
toServiceId: string;
callCount: number;
totalDurationUnixNano: number;
errorCount: number;
}
const TraceServiceMap: FunctionComponent<TraceServiceMapProps> = (
props: TraceServiceMapProps,
): ReactElement => {
const { spans, telemetryServices } = props;
// Build nodes and edges from spans
const { nodes, edges } = React.useMemo(() => {
const nodeMap: Map<string, ServiceNode> = new Map();
const edgeMap: Map<string, ServiceEdge> = new Map();
const spanServiceMap: Map<string, string> = new Map(); // spanId -> serviceId
// First pass: build span -> service mapping and service nodes
for (const span of spans) {
const serviceId: string = span.serviceId?.toString() || "unknown";
spanServiceMap.set(span.spanId!, serviceId);
const existing: ServiceNode | undefined = nodeMap.get(serviceId);
if (existing) {
existing.spanCount += 1;
existing.totalDurationUnixNano += span.durationUnixNano!;
if (span.statusCode === SpanStatus.Error) {
existing.errorCount += 1;
}
} else {
const service: Service | undefined = telemetryServices.find(
(s: Service) => {
return s._id?.toString() === serviceId;
},
);
nodeMap.set(serviceId, {
serviceId,
serviceName: service?.name || "Unknown",
serviceColor: String(
(service?.serviceColor as unknown as string) || "#6366f1",
),
spanCount: 1,
errorCount: span.statusCode === SpanStatus.Error ? 1 : 0,
totalDurationUnixNano: span.durationUnixNano!,
});
}
}
// Second pass: build edges from parent-child relationships
for (const span of spans) {
if (!span.parentSpanId) {
continue;
}
const parentServiceId: string | undefined = spanServiceMap.get(
span.parentSpanId,
);
const childServiceId: string = span.serviceId?.toString() || "unknown";
if (!parentServiceId || parentServiceId === childServiceId) {
continue; // Skip same-service calls
}
const edgeKey: string = `${parentServiceId}->${childServiceId}`;
const existing: ServiceEdge | undefined = edgeMap.get(edgeKey);
if (existing) {
existing.callCount += 1;
existing.totalDurationUnixNano += span.durationUnixNano!;
if (span.statusCode === SpanStatus.Error) {
existing.errorCount += 1;
}
} else {
edgeMap.set(edgeKey, {
fromServiceId: parentServiceId,
toServiceId: childServiceId,
callCount: 1,
totalDurationUnixNano: span.durationUnixNano!,
errorCount: span.statusCode === SpanStatus.Error ? 1 : 0,
});
}
}
return {
nodes: Array.from(nodeMap.values()),
edges: Array.from(edgeMap.values()),
};
}, [spans, telemetryServices]);
// Compute trace duration for context
const traceDuration: number = React.useMemo(() => {
if (spans.length === 0) {
return 0;
}
let minStart: number = spans[0]!.startTimeUnixNano!;
let maxEnd: number = spans[0]!.endTimeUnixNano!;
for (const span of spans) {
if (span.startTimeUnixNano! < minStart) {
minStart = span.startTimeUnixNano!;
}
if (span.endTimeUnixNano! > maxEnd) {
maxEnd = span.endTimeUnixNano!;
}
}
return maxEnd - minStart;
}, [spans]);
const divisibilityFactor: DivisibilityFactor =
SpanUtil.getDivisibilityFactor(traceDuration);
if (nodes.length === 0) {
return (
<div className="p-8 text-center text-gray-500 text-sm">
No services found in this trace
</div>
);
}
/*
* Layout: arrange nodes in a topological order based on edges
* Simple layout: find entry nodes and lay out left-to-right
*/
const { nodePositions, layoutWidth, layoutHeight } = React.useMemo(() => {
// Build adjacency list
const adjList: Map<string, string[]> = new Map();
const inDegree: Map<string, number> = new Map();
for (const node of nodes) {
adjList.set(node.serviceId, []);
inDegree.set(node.serviceId, 0);
}
for (const edge of edges) {
const neighbors: string[] = adjList.get(edge.fromServiceId) || [];
neighbors.push(edge.toServiceId);
adjList.set(edge.fromServiceId, neighbors);
inDegree.set(edge.toServiceId, (inDegree.get(edge.toServiceId) || 0) + 1);
}
// Topological sort using BFS (Kahn's algorithm)
const queue: string[] = [];
for (const [nodeId, degree] of inDegree.entries()) {
if (degree === 0) {
queue.push(nodeId);
}
}
const levels: Map<string, number> = new Map();
let level: number = 0;
const levelNodes: string[][] = [];
while (queue.length > 0) {
const levelSize: number = queue.length;
const currentLevel: string[] = [];
for (let i: number = 0; i < levelSize; i++) {
const nodeId: string = queue.shift()!;
levels.set(nodeId, level);
currentLevel.push(nodeId);
const neighbors: string[] = adjList.get(nodeId) || [];
for (const neighbor of neighbors) {
const newDegree: number = (inDegree.get(neighbor) || 1) - 1;
inDegree.set(neighbor, newDegree);
if (newDegree === 0) {
queue.push(neighbor);
}
}
}
levelNodes.push(currentLevel);
level++;
}
// Handle cycles - place unvisited nodes at the end
for (const node of nodes) {
if (!levels.has(node.serviceId)) {
if (levelNodes.length === 0) {
levelNodes.push([]);
}
levelNodes[levelNodes.length - 1]!.push(node.serviceId);
levels.set(node.serviceId, levelNodes.length - 1);
}
}
// Compute positions
const nodeWidth: number = 200;
const nodeHeight: number = 80;
const horizontalGap: number = 120;
const verticalGap: number = 40;
const positions: Map<string, { x: number; y: number }> = new Map();
let maxX: number = 0;
let maxY: number = 0;
for (let l: number = 0; l < levelNodes.length; l++) {
const levelNodeIds: string[] = levelNodes[l]!;
const x: number = l * (nodeWidth + horizontalGap) + 20;
for (let n: number = 0; n < levelNodeIds.length; n++) {
const y: number = n * (nodeHeight + verticalGap) + 20;
positions.set(levelNodeIds[n]!, { x, y });
if (x + nodeWidth > maxX) {
maxX = x + nodeWidth;
}
if (y + nodeHeight > maxY) {
maxY = y + nodeHeight;
}
}
}
return {
nodePositions: positions,
layoutWidth: maxX + 40,
layoutHeight: maxY + 40,
};
}, [nodes, edges]);
const nodeWidth: number = 200;
const nodeHeight: number = 80;
return (
<div className="trace-service-map">
<div className="text-[11px] text-gray-500 mb-2 px-1">
Service flow for this trace. Arrows show cross-service calls with count
and latency.
</div>
<div
className="relative overflow-auto rounded border border-gray-200 bg-gray-50"
style={{
minHeight: `${Math.max(layoutHeight, 200)}px`,
}}
>
<svg
width={layoutWidth}
height={layoutHeight}
className="absolute top-0 left-0"
>
{/* Render edges */}
{edges.map((edge: ServiceEdge) => {
const fromPos: { x: number; y: number } | undefined =
nodePositions.get(edge.fromServiceId);
const toPos: { x: number; y: number } | undefined =
nodePositions.get(edge.toServiceId);
if (!fromPos || !toPos) {
return null;
}
const x1: number = fromPos.x + nodeWidth;
const y1: number = fromPos.y + nodeHeight / 2;
const x2: number = toPos.x;
const y2: number = toPos.y + nodeHeight / 2;
const midX: number = (x1 + x2) / 2;
const hasError: boolean = edge.errorCount > 0;
const strokeColor: string = hasError ? "#ef4444" : "#9ca3af";
const avgDuration: number =
edge.callCount > 0
? edge.totalDurationUnixNano / edge.callCount
: 0;
const durationStr: string = SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: avgDuration,
divisibilityFactor: divisibilityFactor,
});
const edgeKey: string = `${edge.fromServiceId}->${edge.toServiceId}`;
return (
<g key={edgeKey}>
{/* Curved path */}
<path
d={`M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`}
fill="none"
stroke={strokeColor}
strokeWidth={Math.min(2 + edge.callCount * 0.5, 5)}
strokeDasharray={hasError ? "4,4" : "none"}
markerEnd="url(#arrowhead)"
/>
{/* Label */}
<text
x={midX}
y={(y1 + y2) / 2 - 8}
textAnchor="middle"
className="text-[10px] fill-gray-500"
>
{edge.callCount}x | avg {durationStr}
</text>
{hasError ? (
<text
x={midX}
y={(y1 + y2) / 2 + 6}
textAnchor="middle"
className="text-[9px] fill-red-500 font-medium"
>
{edge.errorCount} error{edge.errorCount > 1 ? "s" : ""}
</text>
) : (
<></>
)}
</g>
);
})}
{/* Arrow marker definition */}
<defs>
<marker
id="arrowhead"
markerWidth="8"
markerHeight="6"
refX="8"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill="#9ca3af" />
</marker>
</defs>
</svg>
{/* Render nodes */}
{nodes.map((node: ServiceNode) => {
const pos: { x: number; y: number } | undefined = nodePositions.get(
node.serviceId,
);
if (!pos) {
return null;
}
const hasErrors: boolean = node.errorCount > 0;
return (
<div
key={node.serviceId}
className={`absolute rounded-lg border-2 bg-white shadow-sm p-3 ${
hasErrors ? "border-red-300" : "border-gray-200"
}`}
style={{
left: `${pos.x}px`,
top: `${pos.y}px`,
width: `${nodeWidth}px`,
height: `${nodeHeight}px`,
}}
>
<div className="flex items-center space-x-2 mb-1">
<span
className="h-3 w-3 rounded-sm ring-1 ring-black/10 flex-shrink-0"
style={{ backgroundColor: node.serviceColor }}
/>
<span className="text-xs font-semibold text-gray-800 truncate">
{node.serviceName}
</span>
</div>
<div className="flex flex-wrap gap-x-3 text-[10px] text-gray-500">
<span>{node.spanCount} spans</span>
{hasErrors ? (
<span className="text-red-600 font-medium">
{node.errorCount} errors
</span>
) : (
<></>
)}
</div>
<div className="text-[10px] text-gray-400 mt-0.5">
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: node.totalDurationUnixNano,
divisibilityFactor: divisibilityFactor,
})}
</div>
</div>
);
})}
</div>
</div>
);
};
export default TraceServiceMap;

View File

@@ -44,6 +44,9 @@ export interface ComponentProps {
spanQuery?: Query<Span> | undefined;
isMinimalTable?: boolean | undefined;
noItemsMessage?: string | undefined;
onFetchSuccess?:
| ((data: Array<Span>, totalCount: number) => void)
| undefined;
}
const TraceTable: FunctionComponent<ComponentProps> = (
@@ -298,14 +301,22 @@ const TraceTable: FunctionComponent<ComponentProps> = (
noItemsMessage={
props.noItemsMessage ? props.noItemsMessage : "No spans found."
}
onFetchSuccess={props.onFetchSuccess}
showRefreshButton={true}
sortBy="startTime"
sortOrder={SortOrder.Descending}
onViewPage={(span: Span) => {
if (modelId) {
return Promise.resolve(
new Route(viewRoute.toString()).addRoute(
span.traceId!.toString(),
),
);
}
return Promise.resolve(
new Route(viewRoute.toString()).addRoute(
span.traceId!.toString(),
),
RouteUtil.populateRouteParams(RouteMap[PageMap.TRACE_VIEW]!, {
modelId: span.traceId!.toString(),
}),
);
}}
filters={[

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

@@ -0,0 +1,790 @@
import ProjectUtil from "Common/UI/Utils/Project";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import Label from "Common/Models/DatabaseModels/Label";
import Monitor from "Common/Models/DatabaseModels/Monitor";
import AlertSeverity from "Common/Models/DatabaseModels/AlertSeverity";
import AlertState from "Common/Models/DatabaseModels/AlertState";
import IncidentSeverity from "Common/Models/DatabaseModels/IncidentSeverity";
import IncidentState from "Common/Models/DatabaseModels/IncidentState";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
} from "react";
import WorkspaceType, {
getWorkspaceTypeDisplayName,
} from "Common/Types/Workspace/WorkspaceType";
import WorkspaceNotificationSummary from "Common/Models/DatabaseModels/WorkspaceNotificationSummary";
import WorkspaceNotificationSummaryType from "Common/Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryType";
import WorkspaceNotificationSummaryItem from "Common/Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryItem";
import NotificationRuleEventType from "Common/Types/Workspace/NotificationRules/EventType";
import NotificationRuleCondition from "Common/Types/Workspace/NotificationRules/NotificationRuleCondition";
import NotificationRuleConditions from "./NotificationRuleForm/NotificationRuleConditions";
import FilterCondition from "Common/Types/Filter/FilterCondition";
import API from "Common/Utils/API";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import ListResult from "Common/Types/BaseDatabase/ListResult";
import Exception from "Common/Types/Exception/Exception";
import { ErrorFunction, PromiseVoidFunction } from "Common/Types/FunctionTypes";
import ObjectID from "Common/Types/ObjectID";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import IconProp from "Common/Types/Icon/IconProp";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import EmptyResponseData from "Common/Types/API/EmptyResponse";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import URL from "Common/Types/API/URL";
import { APP_API_URL } from "Common/UI/Config";
import { ShowAs } from "Common/UI/Components/ModelTable/BaseModelTable";
import { ModalWidth } from "Common/UI/Components/Modal/Modal";
import RecurringFieldElement from "Common/UI/Components/Events/RecurringFieldElement";
import RecurringViewElement from "Common/UI/Components/Events/RecurringViewElement";
import Recurring from "Common/Types/Events/Recurring";
import FormValues from "Common/UI/Components/Forms/Types/FormValues";
import { CustomElementProps } from "Common/UI/Components/Forms/Types/Field";
import OneUptimeDate from "Common/Types/Date";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import CheckboxElement from "Common/UI/Components/Checkbox/Checkbox";
export interface ComponentProps {
workspaceType: WorkspaceType;
summaryType: WorkspaceNotificationSummaryType;
}
const WorkspaceSummaryTable: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [error, setError] = React.useState<string | undefined>(undefined);
// Dropdown data for filters
const [monitors, setMonitors] = React.useState<Array<Monitor>>([]);
const [labels, setLabels] = React.useState<Array<Label>>([]);
const [alertStates, setAlertStates] = React.useState<Array<AlertState>>([]);
const [alertSeverities, setAlertSeverities] = React.useState<
Array<AlertSeverity>
>([]);
const [incidentSeverities, setIncidentSeverities] = React.useState<
Array<IncidentSeverity>
>([]);
const [incidentStates, setIncidentStates] = React.useState<
Array<IncidentState>
>([]);
// Test modal state
const [showTestModal, setShowTestModal] = React.useState<boolean>(false);
const [isTestLoading, setIsTestLoading] = React.useState<boolean>(false);
const [testError, setTestError] = React.useState<string | undefined>(
undefined,
);
const [testSummary, setTestSummary] = React.useState<
WorkspaceNotificationSummary | undefined
>(undefined);
const [showTestSuccessModal, setShowTestSuccessModal] =
React.useState<boolean>(false);
// Map summary type to notification rule event type for filters
type GetEventTypeFunction = () => NotificationRuleEventType;
const getEventType: GetEventTypeFunction = (): NotificationRuleEventType => {
switch (props.summaryType) {
case WorkspaceNotificationSummaryType.Incident:
return NotificationRuleEventType.Incident;
case WorkspaceNotificationSummaryType.Alert:
return NotificationRuleEventType.Alert;
case WorkspaceNotificationSummaryType.IncidentEpisode:
return NotificationRuleEventType.IncidentEpisode;
case WorkspaceNotificationSummaryType.AlertEpisode:
return NotificationRuleEventType.AlertEpisode;
default:
return NotificationRuleEventType.Incident;
}
};
const eventType: NotificationRuleEventType = getEventType();
// Load dropdown data for filter conditions
const loadPage: PromiseVoidFunction = async (): Promise<void> => {
try {
setIsLoading(true);
setError(undefined);
const monitorsResult: ListResult<Monitor> = await ModelAPI.getList({
modelType: Monitor,
query: { projectId: ProjectUtil.getCurrentProjectId()! },
select: { name: true, _id: true },
skip: 0,
limit: LIMIT_PER_PROJECT,
sort: { name: SortOrder.Ascending },
});
setMonitors(monitorsResult.data);
const labelsResult: ListResult<Label> = await ModelAPI.getList({
modelType: Label,
query: { projectId: ProjectUtil.getCurrentProjectId()! },
select: { name: true, _id: true, color: true },
skip: 0,
limit: LIMIT_PER_PROJECT,
sort: { name: SortOrder.Ascending },
});
setLabels(labelsResult.data);
const alertStatesResult: ListResult<AlertState> = await ModelAPI.getList({
modelType: AlertState,
query: { projectId: ProjectUtil.getCurrentProjectId()! },
select: { name: true, _id: true, color: true },
skip: 0,
limit: LIMIT_PER_PROJECT,
sort: { name: SortOrder.Ascending },
});
setAlertStates(alertStatesResult.data);
const alertSevResult: ListResult<AlertSeverity> = await ModelAPI.getList({
modelType: AlertSeverity,
query: { projectId: ProjectUtil.getCurrentProjectId()! },
select: { name: true, _id: true, color: true },
skip: 0,
limit: LIMIT_PER_PROJECT,
sort: { name: SortOrder.Ascending },
});
setAlertSeverities(alertSevResult.data);
const incSevResult: ListResult<IncidentSeverity> = await ModelAPI.getList(
{
modelType: IncidentSeverity,
query: { projectId: ProjectUtil.getCurrentProjectId()! },
select: { name: true, _id: true, color: true },
skip: 0,
limit: LIMIT_PER_PROJECT,
sort: { name: SortOrder.Ascending },
},
);
setIncidentSeverities(incSevResult.data);
const incStatesResult: ListResult<IncidentState> = await ModelAPI.getList(
{
modelType: IncidentState,
query: { projectId: ProjectUtil.getCurrentProjectId()! },
select: { name: true, _id: true, color: true },
skip: 0,
limit: LIMIT_PER_PROJECT,
sort: { name: SortOrder.Ascending },
},
);
setIncidentStates(incStatesResult.data);
} catch (err) {
setError(API.getFriendlyErrorMessage(err as Exception));
}
setIsLoading(false);
};
useEffect(() => {
loadPage().catch((err: Exception) => {
setError(API.getFriendlyErrorMessage(err as Exception));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
type TestSummaryFunction = (summaryId: ObjectID) => Promise<void>;
const testSummaryFn: TestSummaryFunction = async (
summaryId: ObjectID,
): Promise<void> => {
try {
setIsTestLoading(true);
setTestError(undefined);
const response: HTTPResponse<EmptyResponseData> | HTTPErrorResponse =
await API.post({
url: URL.fromString(APP_API_URL.toString()).addRoute(
`/workspace-notification-summary/test/${summaryId.toString()}`,
),
data: {},
headers: ModelAPI.getCommonHeaders(),
});
if (response.isSuccess()) {
setIsTestLoading(false);
setShowTestModal(false);
setShowTestSuccessModal(true);
}
if (response instanceof HTTPErrorResponse) {
throw response;
}
setIsTestLoading(false);
} catch (err) {
setTestError(API.getFriendlyErrorMessage(err as Exception));
setIsTestLoading(false);
}
};
const allSummaryItems: Array<WorkspaceNotificationSummaryItem> =
Object.values(WorkspaceNotificationSummaryItem);
const typeLabel: string = props.summaryType;
return (
<Fragment>
<ModelTable<WorkspaceNotificationSummary>
modelType={WorkspaceNotificationSummary}
query={{
projectId: ProjectUtil.getCurrentProjectId()!,
summaryType: props.summaryType,
workspaceType: props.workspaceType,
}}
userPreferencesKey={`workspace-summary-table-${props.summaryType}-${props.workspaceType}`}
actionButtons={[
{
title: "Send Test Now",
buttonStyleType: ButtonStyleType.OUTLINE,
icon: IconProp.Play,
onClick: async (
item: WorkspaceNotificationSummary,
onCompleteAction: VoidFunction,
onError: ErrorFunction,
) => {
try {
setTestSummary(item);
setShowTestModal(true);
onCompleteAction();
} catch (err) {
onCompleteAction();
onError(err as Error);
}
},
},
]}
singularName={`${typeLabel} Summary`}
pluralName={`${typeLabel} Summaries`}
id={`workspace-summary-table-${props.summaryType}`}
name={`${typeLabel} Workspace Summaries`}
isDeleteable={true}
isEditable={true}
createEditModalWidth={ModalWidth.Large}
isCreateable={true}
cardProps={{
title: `${typeLabel} Summary - ${getWorkspaceTypeDisplayName(props.workspaceType)}`,
description: `Set up recurring ${typeLabel.toLowerCase()} summary reports posted to ${getWorkspaceTypeDisplayName(props.workspaceType)}. Each summary includes stats like total count, MTTA/MTTR, severity breakdown, and a list of ${typeLabel.toLowerCase()}s with links.`,
}}
showAs={ShowAs.List}
noItemsMessage={`No ${typeLabel.toLowerCase()} summary rules configured yet. Create one to start receiving periodic reports.`}
onBeforeCreate={(values: WorkspaceNotificationSummary) => {
values.summaryType = props.summaryType;
values.projectId = ProjectUtil.getCurrentProjectId()!;
values.workspaceType = props.workspaceType;
// Set nextSendAt based on sendFirstReportAt or recurringInterval
if (values.sendFirstReportAt) {
const firstReportDate: Date = new Date(
values.sendFirstReportAt as unknown as string,
);
if (
firstReportDate.getTime() >
OneUptimeDate.getCurrentDate().getTime()
) {
values.nextSendAt = firstReportDate;
} else {
values.nextSendAt = values.sendFirstReportAt;
}
} else if (values.recurringInterval) {
const recurring: Recurring = Recurring.fromJSON(
values.recurringInterval,
);
values.nextSendAt = Recurring.getNextDateInterval(
OneUptimeDate.getCurrentDate(),
recurring,
);
}
// Parse channel names from comma-separated string
if (values.channelNames && typeof values.channelNames === "string") {
values.channelNames = (values.channelNames as unknown as string)
.split(",")
.map((name: string) => {
return name.trim();
})
.filter((name: string) => {
return name.length > 0;
});
}
// Default to "All" if none selected
if (
!values.summaryItems ||
(Array.isArray(values.summaryItems) &&
values.summaryItems.length === 0)
) {
values.summaryItems = [WorkspaceNotificationSummaryItem.All];
}
if (values.isEnabled === undefined || values.isEnabled === null) {
values.isEnabled = true;
}
// Clean up empty filters
if (values.filters && Array.isArray(values.filters)) {
values.filters = values.filters.filter(
(f: NotificationRuleCondition) => {
if (!f.value) {
return false;
}
if (Array.isArray(f.value)) {
return f.value.length > 0;
}
// String-based conditions (e.g., title contains "X")
if (typeof f.value === "string") {
return f.value.trim().length > 0;
}
return true;
},
);
}
if (!values.filterCondition) {
values.filterCondition = FilterCondition.Any;
}
return Promise.resolve(values);
}}
onBeforeEdit={(values: WorkspaceNotificationSummary) => {
// Convert channelNames from JSON array to comma-separated string for the text input
if (values.channelNames && Array.isArray(values.channelNames)) {
values.channelNames = (values.channelNames as Array<string>).join(
", ",
) as unknown as Array<string>;
}
return Promise.resolve(values);
}}
formFields={[
{
field: {
name: true,
},
title: "Summary Name",
fieldType: FormFieldSchemaType.Text,
required: true,
stepId: "basic",
placeholder: `Weekly ${typeLabel} Summary`,
validation: {
minLength: 2,
},
},
{
field: {
description: true,
},
stepId: "basic",
title: "Description",
fieldType: FormFieldSchemaType.LongText,
required: false,
placeholder: `e.g., Weekly ${typeLabel.toLowerCase()} summary for the engineering team.`,
},
{
field: {
channelNames: true,
},
stepId: "basic",
title: "Channel Names",
description: `Enter one or more ${getWorkspaceTypeDisplayName(props.workspaceType)} channel names (comma-separated) where the summary will be posted.`,
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "#incidents-summary, #engineering",
},
...(props.workspaceType === WorkspaceType.MicrosoftTeams
? [
{
field: {
teamName: true,
},
stepId: "basic",
title: "Team Name",
description:
"The name of the Microsoft Teams team where the summary will be posted.",
fieldType: FormFieldSchemaType.Text,
required: false,
placeholder: "Engineering Team",
},
]
: []),
{
field: {
isEnabled: true,
},
stepId: "basic",
title: "Enabled",
description:
"When enabled, the summary will be sent automatically on the configured schedule.",
fieldType: FormFieldSchemaType.Toggle,
required: false,
},
{
field: {
recurringInterval: true,
},
title: "How Often",
description:
"Choose how frequently this summary should be posted (e.g., every 1 day, every 1 week).",
fieldType: FormFieldSchemaType.CustomComponent,
required: true,
stepId: "schedule",
getCustomElement: (
value: FormValues<WorkspaceNotificationSummary>,
elementProps: CustomElementProps,
): ReactElement => {
return (
<RecurringFieldElement
error={elementProps.error}
onChange={(recurring: Recurring) => {
if (elementProps.onChange) {
elementProps.onChange(recurring);
}
}}
initialValue={
value.recurringInterval &&
value.recurringInterval instanceof Recurring
? Recurring.fromJSON(value.recurringInterval as Recurring)
: undefined
}
/>
);
},
},
{
field: {
sendFirstReportAt: true,
},
title: "Send First Report At",
description:
"When should the first summary report be sent? Subsequent reports will follow the recurring interval from this date. If left empty, the first report will be sent after the recurring interval from now.",
fieldType: FormFieldSchemaType.DateTime,
required: false,
stepId: "schedule",
},
{
field: {
numberOfDaysOfData: true,
},
title: "Lookback Period (Days)",
description:
"How many days of data to include in each summary. For example, 7 means the summary will cover the last 7 days.",
fieldType: FormFieldSchemaType.Number,
required: true,
stepId: "schedule",
placeholder: "7",
},
{
field: {
summaryItems: true,
},
title: "What to Include",
description:
'Choose which sections appear in the summary. Select "All" to include everything, or pick specific sections.',
fieldType: FormFieldSchemaType.CustomComponent,
required: false,
stepId: "content",
getCustomElement: (
value: FormValues<WorkspaceNotificationSummary>,
elementProps: CustomElementProps,
): ReactElement => {
const currentItems: Array<WorkspaceNotificationSummaryItem> =
(value.summaryItems as Array<WorkspaceNotificationSummaryItem>) || [
WorkspaceNotificationSummaryItem.All,
];
const isAllSelected: boolean = currentItems.includes(
WorkspaceNotificationSummaryItem.All,
);
const individualItems: Array<WorkspaceNotificationSummaryItem> =
allSummaryItems.filter(
(item: WorkspaceNotificationSummaryItem) => {
return item !== WorkspaceNotificationSummaryItem.All;
},
);
return (
<div className="space-y-2">
<CheckboxElement
title="All"
value={isAllSelected}
onChange={(checked: boolean) => {
if (elementProps.onChange) {
if (checked) {
elementProps.onChange([
WorkspaceNotificationSummaryItem.All,
]);
} else {
elementProps.onChange([]);
}
}
}}
/>
<div className="ml-6 space-y-2">
{individualItems.map(
(item: WorkspaceNotificationSummaryItem) => {
return (
<CheckboxElement
key={item}
title={item}
disabled={isAllSelected}
value={isAllSelected || currentItems.includes(item)}
onChange={(checked: boolean) => {
if (elementProps.onChange) {
let newItems: Array<WorkspaceNotificationSummaryItem> =
currentItems.filter(
(i: WorkspaceNotificationSummaryItem) => {
return (
i !==
WorkspaceNotificationSummaryItem.All &&
i !== item
);
},
);
if (checked) {
newItems.push(item);
}
// If all individual items are selected, switch to "All"
if (
newItems.length === individualItems.length
) {
newItems = [
WorkspaceNotificationSummaryItem.All,
];
}
elementProps.onChange(newItems);
}
}}
/>
);
},
)}
</div>
</div>
);
},
},
{
field: {
filterCondition: true,
},
title: "Filter Condition",
description: `Choose whether ${typeLabel.toLowerCase()}s must match ALL filters or ANY filter. If no filters are added, the summary will include all ${typeLabel.toLowerCase()}s.`,
fieldType: FormFieldSchemaType.RadioButton,
required: false,
stepId: "filters",
radioButtonOptions: [
{
title: "Any",
value: FilterCondition.Any,
},
{
title: "All",
value: FilterCondition.All,
},
],
},
{
field: {
filters: true,
},
title: "Filter Conditions",
description: `Only include ${typeLabel.toLowerCase()}s that match these conditions. Leave empty to include all.`,
fieldType: FormFieldSchemaType.CustomComponent,
required: false,
stepId: "filters",
getCustomElement: (
value: FormValues<WorkspaceNotificationSummary>,
elementProps: CustomElementProps,
): ReactElement => {
return (
<NotificationRuleConditions
eventType={eventType}
monitors={monitors}
labels={labels}
alertStates={alertStates}
alertSeverities={alertSeverities}
incidentSeverities={incidentSeverities}
incidentStates={incidentStates}
scheduledMaintenanceStates={[]}
monitorStatus={[]}
onChange={(conditions: Array<NotificationRuleCondition>) => {
if (elementProps.onChange) {
elementProps.onChange(conditions);
}
}}
value={
(value.filters as
| Array<NotificationRuleCondition>
| undefined) || []
}
/>
);
},
},
]}
formSteps={[
{
title: "Basic Info",
id: "basic",
},
{
title: "Schedule",
id: "schedule",
},
{
title: "Content",
id: "content",
},
{
title: "Filters",
id: "filters",
},
]}
showRefreshButton={true}
filters={[
{
field: {
name: true,
},
type: FieldType.Text,
title: "Summary Name",
},
{
field: {
isEnabled: true,
},
type: FieldType.Boolean,
title: "Enabled",
},
]}
columns={[
{
field: {
name: true,
},
title: "Name",
type: FieldType.Text,
},
{
field: {
isEnabled: true,
},
title: "Enabled",
type: FieldType.Boolean,
},
{
field: {
recurringInterval: true,
},
title: "Frequency",
type: FieldType.Element,
getElement: (value: WorkspaceNotificationSummary): ReactElement => {
return (
<RecurringViewElement
value={value.recurringInterval as Recurring}
/>
);
},
},
{
field: {
sendFirstReportAt: true,
},
noValueMessage: "-",
title: "First Report",
type: FieldType.DateTime,
},
{
field: {
numberOfDaysOfData: true,
},
title: "Lookback",
type: FieldType.Element,
getElement: (value: WorkspaceNotificationSummary): ReactElement => {
return <span>{value.numberOfDaysOfData} days</span>;
},
},
{
field: {
lastSentAt: true,
},
noValueMessage: "Never",
title: "Last Sent",
type: FieldType.DateTime,
},
{
field: {
nextSendAt: true,
},
noValueMessage: "-",
title: "Next Send",
type: FieldType.DateTime,
},
]}
/>
{showTestModal && testSummary ? (
<ConfirmModal
title={`Send Test Summary Now`}
error={testError}
description={`This will send the "${testSummary.name}" summary to ${getWorkspaceTypeDisplayName(props.workspaceType)} right now. The summary will include data from the last ${testSummary.numberOfDaysOfData || 7} days. This will not affect the regular schedule.`}
submitButtonText={"Send Now"}
onClose={() => {
setShowTestModal(false);
setTestSummary(undefined);
setTestError(undefined);
}}
isLoading={isTestLoading}
onSubmit={async () => {
if (!testSummary.id) {
return;
}
await testSummaryFn(testSummary.id!);
}}
/>
) : (
<></>
)}
{showTestSuccessModal ? (
<ConfirmModal
title={testError ? `Test Failed` : `Summary Sent`}
error={testError}
description={
testError
? `The test summary could not be sent. Please check your channel names and workspace connection settings.`
: `The test summary was sent successfully. Check your ${getWorkspaceTypeDisplayName(props.workspaceType)} channel to see how it looks.`
}
submitButtonType={ButtonStyleType.NORMAL}
submitButtonText={"Close"}
onSubmit={async () => {
setShowTestSuccessModal(false);
setTestSummary(undefined);
setShowTestModal(false);
setTestError("");
}}
/>
) : (
<></>
)}
</Fragment>
);
};
export default WorkspaceSummaryTable;

View File

@@ -1,5 +1,5 @@
import PageMap from "../../../Utils/PageMap";
import RouteMap from "../../../Utils/RouteMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import PageComponentProps from "../../PageComponentProps";
import Route from "Common/Types/API/Route";
import ObjectID from "Common/Types/ObjectID";
@@ -127,7 +127,10 @@ const TeamView: FunctionComponent<PageComponentProps> = (): ReactElement => {
modelId={Navigation.getLastParamAsObjectID()}
onDeleteSuccess={() => {
Navigation.navigate(
RouteMap[PageMap.ALERTS_SETTINGS_NOTE_TEMPLATES] as Route,
RouteUtil.populateRouteParams(
RouteMap[PageMap.ALERTS_SETTINGS_NOTE_TEMPLATES] as Route,
{ modelId },
),
);
}}
/>

Some files were not shown because too many files have changed in this diff Show More