Skip to main content

mockforge_ui/handlers/
plugin.rs

1//! Plugin management handlers
2
3use axum::{
4    extract::{Path, Query, State},
5    response::Json,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9
10use super::{AdminState, ApiResponse};
11
12/// Plugin information for API responses
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct PluginInfo {
15    pub id: String,
16    pub name: String,
17    pub version: String,
18    pub types: Vec<String>,
19    pub status: String,
20    pub healthy: bool,
21    pub description: String,
22    pub author: String,
23}
24
25/// Plugin statistics
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct PluginStats {
28    pub total_plugins: usize,
29    pub discovered: usize,
30    pub loaded: usize,
31    pub failed: usize,
32    pub skipped: usize,
33    pub success_rate: f64,
34}
35
36/// Plugin health information
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct PluginHealthInfo {
39    pub id: String,
40    pub healthy: bool,
41    pub message: String,
42    pub last_check: String,
43}
44
45/// Plugin status response
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct PluginStatusData {
48    pub stats: PluginStats,
49    pub health: Vec<PluginHealthInfo>,
50    pub last_updated: Option<String>,
51}
52
53/// Query parameters for plugin listing
54#[derive(Debug, Deserialize)]
55pub struct PluginListQuery {
56    #[serde(rename = "type")]
57    pub plugin_type: Option<String>,
58    pub status: Option<String>,
59}
60
61/// Reload plugin request
62#[derive(Debug, Deserialize)]
63pub struct ReloadPluginRequest {
64    pub plugin_id: String,
65}
66
67/// Get list of plugins
68pub async fn get_plugins(
69    State(state): State<AdminState>,
70    Query(query): Query<PluginListQuery>,
71) -> Json<ApiResponse<serde_json::Value>> {
72    tracing::info!("get_plugins called");
73
74    let registry = state.plugin_registry.read().await;
75    let all_plugins = registry.list_plugins();
76
77    let mut plugins: Vec<PluginInfo> = Vec::new();
78
79    for plugin_id in all_plugins {
80        if let Some(plugin_instance) = registry.get_plugin(&plugin_id) {
81            // Convert core PluginInfo to UI PluginInfo
82            let ui_plugin = PluginInfo {
83                id: plugin_instance.manifest.info.id.as_str().to_string(),
84                name: plugin_instance.manifest.info.name.clone(),
85                version: plugin_instance.manifest.info.version.to_string(),
86                types: plugin_instance.manifest.capabilities.clone(),
87                status: plugin_instance.state.to_string(),
88                healthy: plugin_instance.is_healthy(),
89                description: plugin_instance.manifest.info.description.clone(),
90                author: plugin_instance.manifest.info.author.name.clone(),
91            };
92
93            // Apply filters
94            let matches_type =
95                query.plugin_type.as_ref().map(|t| ui_plugin.types.contains(t)).unwrap_or(true);
96
97            let matches_status =
98                query.status.as_ref().map(|s| ui_plugin.status == *s).unwrap_or(true);
99
100            if matches_type && matches_status {
101                plugins.push(ui_plugin);
102            }
103        }
104    }
105
106    Json(ApiResponse::success(json!({
107        "plugins": plugins,
108        "total": plugins.len()
109    })))
110}
111
112/// Get plugin status
113pub async fn get_plugin_status(
114    State(state): State<AdminState>,
115) -> Json<ApiResponse<PluginStatusData>> {
116    let registry = state.plugin_registry.read().await;
117    let registry_stats = registry.get_stats();
118
119    // Calculate stats from registry
120    let mut loaded = 0;
121    let mut failed = 0;
122
123    let mut health: Vec<PluginHealthInfo> = Vec::new();
124
125    for plugin_id in registry.list_plugins() {
126        if let Some(plugin_instance) = registry.get_plugin(&plugin_id) {
127            let is_loaded = plugin_instance.state.is_ready();
128
129            if is_loaded {
130                loaded += 1;
131            } else {
132                failed += 1;
133            }
134
135            let is_healthy = plugin_instance.is_healthy();
136
137            health.push(PluginHealthInfo {
138                id: plugin_id.as_str().to_string(),
139                healthy: is_healthy,
140                message: if is_healthy {
141                    "Plugin is healthy".to_string()
142                } else {
143                    "Plugin has issues".to_string()
144                },
145                last_check: plugin_instance.health.last_check.to_rfc3339(),
146            });
147        }
148    }
149
150    let total_plugins = registry_stats.total_plugins as usize;
151    let success_rate = if total_plugins > 0 {
152        (loaded as f64 / total_plugins as f64) * 100.0
153    } else {
154        100.0
155    };
156
157    let status = PluginStatusData {
158        stats: PluginStats {
159            total_plugins,
160            discovered: total_plugins,
161            loaded,
162            failed,
163            skipped: 0, // Not tracked in current registry
164            success_rate,
165        },
166        health,
167        last_updated: Some(registry_stats.last_updated.to_rfc3339()),
168    };
169
170    Json(ApiResponse::success(status))
171}
172
173/// Get plugin details
174pub async fn get_plugin_details(
175    State(state): State<AdminState>,
176    Path(plugin_id): Path<String>,
177) -> Json<ApiResponse<serde_json::Value>> {
178    let registry = state.plugin_registry.read().await;
179    // Find plugin ID from registry - must exist in registry to query it
180    // The registry uses PluginId from its version of mockforge_plugin_core
181    let plugin_id_core = match registry.list_plugins().iter().find(|id| id.as_str() == plugin_id) {
182        Some(id) => id.clone(),
183        None => {
184            // Plugin not found in registry
185            return Json(ApiResponse::error(format!("Plugin not found: {}", plugin_id)));
186        }
187    };
188
189    if let Some(plugin_instance) = registry.get_plugin(&plugin_id_core) {
190        let details = json!({
191            "id": plugin_instance.manifest.info.id.as_str(),
192            "name": plugin_instance.manifest.info.name,
193            "version": plugin_instance.manifest.info.version.to_string(),
194            "description": plugin_instance.manifest.info.description,
195            "author": {
196                "name": plugin_instance.manifest.info.author.name,
197                "email": plugin_instance.manifest.info.author.email
198            },
199            "capabilities": plugin_instance.manifest.capabilities,
200            "dependencies": plugin_instance.manifest.dependencies.iter()
201                .map(|(id, version)| json!({
202                    "id": id.as_str(),
203                    "version": version.to_string()
204                }))
205                .collect::<Vec<_>>(),
206            "state": plugin_instance.state.to_string(),
207            "healthy": plugin_instance.is_healthy(),
208            "health": {
209                "state": plugin_instance.health.state.to_string(),
210                "healthy": plugin_instance.health.healthy,
211                "message": plugin_instance.health.message,
212                "last_check": plugin_instance.health.last_check.to_rfc3339(),
213                "metrics": {
214                    "total_executions": plugin_instance.health.metrics.total_executions,
215                    "successful_executions": plugin_instance.health.metrics.successful_executions,
216                    "failed_executions": plugin_instance.health.metrics.failed_executions,
217                    "avg_execution_time_ms": plugin_instance.health.metrics.avg_execution_time_ms,
218                    "max_execution_time_ms": plugin_instance.health.metrics.max_execution_time_ms,
219                    "memory_usage_bytes": plugin_instance.health.metrics.memory_usage_bytes,
220                    "peak_memory_usage_bytes": plugin_instance.health.metrics.peak_memory_usage_bytes
221                }
222            }
223        });
224
225        Json(ApiResponse::success(details))
226    } else {
227        Json(ApiResponse::error(format!("Plugin not found: {}", plugin_id)))
228    }
229}
230
231/// Delete a plugin
232pub async fn delete_plugin(
233    State(state): State<AdminState>,
234    Path(plugin_id): Path<String>,
235) -> Json<ApiResponse<serde_json::Value>> {
236    let mut registry = state.plugin_registry.write().await;
237    // Find plugin ID from registry - must exist to remove it
238    let plugin_id_core = match registry.list_plugins().iter().find(|id| id.as_str() == plugin_id) {
239        Some(id) => id.clone(),
240        None => {
241            return Json(ApiResponse::error(format!("Plugin not found: {}", plugin_id)));
242        }
243    };
244
245    match registry.remove_plugin(&plugin_id_core) {
246        Ok(removed_plugin) => Json(ApiResponse::success(json!({
247            "message": format!("Plugin '{}' removed successfully", plugin_id),
248            "plugin": {
249                "id": removed_plugin.id.as_str(),
250                "name": removed_plugin.manifest.info.name
251            }
252        }))),
253        Err(_) => Json(ApiResponse::error(format!("Failed to remove plugin: {}", plugin_id))),
254    }
255}
256
257/// Reload a plugin by removing and re-adding it to the registry
258///
259/// This resets the plugin's internal state. Note that this performs an
260/// in-memory reload — the plugin binary is not re-read from disk. To
261/// reload from disk, use the CLI plugin reload command.
262pub async fn reload_plugin(
263    State(state): State<AdminState>,
264    Json(payload): Json<ReloadPluginRequest>,
265) -> Json<ApiResponse<serde_json::Value>> {
266    let mut registry = state.plugin_registry.write().await;
267    // Find plugin ID from registry - must exist to reload it
268    let plugin_id_core = match registry
269        .list_plugins()
270        .iter()
271        .find(|id| id.as_str() == payload.plugin_id)
272    {
273        Some(id) => id.clone(),
274        None => {
275            return Json(ApiResponse::error(format!("Plugin not found: {}", payload.plugin_id)));
276        }
277    };
278
279    // Remove the plugin from the registry and re-add it to reset its state
280    match registry.remove_plugin(&plugin_id_core) {
281        Ok(mut instance) => {
282            // Reset health status to default (healthy, unloaded)
283            instance.health = Default::default();
284            let plugin_name = instance.manifest.info.name.clone();
285            match registry.add_plugin(instance) {
286                Ok(()) => Json(ApiResponse::success(json!({
287                    "message": format!("Plugin '{}' reloaded successfully", plugin_name),
288                    "status": "loaded"
289                }))),
290                Err(e) => Json(ApiResponse::error(format!(
291                    "Failed to re-register plugin '{}': {}",
292                    payload.plugin_id, e
293                ))),
294            }
295        }
296        Err(e) => Json(ApiResponse::error(format!(
297            "Failed to unload plugin '{}': {}",
298            payload.plugin_id, e
299        ))),
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_plugin_info_creation() {
309        let info = PluginInfo {
310            id: "test".to_string(),
311            name: "Test Plugin".to_string(),
312            version: "1.0.0".to_string(),
313            types: vec!["resolver".to_string()],
314            status: "ready".to_string(),
315            healthy: true,
316            description: "Test description".to_string(),
317            author: "Test Author".to_string(),
318        };
319
320        assert_eq!(info.id, "test");
321        assert_eq!(info.name, "Test Plugin");
322        assert_eq!(info.version, "1.0.0");
323        assert!(info.healthy);
324        assert_eq!(info.author, "Test Author");
325    }
326
327    #[test]
328    fn test_plugin_stats_creation() {
329        let stats = PluginStats {
330            total_plugins: 10,
331            discovered: 10,
332            loaded: 8,
333            failed: 2,
334            skipped: 0,
335            success_rate: 80.0,
336        };
337
338        assert_eq!(stats.total_plugins, 10);
339        assert_eq!(stats.discovered, 10);
340        assert_eq!(stats.loaded, 8);
341        assert_eq!(stats.failed, 2);
342        assert_eq!(stats.success_rate, 80.0);
343    }
344
345    #[test]
346    fn test_plugin_health_info_creation() {
347        let health = PluginHealthInfo {
348            id: "plugin-1".to_string(),
349            healthy: true,
350            message: "All good".to_string(),
351            last_check: "2024-01-01T00:00:00Z".to_string(),
352        };
353
354        assert_eq!(health.id, "plugin-1");
355        assert!(health.healthy);
356        assert_eq!(health.message, "All good");
357    }
358
359    #[test]
360    fn test_plugin_status_data_creation() {
361        let stats = PluginStats {
362            total_plugins: 5,
363            discovered: 5,
364            loaded: 5,
365            failed: 0,
366            skipped: 0,
367            success_rate: 100.0,
368        };
369
370        let status_data = PluginStatusData {
371            stats,
372            health: vec![],
373            last_updated: Some("2024-01-01T00:00:00Z".to_string()),
374        };
375
376        assert_eq!(status_data.stats.total_plugins, 5);
377        assert!(status_data.health.is_empty());
378    }
379
380    #[test]
381    fn test_reload_plugin_request() {
382        let req = ReloadPluginRequest {
383            plugin_id: "test-plugin".to_string(),
384        };
385
386        assert_eq!(req.plugin_id, "test-plugin");
387    }
388
389    #[test]
390    fn test_plugin_list_query_default() {
391        let query = PluginListQuery {
392            plugin_type: None,
393            status: None,
394        };
395
396        assert!(query.plugin_type.is_none());
397        assert!(query.status.is_none());
398    }
399
400    #[test]
401    fn test_plugin_list_query_with_filters() {
402        let query = PluginListQuery {
403            plugin_type: Some("resolver".to_string()),
404            status: Some("ready".to_string()),
405        };
406
407        assert_eq!(query.plugin_type, Some("resolver".to_string()));
408        assert_eq!(query.status, Some("ready".to_string()));
409    }
410}