unifi-cli 0.2.2__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/.github/workflows/release.yml +10 -3
- unifi_cli-0.3.0/.gitignore +2 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/CHANGELOG.md +14 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/Cargo.lock +1 -1
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/Cargo.toml +1 -1
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/PKG-INFO +4 -2
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/README.md +3 -1
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/api/client.rs +23 -4
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/api/types.rs +11 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/commands/clients.rs +251 -49
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/commands/devices.rs +5 -5
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/commands/events.rs +5 -5
- unifi_cli-0.3.0/src/fields.rs +241 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/lib.rs +1 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/main.rs +130 -13
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/schema.rs +7 -74
- unifi_cli-0.3.0/tests/cli_contract.rs +247 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/tests/mock_server.rs +109 -1
- unifi_cli-0.2.2/.gitignore +0 -1
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/.github/workflows/ci.yml +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/LICENSE +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/Makefile +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/prek.toml +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/pyproject.toml +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/api/mod.rs +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/api/tests.rs +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/commands/mod.rs +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/commands/networks.rs +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/commands/protect.rs +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/commands/system.rs +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/output.rs +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/src/tui.rs +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/tests/fixtures/clispec-v0.2.json +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/tests/spec_compliance.rs +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/unifi_cli/__init__.py +0 -0
- {unifi_cli-0.2.2 → unifi_cli-0.3.0}/unifi_cli/__main__.py +0 -0
|
@@ -332,7 +332,7 @@ jobs:
|
|
|
332
332
|
|
|
333
333
|
- name: Update Homebrew formula
|
|
334
334
|
env:
|
|
335
|
-
|
|
335
|
+
HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
|
|
336
336
|
run: |
|
|
337
337
|
VERSION="${{ github.ref_name }}"
|
|
338
338
|
VERSION_NUM="${VERSION#v}"
|
|
@@ -381,8 +381,15 @@ jobs:
|
|
|
381
381
|
sed -i "s/SHA_AARCH64_UNKNOWN_LINUX_GNU/${{ steps.hashes.outputs.aarch64_unknown_linux_gnu }}/g" /tmp/unifi-cli.rb
|
|
382
382
|
sed -i "s/SHA_X86_64_UNKNOWN_LINUX_GNU/${{ steps.hashes.outputs.x86_64_unknown_linux_gnu }}/g" /tmp/unifi-cli.rb
|
|
383
383
|
|
|
384
|
-
#
|
|
385
|
-
|
|
384
|
+
# Authenticate to the tap with a write deploy key scoped to
|
|
385
|
+
# rvben/homebrew-tap. The key does not expire, so cross-repo pushes
|
|
386
|
+
# keep working without periodic token rotation.
|
|
387
|
+
mkdir -p ~/.ssh
|
|
388
|
+
echo "$HOMEBREW_TAP_DEPLOY_KEY" > ~/.ssh/id_ed25519
|
|
389
|
+
chmod 600 ~/.ssh/id_ed25519
|
|
390
|
+
ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null
|
|
391
|
+
|
|
392
|
+
git clone git@github.com:rvben/homebrew-tap.git /tmp/tap
|
|
386
393
|
mkdir -p /tmp/tap/Formula
|
|
387
394
|
cp /tmp/unifi-cli.rb /tmp/tap/Formula/unifi-cli.rb
|
|
388
395
|
cd /tmp/tap
|
|
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## [0.3.0](https://github.com/rvben/unifi-cli/compare/v0.2.3...v0.3.0) - 2026-07-09
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **clients**: stop three silent-failure modes in the agent contract ([5e50bb7](https://github.com/rvben/unifi-cli/commit/5e50bb704777407acf244856bc435f7ceccd7b00))
|
|
14
|
+
|
|
15
|
+
## [0.2.3](https://github.com/rvben/unifi-cli/compare/v0.2.2...v0.2.3) - 2026-07-07
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **events**: fall back to rest/alarm when stat/event is 404 on UniFi OS 9+ ([ac8a69c](https://github.com/rvben/unifi-cli/commit/ac8a69c743c5eab05def7bcd67e08e5b060275ea))
|
|
20
|
+
|
|
7
21
|
## [0.2.2](https://github.com/rvben/unifi-cli/compare/v0.2.1...v0.2.2) - 2026-06-11
|
|
8
22
|
|
|
9
23
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Environment :: Console
|
|
6
6
|
Classifier: Intended Audience :: System Administrators
|
|
@@ -151,6 +151,7 @@ unifi clients list # List connected clients
|
|
|
151
151
|
unifi clients list --wired # Wired clients only
|
|
152
152
|
unifi clients list --wireless --name tasmota # Filter by type and name
|
|
153
153
|
unifi clients list --watch # Auto-refresh
|
|
154
|
+
unifi clients list --fields name,ssid,ip # Project specific fields (see `unifi schema`)
|
|
154
155
|
unifi clients show aa:bb:cc:dd:ee:ff # Show client details
|
|
155
156
|
unifi clients top # Top clients by bandwidth
|
|
156
157
|
unifi clients block aa:bb:cc:dd:ee:ff # Block a client
|
|
@@ -182,7 +183,8 @@ unifi events list --limit 50 # Last 50 events
|
|
|
182
183
|
### Networks
|
|
183
184
|
|
|
184
185
|
```bash
|
|
185
|
-
unifi networks
|
|
186
|
+
unifi networks list # List all networks
|
|
187
|
+
unifi networks # Same thing
|
|
186
188
|
```
|
|
187
189
|
|
|
188
190
|
### System
|
|
@@ -130,6 +130,7 @@ unifi clients list # List connected clients
|
|
|
130
130
|
unifi clients list --wired # Wired clients only
|
|
131
131
|
unifi clients list --wireless --name tasmota # Filter by type and name
|
|
132
132
|
unifi clients list --watch # Auto-refresh
|
|
133
|
+
unifi clients list --fields name,ssid,ip # Project specific fields (see `unifi schema`)
|
|
133
134
|
unifi clients show aa:bb:cc:dd:ee:ff # Show client details
|
|
134
135
|
unifi clients top # Top clients by bandwidth
|
|
135
136
|
unifi clients block aa:bb:cc:dd:ee:ff # Block a client
|
|
@@ -161,7 +162,8 @@ unifi events list --limit 50 # Last 50 events
|
|
|
161
162
|
### Networks
|
|
162
163
|
|
|
163
164
|
```bash
|
|
164
|
-
unifi networks
|
|
165
|
+
unifi networks list # List all networks
|
|
166
|
+
unifi networks # Same thing
|
|
165
167
|
```
|
|
166
168
|
|
|
167
169
|
### System
|
|
@@ -367,11 +367,30 @@ impl UnifiClient {
|
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
// Events
|
|
370
|
+
//
|
|
371
|
+
// Legacy `stat/event` was removed in UniFi Network 9+ (UniFi OS) and now
|
|
372
|
+
// returns api.err.NotFound (404). On those controllers the surviving REST
|
|
373
|
+
// surface for notable events is `rest/alarm`, whose records share this
|
|
374
|
+
// `Event` shape, so fall back to it. (The full live event stream on newer
|
|
375
|
+
// controllers is only exposed over the events WebSocket, which this REST
|
|
376
|
+
// client does not consume.)
|
|
370
377
|
pub async fn list_events(&self, limit: usize) -> Result<Vec<Event>, ApiError> {
|
|
371
|
-
|
|
372
|
-
.get_legacy(&format!("/stat/event?_limit={limit}"))
|
|
373
|
-
.await
|
|
374
|
-
|
|
378
|
+
match self
|
|
379
|
+
.get_legacy::<Event>(&format!("/stat/event?_limit={limit}"))
|
|
380
|
+
.await
|
|
381
|
+
{
|
|
382
|
+
Ok(events) => Ok(events),
|
|
383
|
+
Err(ApiError::NotFound(_)) => {
|
|
384
|
+
let mut alarms: Vec<Event> = self.get_legacy("/rest/alarm").await?;
|
|
385
|
+
// `rest/alarm` is neither time-ordered nor limited server-side;
|
|
386
|
+
// present the most recent `limit` records to match the
|
|
387
|
+
// semantics `stat/event?_limit=` provided on older controllers.
|
|
388
|
+
alarms.sort_by_key(|e| std::cmp::Reverse(e.time));
|
|
389
|
+
alarms.truncate(limit);
|
|
390
|
+
Ok(alarms)
|
|
391
|
+
}
|
|
392
|
+
Err(e) => Err(e),
|
|
393
|
+
}
|
|
375
394
|
}
|
|
376
395
|
|
|
377
396
|
// Port table for a specific device
|
|
@@ -38,12 +38,19 @@ pub struct Site {
|
|
|
38
38
|
pub struct Client {
|
|
39
39
|
#[serde(alias = "macAddress")]
|
|
40
40
|
pub mac_address: Option<String>,
|
|
41
|
+
/// The last address the controller recorded for this client. It outlives the
|
|
42
|
+
/// lease, so it can name an address the client no longer holds. `clients list`
|
|
43
|
+
/// reports the live address from `stat/sta` instead, so that it agrees with
|
|
44
|
+
/// `clients show`.
|
|
41
45
|
#[serde(alias = "ipAddress")]
|
|
42
46
|
pub ip_address: Option<String>,
|
|
43
47
|
pub name: Option<String>,
|
|
44
48
|
pub hostname: Option<String>,
|
|
45
49
|
#[serde(alias = "type")]
|
|
46
50
|
pub client_type: Option<String>,
|
|
51
|
+
/// ISO8601 timestamp of the current association.
|
|
52
|
+
#[serde(alias = "connectedAt")]
|
|
53
|
+
pub connected_at: Option<String>,
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
impl Client {
|
|
@@ -83,6 +90,10 @@ pub struct LegacyClient {
|
|
|
83
90
|
pub ap_mac: Option<String>,
|
|
84
91
|
#[serde(rename = "essid")]
|
|
85
92
|
pub ssid: Option<String>,
|
|
93
|
+
/// Name of the network the client landed on ("Default", "IoT", ...). Absent
|
|
94
|
+
/// while the client is associated but has not obtained an address.
|
|
95
|
+
pub network: Option<String>,
|
|
96
|
+
pub vlan: Option<u32>,
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
impl LegacyClient {
|
|
@@ -14,7 +14,77 @@ pub struct ListFilter {
|
|
|
14
14
|
pub struct Pagination {
|
|
15
15
|
pub limit: usize,
|
|
16
16
|
pub offset: usize,
|
|
17
|
-
|
|
17
|
+
/// Field names already validated against `fields::CLIENTS_LIST`.
|
|
18
|
+
pub fields: Option<Vec<String>>,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// A client as `clients list` reports it: the integration-API record joined with
|
|
22
|
+
/// the live legacy record for the same MAC.
|
|
23
|
+
///
|
|
24
|
+
/// The integration API knows every client the controller has seen and carries
|
|
25
|
+
/// `connectedAt`. Only the legacy `stat/sta` record carries the live address,
|
|
26
|
+
/// SSID, signal, network and VLAN. Neither alone answers "which SSID is each
|
|
27
|
+
/// client on", and reading `ip` from the integration record makes `clients list`
|
|
28
|
+
/// disagree with `clients show` whenever a client has lost its lease.
|
|
29
|
+
pub(crate) struct ClientRow {
|
|
30
|
+
pub client: Client,
|
|
31
|
+
pub detail: Option<LegacyClient>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
impl ClientRow {
|
|
35
|
+
/// The address the client holds right now, or None while it has none.
|
|
36
|
+
///
|
|
37
|
+
/// Deliberately not `Client::ip_address`, which is the last address the
|
|
38
|
+
/// controller ever saw and lingers long after the lease is gone.
|
|
39
|
+
pub fn live_ip(&self) -> Option<&str> {
|
|
40
|
+
self.detail.as_ref().and_then(|d| d.ip.as_deref())
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pub fn to_json(&self) -> serde_json::Value {
|
|
44
|
+
let c = &self.client;
|
|
45
|
+
let d = self.detail.as_ref();
|
|
46
|
+
serde_json::json!({
|
|
47
|
+
"name": c.clean_name(),
|
|
48
|
+
"mac": c.mac_address.as_deref().map(|m| format_mac(&normalize_mac(m))),
|
|
49
|
+
"ip": self.live_ip(),
|
|
50
|
+
"type": c.client_type,
|
|
51
|
+
"ssid": d.and_then(|d| d.ssid.as_deref()),
|
|
52
|
+
"signal": d.and_then(|d| d.signal),
|
|
53
|
+
"uptime": d.and_then(|d| d.uptime),
|
|
54
|
+
"network": d.and_then(|d| d.network.as_deref()),
|
|
55
|
+
"vlan": d.and_then(|d| d.vlan),
|
|
56
|
+
"tx_bytes": d.and_then(|d| d.tx_bytes),
|
|
57
|
+
"rx_bytes": d.and_then(|d| d.rx_bytes),
|
|
58
|
+
"blocked": d.map(|d| d.blocked),
|
|
59
|
+
"connected_at": c.connected_at,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Join integration-API clients to their live legacy records by MAC.
|
|
65
|
+
///
|
|
66
|
+
/// A client with no legacy record is kept, with its live fields null. That is
|
|
67
|
+
/// the honest answer: it is associated (the controller lists it) but the
|
|
68
|
+
/// controller has no current address or SSID for it.
|
|
69
|
+
pub(crate) fn merge_rows(clients: Vec<Client>, legacy: Vec<LegacyClient>) -> Vec<ClientRow> {
|
|
70
|
+
let mut by_mac: std::collections::HashMap<String, LegacyClient> = legacy
|
|
71
|
+
.into_iter()
|
|
72
|
+
.filter_map(|l| {
|
|
73
|
+
let key = l.mac.as_deref().map(normalize_mac)?;
|
|
74
|
+
Some((key, l))
|
|
75
|
+
})
|
|
76
|
+
.collect();
|
|
77
|
+
|
|
78
|
+
clients
|
|
79
|
+
.into_iter()
|
|
80
|
+
.map(|c| {
|
|
81
|
+
let detail = c
|
|
82
|
+
.mac_address
|
|
83
|
+
.as_deref()
|
|
84
|
+
.and_then(|m| by_mac.remove(&normalize_mac(m)));
|
|
85
|
+
ClientRow { client: c, detail }
|
|
86
|
+
})
|
|
87
|
+
.collect()
|
|
18
88
|
}
|
|
19
89
|
|
|
20
90
|
pub(crate) fn apply_filter(clients: Vec<Client>, filter: &ListFilter) -> Vec<Client> {
|
|
@@ -39,27 +109,15 @@ pub(crate) fn apply_filter(clients: Vec<Client>, filter: &ListFilter) -> Vec<Cli
|
|
|
39
109
|
.collect()
|
|
40
110
|
}
|
|
41
111
|
|
|
42
|
-
fn render_clients(
|
|
112
|
+
fn render_clients(rows: &[ClientRow], out: &OutputConfig) {
|
|
43
113
|
if out.is_json() {
|
|
44
114
|
out.print_data(
|
|
45
|
-
&serde_json::to_string_pretty(
|
|
46
|
-
|
|
47
|
-
.iter()
|
|
48
|
-
.map(|c| {
|
|
49
|
-
serde_json::json!({
|
|
50
|
-
"name": c.clean_name(),
|
|
51
|
-
"mac": c.mac_address.as_deref().map(|m| format_mac(&normalize_mac(m))),
|
|
52
|
-
"ip": c.ip_address,
|
|
53
|
-
"type": c.client_type,
|
|
54
|
-
})
|
|
55
|
-
})
|
|
56
|
-
.collect::<Vec<_>>(),
|
|
57
|
-
)
|
|
58
|
-
.expect("failed to serialize JSON"),
|
|
115
|
+
&serde_json::to_string_pretty(&rows.iter().map(ClientRow::to_json).collect::<Vec<_>>())
|
|
116
|
+
.expect("failed to serialize JSON"),
|
|
59
117
|
);
|
|
60
118
|
} else {
|
|
61
119
|
let color = use_color();
|
|
62
|
-
let names: Vec<String> =
|
|
120
|
+
let names: Vec<String> = rows.iter().map(|r| r.client.clean_name()).collect();
|
|
63
121
|
let name_w = names.iter().map(|n| n.len()).max().unwrap_or(4).max(4) + 2;
|
|
64
122
|
let total_w = name_w + 19 + 15 + 10;
|
|
65
123
|
let header = format!("{:<name_w$} {:<19} {:<15} {}", "Name", "MAC", "IP", "Type");
|
|
@@ -71,14 +129,15 @@ fn render_clients(clients: &[Client], out: &OutputConfig) {
|
|
|
71
129
|
println!("{}", "-".repeat(total_w));
|
|
72
130
|
}
|
|
73
131
|
|
|
74
|
-
for (
|
|
75
|
-
let mac =
|
|
132
|
+
for (r, name) in rows.iter().zip(&names) {
|
|
133
|
+
let mac = r
|
|
134
|
+
.client
|
|
76
135
|
.mac_address
|
|
77
136
|
.as_deref()
|
|
78
137
|
.map(format_mac)
|
|
79
138
|
.unwrap_or_else(|| "-".into());
|
|
80
|
-
let ip =
|
|
81
|
-
let ctype =
|
|
139
|
+
let ip = r.live_ip().unwrap_or("-");
|
|
140
|
+
let ctype = r.client.client_type.as_deref().unwrap_or("-");
|
|
82
141
|
let pad = name_w - 1;
|
|
83
142
|
|
|
84
143
|
if color {
|
|
@@ -94,7 +153,7 @@ fn render_clients(clients: &[Client], out: &OutputConfig) {
|
|
|
94
153
|
}
|
|
95
154
|
}
|
|
96
155
|
}
|
|
97
|
-
out.print_message(&format!("\n{} clients",
|
|
156
|
+
out.print_message(&format!("\n{} clients", rows.len()));
|
|
98
157
|
}
|
|
99
158
|
|
|
100
159
|
pub async fn list(
|
|
@@ -118,22 +177,16 @@ pub async fn list(
|
|
|
118
177
|
crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
|
|
119
178
|
)?;
|
|
120
179
|
eprintln!("Every {interval}s | clients list (press Ctrl+C to exit)\n");
|
|
121
|
-
match client
|
|
122
|
-
Ok(
|
|
123
|
-
|
|
124
|
-
render_clients(&filtered, &out);
|
|
125
|
-
}
|
|
126
|
-
Err(e) => {
|
|
127
|
-
eprintln!("Error: {e}");
|
|
128
|
-
}
|
|
180
|
+
match fetch_rows(client, &filter).await {
|
|
181
|
+
Ok(rows) => render_clients(&rows, &out),
|
|
182
|
+
Err(e) => eprintln!("Error: {e}"),
|
|
129
183
|
}
|
|
130
184
|
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
|
|
131
185
|
}
|
|
132
186
|
} else {
|
|
133
|
-
let
|
|
134
|
-
let
|
|
135
|
-
let
|
|
136
|
-
let paginated: Vec<Client> = filtered
|
|
187
|
+
let rows = fetch_rows(client, &filter).await?;
|
|
188
|
+
let total = rows.len();
|
|
189
|
+
let paginated: Vec<ClientRow> = rows
|
|
137
190
|
.into_iter()
|
|
138
191
|
.skip(pagination.offset)
|
|
139
192
|
.take(pagination.limit)
|
|
@@ -141,21 +194,11 @@ pub async fn list(
|
|
|
141
194
|
if out.is_json() {
|
|
142
195
|
let items: Vec<serde_json::Value> = paginated
|
|
143
196
|
.iter()
|
|
144
|
-
.map(|
|
|
145
|
-
let
|
|
146
|
-
|
|
147
|
-
.
|
|
148
|
-
.
|
|
149
|
-
let mut obj = serde_json::json!({
|
|
150
|
-
"name": c.clean_name(),
|
|
151
|
-
"mac": mac_str,
|
|
152
|
-
"ip": c.ip_address,
|
|
153
|
-
"type": c.client_type,
|
|
154
|
-
});
|
|
155
|
-
if let Some(ref fields_str) = pagination.fields {
|
|
156
|
-
let keep: Vec<&str> = fields_str.split(',').map(str::trim).collect();
|
|
157
|
-
let map = obj.as_object_mut().unwrap();
|
|
158
|
-
map.retain(|k, _| keep.contains(&k.as_str()));
|
|
197
|
+
.map(|r| {
|
|
198
|
+
let mut obj = r.to_json();
|
|
199
|
+
if let Some(ref keep) = pagination.fields {
|
|
200
|
+
let map = obj.as_object_mut().expect("row is a JSON object");
|
|
201
|
+
map.retain(|k, _| keep.iter().any(|f| f == k));
|
|
159
202
|
}
|
|
160
203
|
obj
|
|
161
204
|
})
|
|
@@ -176,6 +219,17 @@ pub async fn list(
|
|
|
176
219
|
}
|
|
177
220
|
}
|
|
178
221
|
|
|
222
|
+
/// Fetch both client views and join them. Two calls, once, rather than one call
|
|
223
|
+
/// per client for anything beyond name/mac/ip/type.
|
|
224
|
+
async fn fetch_rows(
|
|
225
|
+
client: &mut UnifiClient,
|
|
226
|
+
filter: &ListFilter,
|
|
227
|
+
) -> Result<Vec<ClientRow>, Box<dyn std::error::Error>> {
|
|
228
|
+
let clients = client.list_clients().await?;
|
|
229
|
+
let legacy = client.list_clients_legacy().await?;
|
|
230
|
+
Ok(merge_rows(apply_filter(clients, filter), legacy))
|
|
231
|
+
}
|
|
232
|
+
|
|
179
233
|
pub async fn show(
|
|
180
234
|
client: &UnifiClient,
|
|
181
235
|
mac: &str,
|
|
@@ -195,6 +249,9 @@ pub async fn show(
|
|
|
195
249
|
"signal": c.signal,
|
|
196
250
|
"ssid": c.ssid,
|
|
197
251
|
"ap_mac": c.ap_mac,
|
|
252
|
+
"network": c.network,
|
|
253
|
+
"vlan": c.vlan,
|
|
254
|
+
"blocked": c.blocked,
|
|
198
255
|
}))?);
|
|
199
256
|
return Ok(());
|
|
200
257
|
}
|
|
@@ -250,6 +307,15 @@ pub async fn show(
|
|
|
250
307
|
println!(" {} {}", label("AP: "), format_mac(ap));
|
|
251
308
|
}
|
|
252
309
|
}
|
|
310
|
+
if let Some(ref network) = c.network {
|
|
311
|
+
println!(" {} {network}", label("Network:"));
|
|
312
|
+
}
|
|
313
|
+
if let Some(vlan) = c.vlan {
|
|
314
|
+
println!(" {} {vlan}", label("VLAN: "));
|
|
315
|
+
}
|
|
316
|
+
if c.blocked {
|
|
317
|
+
println!(" {} yes", label("Blocked:"));
|
|
318
|
+
}
|
|
253
319
|
|
|
254
320
|
Ok(())
|
|
255
321
|
}
|
|
@@ -419,6 +485,142 @@ mod tests {
|
|
|
419
485
|
.unwrap()
|
|
420
486
|
}
|
|
421
487
|
|
|
488
|
+
fn legacy(json: &str) -> LegacyClient {
|
|
489
|
+
serde_json::from_str(json).unwrap()
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// --- merge_rows: `clients list` must describe a client the way `show` does ---
|
|
493
|
+
|
|
494
|
+
#[test]
|
|
495
|
+
fn merge_joins_legacy_detail_by_mac() {
|
|
496
|
+
let clients = vec![make_client(
|
|
497
|
+
"Plug",
|
|
498
|
+
"70:03:9f:90:3c:29",
|
|
499
|
+
"10.10.20.202",
|
|
500
|
+
"WIRELESS",
|
|
501
|
+
)];
|
|
502
|
+
let legacy_records = vec![legacy(
|
|
503
|
+
r#"{"_id":"1","mac":"70:03:9f:90:3c:29","ip":"10.10.20.202","essid":"Notwork",
|
|
504
|
+
"signal":-57,"uptime":321125,"network":"IoT","vlan":20}"#,
|
|
505
|
+
)];
|
|
506
|
+
|
|
507
|
+
let rows = merge_rows(clients, legacy_records);
|
|
508
|
+
assert_eq!(rows.len(), 1);
|
|
509
|
+
let json = rows[0].to_json();
|
|
510
|
+
assert_eq!(json["ssid"], "Notwork");
|
|
511
|
+
assert_eq!(json["signal"], -57);
|
|
512
|
+
assert_eq!(json["network"], "IoT");
|
|
513
|
+
assert_eq!(json["vlan"], 20);
|
|
514
|
+
assert_eq!(json["ip"], "10.10.20.202");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#[test]
|
|
518
|
+
fn merge_matches_macs_in_different_formats() {
|
|
519
|
+
let clients = vec![make_client("X", "AA-BB-CC-DD-EE-FF", "10.0.0.1", "WIRED")];
|
|
520
|
+
let legacy_records = vec![legacy(
|
|
521
|
+
r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:ff","ip":"10.0.0.5"}"#,
|
|
522
|
+
)];
|
|
523
|
+
let rows = merge_rows(clients, legacy_records);
|
|
524
|
+
assert_eq!(rows[0].live_ip(), Some("10.0.0.5"));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/// The defect this guards: `clients list` read `ip` from the integration API,
|
|
528
|
+
/// which keeps the last address a client ever held. A plug that had dropped
|
|
529
|
+
/// its lease four days earlier was still reported at that address, while
|
|
530
|
+
/// `clients show` correctly reported none.
|
|
531
|
+
#[test]
|
|
532
|
+
fn live_ip_wins_over_the_integration_apis_last_known_address() {
|
|
533
|
+
let clients = vec![make_client(
|
|
534
|
+
"Refrigerator",
|
|
535
|
+
"c4:dd:57:1d:07:e6",
|
|
536
|
+
"10.10.20.203",
|
|
537
|
+
"WIRELESS",
|
|
538
|
+
)];
|
|
539
|
+
let legacy_records = vec![legacy(
|
|
540
|
+
r#"{"_id":"1","mac":"c4:dd:57:1d:07:e6","essid":"Network"}"#,
|
|
541
|
+
)];
|
|
542
|
+
|
|
543
|
+
let rows = merge_rows(clients, legacy_records);
|
|
544
|
+
assert_eq!(
|
|
545
|
+
rows[0].live_ip(),
|
|
546
|
+
None,
|
|
547
|
+
"a client with no current lease must report no ip"
|
|
548
|
+
);
|
|
549
|
+
assert_eq!(rows[0].to_json()["ip"], serde_json::Value::Null);
|
|
550
|
+
assert_eq!(rows[0].to_json()["ssid"], "Network");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
#[test]
|
|
554
|
+
fn clients_without_a_legacy_record_survive_with_null_detail() {
|
|
555
|
+
let clients = vec![make_client(
|
|
556
|
+
"Ghost",
|
|
557
|
+
"aa:bb:cc:dd:ee:ff",
|
|
558
|
+
"10.0.0.1",
|
|
559
|
+
"WIRED",
|
|
560
|
+
)];
|
|
561
|
+
let rows = merge_rows(clients, vec![]);
|
|
562
|
+
assert_eq!(rows.len(), 1);
|
|
563
|
+
let json = rows[0].to_json();
|
|
564
|
+
assert_eq!(json["name"], "Ghost");
|
|
565
|
+
assert_eq!(json["ip"], serde_json::Value::Null);
|
|
566
|
+
assert_eq!(json["ssid"], serde_json::Value::Null);
|
|
567
|
+
assert_eq!(json["blocked"], serde_json::Value::Null);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#[test]
|
|
571
|
+
fn merge_preserves_client_order_and_count() {
|
|
572
|
+
let clients = vec![
|
|
573
|
+
make_client("A", "aa:bb:cc:dd:ee:01", "10.0.0.1", "WIRED"),
|
|
574
|
+
make_client("B", "aa:bb:cc:dd:ee:02", "10.0.0.2", "WIRELESS"),
|
|
575
|
+
make_client("C", "aa:bb:cc:dd:ee:03", "10.0.0.3", "WIRELESS"),
|
|
576
|
+
];
|
|
577
|
+
let legacy_records = vec![legacy(
|
|
578
|
+
r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:02","ip":"1.2.3.4"}"#,
|
|
579
|
+
)];
|
|
580
|
+
let rows = merge_rows(clients, legacy_records);
|
|
581
|
+
let names: Vec<String> = rows
|
|
582
|
+
.iter()
|
|
583
|
+
.map(|r| r.client.clean_name())
|
|
584
|
+
.collect::<Vec<_>>();
|
|
585
|
+
assert_eq!(names, vec!["A", "B", "C"]);
|
|
586
|
+
assert_eq!(rows[1].live_ip(), Some("1.2.3.4"));
|
|
587
|
+
assert_eq!(rows[0].live_ip(), None);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
#[test]
|
|
591
|
+
fn merge_ignores_legacy_records_with_no_matching_client() {
|
|
592
|
+
let clients = vec![make_client("A", "aa:bb:cc:dd:ee:01", "10.0.0.1", "WIRED")];
|
|
593
|
+
let legacy_records = vec![
|
|
594
|
+
legacy(r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:01","ip":"1.1.1.1"}"#),
|
|
595
|
+
legacy(r#"{"_id":"2","mac":"ff:ff:ff:ff:ff:ff","ip":"2.2.2.2"}"#),
|
|
596
|
+
];
|
|
597
|
+
let rows = merge_rows(clients, legacy_records);
|
|
598
|
+
assert_eq!(rows.len(), 1);
|
|
599
|
+
assert_eq!(rows[0].live_ip(), Some("1.1.1.1"));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
#[test]
|
|
603
|
+
fn to_json_emits_exactly_the_published_field_set() {
|
|
604
|
+
let rows = merge_rows(
|
|
605
|
+
vec![make_client("A", "aa:bb:cc:dd:ee:01", "10.0.0.1", "WIRED")],
|
|
606
|
+
vec![],
|
|
607
|
+
);
|
|
608
|
+
let json = rows[0].to_json();
|
|
609
|
+
let mut emitted: Vec<&str> = json
|
|
610
|
+
.as_object()
|
|
611
|
+
.unwrap()
|
|
612
|
+
.keys()
|
|
613
|
+
.map(|s| s.as_str())
|
|
614
|
+
.collect();
|
|
615
|
+
let mut published = crate::fields::names(crate::fields::CLIENTS_LIST);
|
|
616
|
+
emitted.sort_unstable();
|
|
617
|
+
published.sort_unstable();
|
|
618
|
+
assert_eq!(
|
|
619
|
+
emitted, published,
|
|
620
|
+
"clients list output must match the fields the schema publishes"
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
422
624
|
// --- total_bytes ---
|
|
423
625
|
|
|
424
626
|
#[test]
|
|
@@ -6,7 +6,8 @@ use crate::output::{OutputConfig, use_color};
|
|
|
6
6
|
pub struct Pagination {
|
|
7
7
|
pub limit: usize,
|
|
8
8
|
pub offset: usize,
|
|
9
|
-
|
|
9
|
+
/// Field names already validated against `fields::DEVICES_LIST`.
|
|
10
|
+
pub fields: Option<Vec<String>>,
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
fn render_devices(devices: &[Device], out: &OutputConfig) {
|
|
@@ -146,10 +147,9 @@ pub async fn list(
|
|
|
146
147
|
"state": d.state,
|
|
147
148
|
"firmware": d.firmware_version,
|
|
148
149
|
});
|
|
149
|
-
if let Some(ref
|
|
150
|
-
let
|
|
151
|
-
|
|
152
|
-
map.retain(|k, _| keep.contains(&k.as_str()));
|
|
150
|
+
if let Some(ref keep) = pagination.fields {
|
|
151
|
+
let map = obj.as_object_mut().expect("device is a JSON object");
|
|
152
|
+
map.retain(|k, _| keep.iter().any(|f| f == k));
|
|
153
153
|
}
|
|
154
154
|
obj
|
|
155
155
|
})
|
|
@@ -6,7 +6,8 @@ use crate::output::{OutputConfig, use_color};
|
|
|
6
6
|
pub struct Pagination {
|
|
7
7
|
pub limit: usize,
|
|
8
8
|
pub offset: usize,
|
|
9
|
-
|
|
9
|
+
/// Field names already validated against `fields::EVENTS_LIST`.
|
|
10
|
+
pub fields: Option<Vec<String>>,
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
pub async fn list(
|
|
@@ -33,10 +34,9 @@ pub async fn list(
|
|
|
33
34
|
"time": e.time,
|
|
34
35
|
"datetime": e.datetime,
|
|
35
36
|
});
|
|
36
|
-
if let Some(ref
|
|
37
|
-
let
|
|
38
|
-
|
|
39
|
-
map.retain(|k, _| keep.contains(&k.as_str()));
|
|
37
|
+
if let Some(ref keep) = pagination.fields {
|
|
38
|
+
let map = obj.as_object_mut().expect("event is a JSON object");
|
|
39
|
+
map.retain(|k, _| keep.iter().any(|f| f == k));
|
|
40
40
|
}
|
|
41
41
|
obj
|
|
42
42
|
})
|