Skip to main content

unifly_api/session/
stats.rs

1// Session API statistics endpoints
2//
3// Historical reports (stat/report/) and DPI statistics (stat/sitedpi).
4// These endpoints return loosely-typed JSON because the field set varies
5// by report type, interval, and firmware version.
6
7use serde_json::json;
8use tracing::debug;
9
10use crate::error::Error;
11use crate::session::client::SessionClient;
12
13fn attrs_or_default(attrs: Option<&[String]>, default: &[&str]) -> serde_json::Value {
14    attrs.map_or_else(|| json!(default), |custom| json!(custom))
15}
16
17impl SessionClient {
18    /// Fetch site-level historical statistics.
19    ///
20    /// `POST /api/s/{site}/stat/report/{interval}.site`
21    ///
22    /// The `interval` parameter should be one of: `"5minutes"`, `"hourly"`, `"daily"`.
23    /// Returns loosely-typed JSON because the field set varies by report type.
24    pub async fn get_site_stats(
25        &self,
26        interval: &str,
27        start: Option<i64>,
28        end: Option<i64>,
29        attrs: Option<&[String]>,
30    ) -> Result<Vec<serde_json::Value>, Error> {
31        let path = format!("stat/report/{interval}.site");
32        let url = self.site_url(&path);
33        debug!(interval, ?start, ?end, "fetching site stats");
34
35        // The report endpoint requires a POST with attribute selection.
36        // Requesting common attributes; the API ignores unknown ones.
37        let mut body = json!({
38            "attrs": attrs_or_default(
39                attrs,
40                &["bytes", "num_sta", "time", "wlan-num_sta", "lan-num_sta"],
41            ),
42        });
43        if let Some(s) = start {
44            body["start"] = json!(s);
45        }
46        if let Some(e) = end {
47            body["end"] = json!(e);
48        }
49
50        self.post(url, &body).await
51    }
52
53    /// Fetch per-device historical statistics.
54    ///
55    /// `POST /api/s/{site}/stat/report/{interval}.device`
56    ///
57    /// If `macs` is provided, results are filtered to those devices.
58    pub async fn get_device_stats(
59        &self,
60        interval: &str,
61        macs: Option<&[String]>,
62        attrs: Option<&[String]>,
63    ) -> Result<Vec<serde_json::Value>, Error> {
64        let path = format!("stat/report/{interval}.device");
65        let url = self.site_url(&path);
66        debug!(interval, "fetching device stats");
67
68        let mut body = json!({
69            "attrs": attrs_or_default(attrs, &["bytes", "num_sta", "time", "rx_bytes", "tx_bytes"]),
70        });
71        if let Some(m) = macs {
72            body["macs"] = json!(m);
73        }
74
75        self.post(url, &body).await
76    }
77
78    /// Fetch per-client historical statistics.
79    ///
80    /// `POST /api/s/{site}/stat/report/{interval}.user`
81    ///
82    /// If `macs` is provided, results are filtered to those clients.
83    pub async fn get_client_stats(
84        &self,
85        interval: &str,
86        macs: Option<&[String]>,
87        attrs: Option<&[String]>,
88    ) -> Result<Vec<serde_json::Value>, Error> {
89        let path = format!("stat/report/{interval}.user");
90        let url = self.site_url(&path);
91        debug!(interval, "fetching client stats");
92
93        let mut body = json!({
94            "attrs": attrs_or_default(attrs, &["bytes", "time", "rx_bytes", "tx_bytes"]),
95        });
96        if let Some(m) = macs {
97            body["macs"] = json!(m);
98        }
99
100        self.post(url, &body).await
101    }
102
103    /// Fetch gateway historical statistics.
104    ///
105    /// `POST /api/s/{site}/stat/report/{interval}.gw`
106    pub async fn get_gateway_stats(
107        &self,
108        interval: &str,
109        start: Option<i64>,
110        end: Option<i64>,
111        attrs: Option<&[String]>,
112    ) -> Result<Vec<serde_json::Value>, Error> {
113        let path = format!("stat/report/{interval}.gw");
114        let url = self.site_url(&path);
115        debug!(interval, ?start, ?end, "fetching gateway stats");
116
117        let mut body = json!({
118            "attrs": attrs_or_default(
119                attrs,
120                &[
121                    "bytes",
122                    "time",
123                    "wan-tx_bytes",
124                    "wan-rx_bytes",
125                    "lan-rx_bytes",
126                    "lan-tx_bytes",
127                ],
128            ),
129        });
130        if let Some(s) = start {
131            body["start"] = json!(s);
132        }
133        if let Some(e) = end {
134            body["end"] = json!(e);
135        }
136
137        self.post(url, &body).await
138    }
139
140    /// Fetch DPI (Deep Packet Inspection) statistics.
141    ///
142    /// Tries multiple session DPI endpoints for compatibility across firmware
143    /// versions:
144    /// 1. `stat/stadpi` with MAC filter — when `macs` is provided
145    /// 2. `stat/sitedpi` with type filter — site-level aggregated stats
146    /// 3. `stat/dpi` (unfiltered GET) — fallback for firmware that only
147    ///    populates this endpoint
148    ///
149    /// The `group_by` parameter selects the DPI grouping: `"by_app"` or `"by_cat"`.
150    /// Returns empty data if DPI tracking is not enabled on the controller.
151    pub async fn get_dpi_stats(
152        &self,
153        group_by: &str,
154        macs: Option<&[String]>,
155    ) -> Result<Vec<serde_json::Value>, Error> {
156        // Per-station endpoint when filtering by MAC addresses.
157        if let Some(m) = macs {
158            let url = self.site_url("stat/stadpi");
159            debug!(group_by, "fetching station DPI stats (filtered)");
160            let body = json!({"type": group_by, "macs": m});
161            return self.post(url, &body).await;
162        }
163
164        // Try v2 flow statistics endpoint first (Network Application 9+).
165        let v2_url = self.site_url_v2("traffic-flow-latest-statistics?period=DAY&top=30");
166        debug!("fetching v2 traffic flow statistics");
167        match self.get_raw(v2_url).await {
168            Ok(v2_data) => {
169                if v2_data
170                    .get("top_all_traffic_by_application")
171                    .and_then(|a| a.as_array())
172                    .is_some_and(|a| !a.is_empty())
173                {
174                    debug!("v2 traffic flow stats received");
175                    return Ok(vec![v2_data]);
176                }
177                debug!("v2 response had no DPI app data, trying session");
178            }
179            Err(e) => {
180                debug!("v2 traffic flow stats unavailable, trying session: {e}");
181            }
182        }
183
184        // Session: site-level filtered endpoint.
185        let url = self.site_url("stat/sitedpi");
186        debug!(group_by, "fetching site DPI stats");
187        let result: Vec<serde_json::Value> = self.post(url, &json!({"type": group_by})).await?;
188        if !result.is_empty() && result.iter().any(|v| v.get(group_by).is_some()) {
189            return Ok(result);
190        }
191
192        // Session fallback: unfiltered DPI endpoint.
193        let url = self.site_url("stat/dpi");
194        debug!("falling back to unfiltered DPI stats");
195        self.get(url).await
196    }
197}