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