Skip to content

Commit 8c6a098

Browse files
feat(enterprise): add usage report commands (#165) (#297)
- Get current usage report for license compliance and capacity planning - Export usage report to JSON or CSV format - CSV conversion for spreadsheet analysis - Support for JMESPath query filtering - Comprehensive mdBook documentation - Unit tests for command parsing and CSV conversion
1 parent c61c6ee commit 8c6a098

File tree

6 files changed

+606
-0
lines changed

6 files changed

+606
-0
lines changed

crates/redisctl/src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,10 @@ pub enum EnterpriseCommands {
10271027
/// Statistics and metrics operations
10281028
#[command(subcommand)]
10291029
Stats(EnterpriseStatsCommands),
1030+
1031+
/// Usage report operations
1032+
#[command(subcommand, name = "usage-report")]
1033+
UsageReport(crate::commands::enterprise::usage_report::UsageReportCommands),
10301034
}
10311035

10321036
/// Cloud workflow commands

crates/redisctl/src/commands/enterprise/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ pub mod node_impl;
2020
pub mod rbac;
2121
pub mod rbac_impl;
2222
pub mod stats;
23+
pub mod usage_report;
2324
pub mod utils;
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
use anyhow::Context;
2+
use clap::Subcommand;
3+
4+
use crate::{cli::OutputFormat, connection::ConnectionManager, error::Result as CliResult};
5+
6+
#[allow(dead_code)]
7+
pub async fn handle_usage_report_command(
8+
conn_mgr: &ConnectionManager,
9+
profile_name: Option<&str>,
10+
usage_report_cmd: UsageReportCommands,
11+
output_format: OutputFormat,
12+
query: Option<&str>,
13+
) -> CliResult<()> {
14+
usage_report_cmd
15+
.execute(conn_mgr, profile_name, output_format, query)
16+
.await
17+
}
18+
19+
#[derive(Debug, Clone, Subcommand)]
20+
pub enum UsageReportCommands {
21+
/// Get current usage report
22+
Get,
23+
24+
/// Export usage report to file
25+
Export {
26+
/// Output file path
27+
#[arg(short, long)]
28+
output: String,
29+
30+
/// Export format (json or csv)
31+
#[arg(short, long, default_value = "json")]
32+
format: String,
33+
},
34+
}
35+
36+
impl UsageReportCommands {
37+
#[allow(dead_code)]
38+
pub async fn execute(
39+
&self,
40+
conn_mgr: &ConnectionManager,
41+
profile_name: Option<&str>,
42+
output_format: OutputFormat,
43+
query: Option<&str>,
44+
) -> CliResult<()> {
45+
handle_usage_report_command_impl(conn_mgr, profile_name, self, output_format, query).await
46+
}
47+
}
48+
49+
#[allow(dead_code)]
50+
async fn handle_usage_report_command_impl(
51+
conn_mgr: &ConnectionManager,
52+
profile_name: Option<&str>,
53+
command: &UsageReportCommands,
54+
output_format: OutputFormat,
55+
query: Option<&str>,
56+
) -> CliResult<()> {
57+
let client = conn_mgr.create_enterprise_client(profile_name).await?;
58+
59+
match command {
60+
UsageReportCommands::Get => {
61+
let response: serde_json::Value = client
62+
.get("/v1/usage_report")
63+
.await
64+
.context("Failed to get usage report")?;
65+
66+
let output_data = if let Some(q) = query {
67+
super::utils::apply_jmespath(&response, q)?
68+
} else {
69+
response
70+
};
71+
72+
super::utils::print_formatted_output(output_data, output_format)?;
73+
}
74+
UsageReportCommands::Export { output, format } => {
75+
let response: serde_json::Value = client
76+
.get("/v1/usage_report")
77+
.await
78+
.context("Failed to get usage report for export")?;
79+
80+
let output_data = if let Some(q) = query {
81+
super::utils::apply_jmespath(&response, q)?
82+
} else {
83+
response
84+
};
85+
86+
match format.as_str() {
87+
"json" => {
88+
let json_str = serde_json::to_string_pretty(&output_data)
89+
.context("Failed to serialize to JSON")?;
90+
std::fs::write(output, json_str)
91+
.context(format!("Failed to write to {}", output))?;
92+
println!("Usage report exported to {}", output);
93+
}
94+
"csv" => {
95+
// Convert JSON to CSV format
96+
let csv_data = json_to_csv(&output_data)?;
97+
std::fs::write(output, csv_data)
98+
.context(format!("Failed to write to {}", output))?;
99+
println!("Usage report exported to {} as CSV", output);
100+
}
101+
_ => {
102+
return Err(anyhow::anyhow!(
103+
"Unsupported format: {}. Use 'json' or 'csv'",
104+
format
105+
)
106+
.into());
107+
}
108+
}
109+
}
110+
}
111+
112+
Ok(())
113+
}
114+
115+
fn json_to_csv(data: &serde_json::Value) -> CliResult<String> {
116+
// Simple CSV conversion for usage report data
117+
let mut csv = String::new();
118+
119+
if let Some(obj) = data.as_object() {
120+
// Create header row from keys
121+
let headers: Vec<String> = obj.keys().map(|k| k.to_string()).collect();
122+
csv.push_str(&headers.join(","));
123+
csv.push('\n');
124+
125+
// Create data row from values
126+
let values: Vec<String> = obj
127+
.values()
128+
.map(|v| match v {
129+
serde_json::Value::String(s) => format!("\"{}\"", s.replace('"', "\"\"")),
130+
_ => v.to_string(),
131+
})
132+
.collect();
133+
csv.push_str(&values.join(","));
134+
csv.push('\n');
135+
} else if let Some(arr) = data.as_array() {
136+
// Handle array of objects
137+
if let Some(first) = arr.first()
138+
&& let Some(obj) = first.as_object()
139+
{
140+
// Create header row from first object's keys
141+
let headers: Vec<String> = obj.keys().map(|k| k.to_string()).collect();
142+
csv.push_str(&headers.join(","));
143+
csv.push('\n');
144+
145+
// Create data rows
146+
for item in arr {
147+
if let Some(obj) = item.as_object() {
148+
let values: Vec<String> = headers
149+
.iter()
150+
.map(|h| {
151+
obj.get(h)
152+
.map(|v| match v {
153+
serde_json::Value::String(s) => {
154+
format!("\"{}\"", s.replace('"', "\"\""))
155+
}
156+
_ => v.to_string(),
157+
})
158+
.unwrap_or_else(|| String::from(""))
159+
})
160+
.collect();
161+
csv.push_str(&values.join(","));
162+
csv.push('\n');
163+
}
164+
}
165+
}
166+
}
167+
168+
Ok(csv)
169+
}
170+
171+
#[cfg(test)]
172+
mod tests {
173+
use super::*;
174+
175+
#[test]
176+
fn test_usage_report_command_parsing() {
177+
use clap::Parser;
178+
179+
#[derive(Parser)]
180+
struct TestCli {
181+
#[command(subcommand)]
182+
cmd: UsageReportCommands,
183+
}
184+
185+
// Test get command
186+
let cli = TestCli::parse_from(["test", "get"]);
187+
assert!(matches!(cli.cmd, UsageReportCommands::Get));
188+
189+
// Test export command
190+
let cli = TestCli::parse_from(["test", "export", "--output", "report.json"]);
191+
if let UsageReportCommands::Export { output, format } = cli.cmd {
192+
assert_eq!(output, "report.json");
193+
assert_eq!(format, "json");
194+
} else {
195+
panic!("Expected Export command");
196+
}
197+
198+
// Test export with CSV format
199+
let cli = TestCli::parse_from(["test", "export", "-o", "report.csv", "-f", "csv"]);
200+
if let UsageReportCommands::Export { output, format } = cli.cmd {
201+
assert_eq!(output, "report.csv");
202+
assert_eq!(format, "csv");
203+
} else {
204+
panic!("Expected Export command");
205+
}
206+
}
207+
208+
#[test]
209+
fn test_json_to_csv() {
210+
// Test single object
211+
let json = serde_json::json!({
212+
"cluster": "test-cluster",
213+
"databases": 5,
214+
"memory_gb": 128
215+
});
216+
let csv = json_to_csv(&json).unwrap();
217+
assert!(csv.contains("cluster,databases,memory_gb"));
218+
assert!(csv.contains("\"test-cluster\",5,128"));
219+
220+
// Test array of objects
221+
let json = serde_json::json!([
222+
{"name": "db1", "memory": 1024},
223+
{"name": "db2", "memory": 2048}
224+
]);
225+
let csv = json_to_csv(&json).unwrap();
226+
// Check header (order may vary)
227+
assert!(csv.contains("memory,name") || csv.contains("name,memory"));
228+
// Check data rows
229+
assert!(csv.contains("\"db1\""));
230+
assert!(csv.contains("\"db2\""));
231+
assert!(csv.contains("1024"));
232+
assert!(csv.contains("2048"));
233+
}
234+
}

crates/redisctl/src/main.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,16 @@ async fn execute_enterprise_command(
315315
)
316316
.await
317317
}
318+
UsageReport(usage_report_cmd) => {
319+
commands::enterprise::usage_report::handle_usage_report_command(
320+
conn_mgr,
321+
profile,
322+
usage_report_cmd.clone(),
323+
output,
324+
query,
325+
)
326+
.await
327+
}
318328
}
319329
}
320330

docs/src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
- [Nodes](./enterprise/nodes.md)
3434
- [Users & RBAC](./enterprise/users.md)
3535
- [Statistics](./enterprise/stats.md)
36+
- [Usage Reports](./enterprise/usage-report.md)
3637
- [Modules](./enterprise/modules.md)
3738
- [Logs](./enterprise/logs.md)
3839
- [Active-Active (CRDB)](./enterprise/crdb.md)

0 commit comments

Comments
 (0)