Skip to main content

alopex_cli/commands/
server.rs

1//! Server management commands.
2
3use std::io::Write;
4
5use serde::Deserialize;
6
7use crate::cli::{CompactionCommand, OutputFormat, ServerCommand};
8use crate::client::http::{ClientError, HttpClient};
9use crate::error::{CliError, Result};
10use crate::models::{Column, Row};
11use crate::output::server as server_output;
12use crate::output::table::TableFormatter;
13use crate::output::Formatter;
14use crate::tui::renderer::render_output;
15
16#[derive(Debug, Deserialize)]
17struct ServerStatusResponse {
18    version: Option<String>,
19    uptime_secs: Option<u64>,
20    connections: Option<u64>,
21    queries_per_second: Option<f64>,
22    cluster: Option<ServerClusterStatus>,
23}
24
25#[derive(Debug, Deserialize)]
26struct ServerMetricsResponse {
27    qps: Option<f64>,
28    avg_latency_ms: Option<f64>,
29    p99_latency_ms: Option<f64>,
30    memory_usage_mb: Option<u64>,
31    active_connections: Option<u64>,
32}
33
34#[derive(Debug, Deserialize)]
35struct ServerHealthResponse {
36    status: Option<String>,
37    message: Option<String>,
38    degraded: Option<bool>,
39    cluster: Option<ServerClusterStatus>,
40}
41
42#[derive(Debug, Deserialize)]
43struct ServerCompactionResponse {
44    success: Option<bool>,
45    message: Option<String>,
46}
47
48#[derive(Debug, Deserialize)]
49struct ServerClusterOperationResponse {
50    action: Option<String>,
51    cluster: Option<ServerClusterStatus>,
52}
53
54#[derive(Debug, Deserialize)]
55struct ServerClusterStatus {
56    schema_version: Option<u32>,
57    mode: Option<String>,
58    identity: Option<ServerClusterIdentity>,
59    routing_capabilities: Option<ServerRoutingCapabilities>,
60    degraded: Option<bool>,
61    diagnostics: Option<Vec<ServerClusterDiagnostic>>,
62}
63
64#[derive(Debug, Deserialize)]
65struct ServerClusterIdentity {
66    node_id: Option<String>,
67    lifecycle_state: Option<String>,
68}
69
70#[derive(Debug, Deserialize)]
71struct ServerRoutingCapabilities {
72    local_only: Option<bool>,
73    future_distributed_execution_required: Option<bool>,
74    scatter_gather_simulated: Option<bool>,
75}
76
77#[derive(Debug, Deserialize)]
78struct ServerClusterDiagnostic {
79    code: Option<String>,
80}
81
82/// Execute a server management command against a remote server.
83pub async fn execute_remote<W: Write>(
84    client: &HttpClient,
85    cmd: &ServerCommand,
86    writer: &mut W,
87    quiet: bool,
88) -> Result<()> {
89    match cmd {
90        ServerCommand::Status => {
91            let response: ServerStatusResponse = client
92                .get_json("api/admin/status")
93                .await
94                .map_err(map_client_error)?;
95            if quiet {
96                return Ok(());
97            }
98            render_table(
99                writer,
100                server_output::status_columns(),
101                vec![status_row_from_response(&response)],
102            )
103        }
104        ServerCommand::Metrics => {
105            let response: ServerMetricsResponse = client
106                .get_json("api/admin/metrics")
107                .await
108                .map_err(map_client_error)?;
109            if quiet {
110                return Ok(());
111            }
112            render_table(
113                writer,
114                server_output::metrics_columns(),
115                vec![server_output::metrics_row(
116                    response.qps,
117                    response.avg_latency_ms,
118                    response.p99_latency_ms,
119                    response.memory_usage_mb,
120                    response.active_connections,
121                )],
122            )
123        }
124        ServerCommand::Health => {
125            let response: ServerHealthResponse = client
126                .get_json("api/admin/health")
127                .await
128                .map_err(map_client_error)?;
129            if quiet {
130                return Ok(());
131            }
132            render_table(
133                writer,
134                server_output::health_columns(),
135                vec![health_row_from_response(&response)],
136            )
137        }
138        ServerCommand::Join => {
139            let response = execute_cluster_operation(client, "join").await?;
140            if quiet {
141                return Ok(());
142            }
143            render_cluster_operation(writer, &response)
144        }
145        ServerCommand::Leave => {
146            let response = execute_cluster_operation(client, "leave").await?;
147            if quiet {
148                return Ok(());
149            }
150            render_cluster_operation(writer, &response)
151        }
152        ServerCommand::Compaction { command } => match command {
153            CompactionCommand::Trigger => {
154                let request = serde_json::json!({});
155                let response: ServerCompactionResponse = client
156                    .post_json("api/admin/compaction", &request)
157                    .await
158                    .map_err(map_client_error)?;
159                if quiet {
160                    return Ok(());
161                }
162                render_table(
163                    writer,
164                    server_output::compaction_columns(),
165                    vec![server_output::compaction_row(
166                        response.success,
167                        response.message.as_deref(),
168                    )],
169                )
170            }
171        },
172    }
173}
174
175pub async fn execute_remote_tui(
176    client: &HttpClient,
177    cmd: &ServerCommand,
178    quiet: bool,
179    connection_label: impl Into<String>,
180    output_format: OutputFormat,
181    admin_launcher: Option<Box<dyn FnMut() -> Result<()> + '_>>,
182) -> Result<()> {
183    match cmd {
184        ServerCommand::Status => {
185            let response: ServerStatusResponse = client
186                .get_json("api/admin/status")
187                .await
188                .map_err(map_client_error)?;
189            if quiet {
190                return Ok(());
191            }
192            render_output(
193                server_output::status_columns(),
194                vec![status_row_from_response(&response)],
195                connection_label,
196                Some(server_command_context(cmd)),
197                true,
198                None,
199                output_format,
200                admin_launcher,
201            )
202        }
203        ServerCommand::Metrics => {
204            let response: ServerMetricsResponse = client
205                .get_json("api/admin/metrics")
206                .await
207                .map_err(map_client_error)?;
208            if quiet {
209                return Ok(());
210            }
211            render_output(
212                server_output::metrics_columns(),
213                vec![server_output::metrics_row(
214                    response.qps,
215                    response.avg_latency_ms,
216                    response.p99_latency_ms,
217                    response.memory_usage_mb,
218                    response.active_connections,
219                )],
220                connection_label,
221                Some(server_command_context(cmd)),
222                true,
223                None,
224                output_format,
225                admin_launcher,
226            )
227        }
228        ServerCommand::Health => {
229            let response: ServerHealthResponse = client
230                .get_json("api/admin/health")
231                .await
232                .map_err(map_client_error)?;
233            if quiet {
234                return Ok(());
235            }
236            render_output(
237                server_output::health_columns(),
238                vec![health_row_from_response(&response)],
239                connection_label,
240                Some(server_command_context(cmd)),
241                true,
242                None,
243                output_format,
244                admin_launcher,
245            )
246        }
247        ServerCommand::Join => {
248            let response = execute_cluster_operation(client, "join").await?;
249            if quiet {
250                return Ok(());
251            }
252            render_output(
253                server_output::cluster_operation_columns(),
254                vec![cluster_operation_row(&response)],
255                connection_label,
256                Some(server_command_context(cmd)),
257                true,
258                None,
259                output_format,
260                admin_launcher,
261            )
262        }
263        ServerCommand::Leave => {
264            let response = execute_cluster_operation(client, "leave").await?;
265            if quiet {
266                return Ok(());
267            }
268            render_output(
269                server_output::cluster_operation_columns(),
270                vec![cluster_operation_row(&response)],
271                connection_label,
272                Some(server_command_context(cmd)),
273                true,
274                None,
275                output_format,
276                admin_launcher,
277            )
278        }
279        ServerCommand::Compaction { command } => match command {
280            CompactionCommand::Trigger => {
281                let request = serde_json::json!({});
282                let response: ServerCompactionResponse = client
283                    .post_json("api/admin/compaction", &request)
284                    .await
285                    .map_err(map_client_error)?;
286                if quiet {
287                    return Ok(());
288                }
289                render_output(
290                    server_output::compaction_columns(),
291                    vec![server_output::compaction_row(
292                        response.success,
293                        response.message.as_deref(),
294                    )],
295                    connection_label,
296                    Some(server_command_context(cmd)),
297                    true,
298                    None,
299                    output_format,
300                    admin_launcher,
301                )
302            }
303        },
304    }
305}
306
307fn server_command_context(cmd: &ServerCommand) -> String {
308    match cmd {
309        ServerCommand::Status => "server status".to_string(),
310        ServerCommand::Metrics => "server metrics".to_string(),
311        ServerCommand::Health => "server health".to_string(),
312        ServerCommand::Join => "server join".to_string(),
313        ServerCommand::Leave => "server leave".to_string(),
314        ServerCommand::Compaction { .. } => "server compaction trigger".to_string(),
315    }
316}
317
318fn render_table<W: Write>(writer: &mut W, columns: Vec<Column>, rows: Vec<Row>) -> Result<()> {
319    let mut formatter = TableFormatter::new();
320    formatter.write_header(writer, &columns)?;
321    for row in rows {
322        formatter.write_row(writer, &row)?;
323    }
324    formatter.write_footer(writer)
325}
326
327fn status_row_from_response(response: &ServerStatusResponse) -> Row {
328    let cluster = ClusterDisplayFields::from(response.cluster.as_ref());
329    server_output::status_row(server_output::StatusRowFields {
330        version: response.version.as_deref(),
331        uptime_secs: response.uptime_secs,
332        connections: response.connections,
333        qps: response.queries_per_second,
334        cluster_schema_version: cluster.schema_version,
335        cluster_mode: cluster.mode,
336        node_id: cluster.node_id,
337        lifecycle_state: cluster.lifecycle_state,
338        degraded: cluster.degraded,
339        local_only: cluster.local_only,
340        future_distributed: cluster.future_distributed,
341        scatter_gather: cluster.scatter_gather,
342        diagnostics: cluster.diagnostics.as_deref(),
343    })
344}
345
346fn health_row_from_response(response: &ServerHealthResponse) -> Row {
347    let cluster = ClusterDisplayFields::from(response.cluster.as_ref());
348    server_output::health_row(
349        response.status.as_deref(),
350        response.message.as_deref(),
351        response.degraded.or(cluster.degraded),
352        cluster.mode,
353        cluster.node_id,
354    )
355}
356
357async fn execute_cluster_operation(
358    client: &HttpClient,
359    action: &str,
360) -> Result<ServerClusterOperationResponse> {
361    let request = serde_json::json!({});
362    let path = format!("api/admin/cluster/{action}");
363    client
364        .post_json(&path, &request)
365        .await
366        .map_err(map_client_error)
367}
368
369fn render_cluster_operation<W: Write>(
370    writer: &mut W,
371    response: &ServerClusterOperationResponse,
372) -> Result<()> {
373    render_table(
374        writer,
375        server_output::cluster_operation_columns(),
376        vec![cluster_operation_row(response)],
377    )
378}
379
380fn cluster_operation_row(response: &ServerClusterOperationResponse) -> Row {
381    let cluster = response.cluster.as_ref();
382    let identity = cluster.and_then(|cluster| cluster.identity.as_ref());
383    server_output::cluster_operation_row(
384        response.action.as_deref(),
385        cluster.and_then(|cluster| cluster.mode.as_deref()),
386        identity.and_then(|identity| identity.node_id.as_deref()),
387        identity.and_then(|identity| identity.lifecycle_state.as_deref()),
388        cluster.and_then(|cluster| cluster.degraded),
389    )
390}
391
392struct ClusterDisplayFields<'a> {
393    mode: Option<&'a str>,
394    schema_version: Option<u32>,
395    node_id: Option<&'a str>,
396    lifecycle_state: Option<&'a str>,
397    degraded: Option<bool>,
398    local_only: Option<bool>,
399    future_distributed: Option<bool>,
400    scatter_gather: Option<bool>,
401    diagnostics: Option<String>,
402}
403
404impl<'a> From<Option<&'a ServerClusterStatus>> for ClusterDisplayFields<'a> {
405    fn from(cluster: Option<&'a ServerClusterStatus>) -> Self {
406        let identity = cluster.and_then(|cluster| cluster.identity.as_ref());
407        let routing = cluster.and_then(|cluster| cluster.routing_capabilities.as_ref());
408        Self {
409            mode: cluster.and_then(|cluster| cluster.mode.as_deref()),
410            schema_version: cluster.and_then(|cluster| cluster.schema_version),
411            node_id: identity.and_then(|identity| identity.node_id.as_deref()),
412            lifecycle_state: identity.and_then(|identity| identity.lifecycle_state.as_deref()),
413            degraded: cluster.and_then(|cluster| cluster.degraded),
414            local_only: routing.and_then(|routing| routing.local_only),
415            future_distributed: routing
416                .and_then(|routing| routing.future_distributed_execution_required),
417            scatter_gather: routing.and_then(|routing| routing.scatter_gather_simulated),
418            diagnostics: cluster.and_then(diagnostic_codes),
419        }
420    }
421}
422
423fn diagnostic_codes(cluster: &ServerClusterStatus) -> Option<String> {
424    let diagnostics = cluster.diagnostics.as_ref()?;
425    let codes = diagnostics
426        .iter()
427        .filter_map(|diagnostic| diagnostic.code.as_deref())
428        .collect::<Vec<_>>();
429    if codes.is_empty() {
430        None
431    } else {
432        Some(codes.join(","))
433    }
434}
435
436fn map_client_error(err: ClientError) -> CliError {
437    match err {
438        ClientError::Request { source, .. } => {
439            CliError::ServerConnection(format!("request failed: {source}"))
440        }
441        ClientError::InvalidUrl(message) => CliError::InvalidArgument(message),
442        ClientError::Build(message) => CliError::InvalidArgument(message),
443        ClientError::Auth(err) => CliError::InvalidArgument(err.to_string()),
444        ClientError::HttpStatus { status, body } => {
445            CliError::InvalidArgument(format!("Server error: HTTP {} - {}", status.as_u16(), body))
446        }
447    }
448}