Skip to main content

enya_client/
demo.rs

1//! Demo metrics client for offline/showcase mode.
2//!
3//! Provides a `DemoMetricsClient` that implements `MetricsClient` with realistic
4//! mock data, enabling the editor to work without a real Prometheus connection.
5
6use poll_promise::Promise;
7use rustc_hash::{FxHashMap, FxHashSet};
8
9use crate::error::ClientError;
10use crate::now_unix_secs;
11use crate::prometheus::response::MetricLabels;
12use crate::request::QueryRequest;
13use crate::types::{MetricsBucket, MetricsGroup, ResultType};
14use crate::{
15    BackendInfo, HealthCheckResult, LabelsResult, MetricLabelsResult, MetricsClient, QueryResponse,
16    QueryResult,
17};
18
19/// A demo metric definition with its labels.
20#[derive(Debug, Clone)]
21struct DemoMetric {
22    /// Metric name (e.g., "http_requests_total")
23    name: String,
24    /// Category for grouping in UI (reserved for future use)
25    #[allow(dead_code)]
26    category: MetricCategory,
27    /// Label names this metric has
28    labels: Vec<String>,
29    /// Possible values for each label
30    label_values: FxHashMap<String, Vec<String>>,
31    /// Type of metric (affects data generation pattern)
32    metric_type: MetricType,
33}
34
35/// Category for organizing metrics.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum MetricCategory {
38    System,
39    Http,
40    Runtime,
41    Application,
42    Database,
43}
44
45/// Type of metric (affects data generation).
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum MetricType {
48    /// Monotonically increasing counter
49    Counter,
50    /// Fluctuating gauge value
51    Gauge,
52    /// Histogram quantile
53    Histogram,
54}
55
56impl DemoMetric {
57    fn new(name: &str, category: MetricCategory, metric_type: MetricType) -> Self {
58        Self {
59            name: name.to_string(),
60            category,
61            labels: Vec::new(),
62            label_values: FxHashMap::default(),
63            metric_type,
64        }
65    }
66
67    fn with_label(mut self, name: &str, values: &[&str]) -> Self {
68        self.labels.push(name.to_string());
69        self.label_values.insert(
70            name.to_string(),
71            values.iter().map(|s| (*s).to_string()).collect(),
72        );
73        self
74    }
75}
76
77/// Demo metrics client providing realistic mock data.
78///
79/// This client implements the `MetricsClient` trait with a predefined catalog
80/// of realistic Prometheus metrics. It generates time-series data with
81/// appropriate patterns for different metric types.
82///
83/// # Example
84///
85/// ```ignore
86/// use enya_client::demo::DemoMetricsClient;
87/// use enya_client::MetricsClient;
88///
89/// let client = DemoMetricsClient::new();
90/// let names = client.fetch_metric_names(&ctx);
91/// ```
92pub struct DemoMetricsClient {
93    /// Catalog of demo metrics
94    metrics: Vec<DemoMetric>,
95}
96
97impl Default for DemoMetricsClient {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103impl DemoMetricsClient {
104    /// Create a new demo client with the standard metrics catalog.
105    #[must_use]
106    pub fn new() -> Self {
107        Self {
108            metrics: build_metrics_catalog(),
109        }
110    }
111
112    /// Get all metric names.
113    fn metric_names(&self) -> Vec<String> {
114        self.metrics.iter().map(|m| m.name.clone()).collect()
115    }
116
117    /// Get all unique label names across all metrics.
118    fn all_label_names(&self) -> Vec<String> {
119        let mut labels: Vec<String> = self
120            .metrics
121            .iter()
122            .flat_map(|m| m.labels.iter().cloned())
123            .collect();
124        labels.sort();
125        labels.dedup();
126        labels
127    }
128
129    /// Get labels for a specific metric.
130    fn get_metric_labels(&self, metric_name: &str) -> Option<MetricLabels> {
131        self.metrics
132            .iter()
133            .find(|m| m.name == metric_name)
134            .map(|m| MetricLabels {
135                labels: m
136                    .label_values
137                    .iter()
138                    .map(|(k, v)| (k.clone(), v.clone()))
139                    .collect(),
140            })
141    }
142
143    /// Get a metric by name.
144    fn get_metric(&self, name: &str) -> Option<&DemoMetric> {
145        self.metrics.iter().find(|m| m.name == name)
146    }
147
148    /// Generate demo time-series data for a query.
149    fn generate_data(&self, request: &QueryRequest) -> QueryResponse {
150        let now_secs = now_unix_secs();
151        let end_secs = request
152            .end
153            .map(|ns| (ns / 1_000_000_000) as u64)
154            .unwrap_or(now_secs);
155        let start_secs = request
156            .start
157            .map(|ns| (ns / 1_000_000_000) as u64)
158            .unwrap_or(end_secs.saturating_sub(3600));
159
160        let step = request.step_secs.max(1);
161        let num_points = ((end_secs - start_secs) / step).min(1000) as usize;
162
163        // Get metric info for pattern generation
164        let metric = self.get_metric(&request.metric);
165        let metric_type = metric.map(|m| m.metric_type).unwrap_or(MetricType::Gauge);
166
167        // Generate label combinations for series
168        let series_labels = self.generate_series_labels(&request.metric);
169
170        let groups: Vec<MetricsGroup> = series_labels
171            .iter()
172            .enumerate()
173            .map(|(idx, labels)| {
174                let buckets = generate_buckets(
175                    start_secs,
176                    step,
177                    num_points,
178                    metric_type,
179                    idx,
180                    &request.query,
181                );
182
183                let group_str = labels
184                    .iter()
185                    .map(|(k, v)| format!("{k}=\"{v}\""))
186                    .collect::<Vec<_>>()
187                    .join(", ");
188
189                MetricsGroup {
190                    group: format!("{{{group_str}}}"),
191                    buckets,
192                }
193            })
194            .collect();
195
196        let start_ns = (start_secs as u128) * 1_000_000_000;
197        let end_ns = (end_secs as u128) * 1_000_000_000;
198        let granularity_ns = (step as u128) * 1_000_000_000;
199
200        QueryResponse {
201            metric: request.metric.clone(),
202            query: request.query.clone(),
203            parsed_agg: None,
204            parsed_filter: String::new(),
205            parsed_grouping: None,
206            parsed_time_range: None,
207            start: Some(start_ns),
208            end: Some(end_ns),
209            granularity_ns,
210            groups,
211            // Demo mode always generates range query (matrix) results
212            result_type: ResultType::Matrix,
213        }
214    }
215
216    /// Generate label combinations for series.
217    fn generate_series_labels(&self, metric_name: &str) -> Vec<FxHashMap<String, String>> {
218        let Some(metric) = self.get_metric(metric_name) else {
219            // Unknown metric - return single series with generic labels
220            let mut labels = FxHashMap::default();
221            labels.insert("env".to_string(), "prod".to_string());
222            labels.insert("host".to_string(), "server-1".to_string());
223            return vec![labels];
224        };
225
226        // Generate a few combinations based on first 2 labels
227        let mut combinations = Vec::new();
228
229        if metric.labels.is_empty() {
230            combinations.push(FxHashMap::default());
231        } else if metric.labels.len() == 1 {
232            let label = &metric.labels[0];
233            if let Some(values) = metric.label_values.get(label) {
234                for value in values.iter().take(4) {
235                    let mut map = FxHashMap::default();
236                    map.insert(label.clone(), value.clone());
237                    combinations.push(map);
238                }
239            }
240        } else {
241            // Take first 2 labels and create combinations
242            let label1 = &metric.labels[0];
243            let label2 = &metric.labels[1];
244            let values1 = metric.label_values.get(label1).cloned().unwrap_or_default();
245            let values2 = metric.label_values.get(label2).cloned().unwrap_or_default();
246
247            for v1 in values1.iter().take(2) {
248                for v2 in values2.iter().take(2) {
249                    let mut map = FxHashMap::default();
250                    map.insert(label1.clone(), v1.clone());
251                    map.insert(label2.clone(), v2.clone());
252                    combinations.push(map);
253                }
254            }
255        }
256
257        if combinations.is_empty() {
258            combinations.push(FxHashMap::default());
259        }
260
261        combinations
262    }
263}
264
265/// Generate time-series buckets with appropriate patterns.
266fn generate_buckets(
267    start_secs: u64,
268    step: u64,
269    num_points: usize,
270    metric_type: MetricType,
271    series_idx: usize,
272    query: &str,
273) -> Vec<MetricsBucket> {
274    // Use query hash for variety
275    let hash = query
276        .bytes()
277        .fold(0u64, |acc, b| acc.wrapping_add(u64::from(b)));
278    let series_offset = series_idx as f64 * 17.3;
279
280    (0..num_points)
281        .map(|i| {
282            let t = start_secs + (i as u64) * step;
283            let t_f = t as f64;
284
285            let value = match metric_type {
286                MetricType::Counter => {
287                    // Monotonically increasing with rate variations
288                    let base_rate = 100.0 + (hash % 200) as f64;
289                    let variation = (t_f / 300.0 + series_offset).sin() * 20.0;
290                    (i as f64) * (base_rate + variation) / 60.0
291                }
292                MetricType::Gauge => {
293                    // Fluctuating value with occasional spikes
294                    let base = 50.0 + (hash % 50) as f64 + series_offset.abs() % 30.0;
295                    let slow_wave = (t_f / 600.0 + series_offset).sin() * 15.0;
296                    let fast_wave = (t_f / 60.0 + series_offset * 2.0).sin() * 5.0;
297                    let spike = if (t_f / 1800.0 + series_offset).sin() > 0.95 {
298                        30.0
299                    } else {
300                        0.0
301                    };
302                    (base + slow_wave + fast_wave + spike).max(0.0)
303                }
304                MetricType::Histogram => {
305                    // Latency-like distribution (usually low, occasional highs)
306                    let base = 0.05 + (hash % 10) as f64 * 0.01;
307                    let jitter = (t_f * 7.0 + series_offset).sin().abs() * 0.02;
308                    let spike = if (t_f / 900.0 + series_offset).sin() > 0.9 {
309                        0.5
310                    } else {
311                        0.0
312                    };
313                    base + jitter + spike
314                }
315            };
316
317            let start_ns = (t as u128) * 1_000_000_000;
318            let end_ns = start_ns + (step as u128) * 1_000_000_000;
319            MetricsBucket {
320                start: start_ns,
321                end: end_ns,
322                value,
323                count: 1,
324            }
325        })
326        .collect()
327}
328
329impl MetricsClient for DemoMetricsClient {
330    fn query(&self, request: QueryRequest, _ctx: &egui::Context) -> Promise<QueryResult> {
331        let response = self.generate_data(&request);
332        Promise::from_ready(Ok(response))
333    }
334
335    fn fetch_label_names(&self, _ctx: &egui::Context) -> Promise<LabelsResult> {
336        Promise::from_ready(Ok(self.all_label_names()))
337    }
338
339    fn fetch_label_values(&self, label: &str, _ctx: &egui::Context) -> Promise<LabelsResult> {
340        // Collect all values for this label across all metrics
341        let values: Vec<String> = self
342            .metrics
343            .iter()
344            .filter_map(|m| m.label_values.get(label))
345            .flatten()
346            .cloned()
347            .collect::<FxHashSet<_>>()
348            .into_iter()
349            .collect();
350
351        Promise::from_ready(Ok(values))
352    }
353
354    fn fetch_metric_names(&self, _ctx: &egui::Context) -> Promise<LabelsResult> {
355        Promise::from_ready(Ok(self.metric_names()))
356    }
357
358    fn fetch_metric_labels(
359        &self,
360        metric: &str,
361        _ctx: &egui::Context,
362    ) -> Promise<MetricLabelsResult> {
363        match self.get_metric_labels(metric) {
364            Some(labels) => Promise::from_ready(Ok(labels)),
365            None => Promise::from_ready(Err(ClientError::BackendError {
366                status: 404,
367                message: format!("metric '{metric}' not found in demo catalog"),
368            })),
369        }
370    }
371
372    fn backend_type(&self) -> &'static str {
373        "demo"
374    }
375
376    fn health_check(&self, _ctx: &egui::Context) -> Promise<HealthCheckResult> {
377        // Demo mode is always "healthy"
378        Promise::from_ready(Ok(BackendInfo {
379            backend_type: "demo".to_string(),
380            version: "offline".to_string(),
381        }))
382    }
383}
384
385/// Build the standard demo metrics catalog.
386fn build_metrics_catalog() -> Vec<DemoMetric> {
387    vec![
388        // System metrics
389        DemoMetric::new(
390            "node_cpu_seconds_total",
391            MetricCategory::System,
392            MetricType::Counter,
393        )
394        .with_label("cpu", &["0", "1", "2", "3"])
395        .with_label("mode", &["user", "system", "idle", "iowait"]),
396        DemoMetric::new(
397            "node_memory_bytes",
398            MetricCategory::System,
399            MetricType::Gauge,
400        )
401        .with_label("type", &["used", "free", "cached", "buffers"]),
402        DemoMetric::new(
403            "node_disk_read_bytes_total",
404            MetricCategory::System,
405            MetricType::Counter,
406        )
407        .with_label("device", &["sda", "sdb", "nvme0n1"]),
408        DemoMetric::new(
409            "node_disk_write_bytes_total",
410            MetricCategory::System,
411            MetricType::Counter,
412        )
413        .with_label("device", &["sda", "sdb", "nvme0n1"]),
414        DemoMetric::new(
415            "node_network_receive_bytes_total",
416            MetricCategory::System,
417            MetricType::Counter,
418        )
419        .with_label("device", &["eth0", "eth1", "lo"]),
420        DemoMetric::new(
421            "node_network_transmit_bytes_total",
422            MetricCategory::System,
423            MetricType::Counter,
424        )
425        .with_label("device", &["eth0", "eth1", "lo"]),
426        DemoMetric::new("node_load1", MetricCategory::System, MetricType::Gauge),
427        DemoMetric::new("node_load5", MetricCategory::System, MetricType::Gauge),
428        DemoMetric::new("node_load15", MetricCategory::System, MetricType::Gauge),
429        // HTTP metrics
430        DemoMetric::new(
431            "http_requests_total",
432            MetricCategory::Http,
433            MetricType::Counter,
434        )
435        .with_label("method", &["GET", "POST", "PUT", "DELETE"])
436        .with_label(
437            "path",
438            &["/api/users", "/api/orders", "/api/products", "/health"],
439        )
440        .with_label("status_code", &["200", "201", "400", "404", "500"]),
441        DemoMetric::new(
442            "http_request_duration_seconds",
443            MetricCategory::Http,
444            MetricType::Histogram,
445        )
446        .with_label("method", &["GET", "POST", "PUT", "DELETE"])
447        .with_label("path", &["/api/users", "/api/orders", "/api/products"])
448        .with_label("quantile", &["0.5", "0.9", "0.99"]),
449        DemoMetric::new(
450            "http_requests_in_flight",
451            MetricCategory::Http,
452            MetricType::Gauge,
453        )
454        .with_label("service", &["api", "web", "worker"]),
455        DemoMetric::new(
456            "http_response_size_bytes",
457            MetricCategory::Http,
458            MetricType::Histogram,
459        )
460        .with_label("method", &["GET", "POST"])
461        .with_label("quantile", &["0.5", "0.9", "0.99"]),
462        // Tokio runtime metrics
463        DemoMetric::new(
464            "tokio_runtime_workers_count",
465            MetricCategory::Runtime,
466            MetricType::Gauge,
467        )
468        .with_label("runtime", &["main", "blocking"]),
469        DemoMetric::new(
470            "tokio_runtime_blocking_threads",
471            MetricCategory::Runtime,
472            MetricType::Gauge,
473        )
474        .with_label("runtime", &["main"]),
475        DemoMetric::new(
476            "tokio_tasks_spawned_total",
477            MetricCategory::Runtime,
478            MetricType::Counter,
479        )
480        .with_label("runtime", &["main", "blocking"]),
481        DemoMetric::new(
482            "tokio_task_poll_duration_seconds",
483            MetricCategory::Runtime,
484            MetricType::Histogram,
485        )
486        .with_label("quantile", &["0.5", "0.9", "0.99"]),
487        // Application metrics
488        DemoMetric::new(
489            "app_cache_hits_total",
490            MetricCategory::Application,
491            MetricType::Counter,
492        )
493        .with_label("cache", &["users", "sessions", "products"]),
494        DemoMetric::new(
495            "app_cache_misses_total",
496            MetricCategory::Application,
497            MetricType::Counter,
498        )
499        .with_label("cache", &["users", "sessions", "products"]),
500        DemoMetric::new(
501            "app_queue_depth",
502            MetricCategory::Application,
503            MetricType::Gauge,
504        )
505        .with_label("queue", &["orders", "notifications", "emails"]),
506        DemoMetric::new(
507            "app_active_users",
508            MetricCategory::Application,
509            MetricType::Gauge,
510        )
511        .with_label("env", &["prod", "staging"]),
512        // Database metrics
513        DemoMetric::new(
514            "db_connections_active",
515            MetricCategory::Database,
516            MetricType::Gauge,
517        )
518        .with_label("pool", &["primary", "replica"])
519        .with_label("database", &["users", "orders"]),
520        DemoMetric::new(
521            "db_connections_idle",
522            MetricCategory::Database,
523            MetricType::Gauge,
524        )
525        .with_label("pool", &["primary", "replica"]),
526        DemoMetric::new(
527            "db_query_duration_seconds",
528            MetricCategory::Database,
529            MetricType::Histogram,
530        )
531        .with_label("query_type", &["select", "insert", "update", "delete"])
532        .with_label("quantile", &["0.5", "0.9", "0.99"]),
533        DemoMetric::new(
534            "db_transactions_total",
535            MetricCategory::Database,
536            MetricType::Counter,
537        )
538        .with_label("status", &["commit", "rollback"]),
539    ]
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn test_demo_client_metric_names() {
548        let client = DemoMetricsClient::new();
549        let names = client.metric_names();
550        assert!(!names.is_empty());
551        assert!(names.contains(&"http_requests_total".to_string()));
552        assert!(names.contains(&"node_cpu_seconds_total".to_string()));
553    }
554
555    #[test]
556    fn test_demo_client_label_names() {
557        let client = DemoMetricsClient::new();
558        let labels = client.all_label_names();
559        assert!(labels.contains(&"method".to_string()));
560        assert!(labels.contains(&"status_code".to_string()));
561        assert!(labels.contains(&"cpu".to_string()));
562    }
563
564    #[test]
565    fn test_demo_client_metric_labels() {
566        let client = DemoMetricsClient::new();
567        let labels = client.get_metric_labels("http_requests_total");
568        assert!(labels.is_some());
569        let labels = labels.unwrap();
570        assert!(labels.labels.contains_key("method"));
571        assert!(labels.labels.contains_key("status_code"));
572    }
573
574    #[test]
575    fn test_demo_client_backend_type() {
576        let client = DemoMetricsClient::new();
577        assert_eq!(client.backend_type(), "demo");
578    }
579
580    #[test]
581    fn test_generate_buckets_counter() {
582        let buckets = generate_buckets(1000, 60, 10, MetricType::Counter, 0, "test");
583        assert_eq!(buckets.len(), 10);
584        // Counter should be monotonically increasing
585        for window in buckets.windows(2) {
586            assert!(window[1].value >= window[0].value);
587        }
588    }
589
590    #[test]
591    fn test_generate_buckets_gauge() {
592        let buckets = generate_buckets(1000, 60, 10, MetricType::Gauge, 0, "test");
593        assert_eq!(buckets.len(), 10);
594        // Gauge values should all be non-negative
595        for bucket in &buckets {
596            assert!(bucket.value >= 0.0);
597        }
598    }
599}