feat: Implementación completa de Soft Delete para apps

- Agregados campos deleted, deleted_at, deleted_reason a MonitoredApp
- Implementado soft_delete_app() y restore_app() en ConfigManager
- Modificado get_apps() para filtrar apps eliminadas por defecto
- Agregados métodos get_all_apps() y get_deleted_apps()
- Actualizado unregister_app() para usar soft delete en lugar de hard delete
- Creados endpoints:
  * GET /api/apps/deleted - Ver historial de apps eliminadas
  * POST /api/apps/:name/restore - Restaurar app eliminada
- Agregada sección de Historial en index.html con UI completa
- Botón de restaurar para cada app eliminada
- El servicio systemd se elimina físicamente, solo el JSON mantiene historial
- Permite auditoría y recuperación de apps eliminadas accidentalmente
This commit is contained in:
2026-01-19 19:40:47 -05:00
parent 60f38be957
commit 13b36dda5f
6 changed files with 365 additions and 7 deletions

View File

@@ -358,3 +358,52 @@ pub async fn get_system_error_logs() -> Result<Json<serde_json::Value>, StatusCo
}
}
}
/// Endpoint para obtener apps eliminadas (soft delete history)
pub async fn get_deleted_apps_handler() -> Result<Json<serde_json::Value>, StatusCode> {
use crate::config::get_config_manager;
let config_manager = get_config_manager();
let deleted_apps = config_manager.get_deleted_apps();
// Formatear respuesta con información de cada app eliminada
let apps_info: Vec<serde_json::Value> = deleted_apps.iter().map(|app| {
serde_json::json!({
"name": app.name,
"port": app.port,
"path": app.path,
"entry_point": app.entry_point,
"mode": app.mode,
"registered_at": app.registered_at,
"deleted_at": app.deleted_at,
"deleted_reason": app.deleted_reason,
})
}).collect();
Ok(Json(serde_json::json!({
"success": true,
"data": {
"apps": apps_info,
"total": apps_info.len()
}
})))
}
/// Endpoint para restaurar una app eliminada (soft delete)
pub async fn restore_app_handler(
Path(app_name): Path<String>,
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
use crate::config::get_config_manager;
let config_manager = get_config_manager();
match config_manager.restore_app(&app_name) {
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
app_name: app_name.clone(),
operation: "restore".to_string(),
success: true,
message: format!("Aplicación '{}' restaurada exitosamente. Nota: el servicio systemd debe ser recreado manualmente.", app_name),
}))),
Err(e) => Ok(Json(ApiResponse::error(e))),
}
}

View File

@@ -40,6 +40,19 @@ pub struct MonitoredApp {
#[serde(default, skip_serializing_if = "String::is_empty", rename = "reg")]
pub registered_at: String,
// --- SOFT DELETE FIELDS ---
/// Indica si la app fue eliminada (soft delete)
#[serde(default)]
pub deleted: bool,
/// Fecha de eliminación (ISO 8601)
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<String>,
/// Razón de eliminación (opcional)
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_reason: Option<String>,
// DEPRECATED: Mantener por compatibilidad con versiones antiguas
#[serde(skip_serializing_if = "Option::is_none")]
pub systemd_service: Option<String>,
@@ -131,11 +144,30 @@ impl ConfigManager {
Ok(())
}
/// Obtiene las apps activas (no eliminadas)
pub fn get_apps(&self) -> Vec<MonitoredApp> {
let config = self.config.read().unwrap();
config.apps.iter()
.filter(|app| !app.deleted)
.cloned()
.collect()
}
/// Obtiene TODAS las apps, incluyendo las eliminadas
pub fn get_all_apps(&self) -> Vec<MonitoredApp> {
let config = self.config.read().unwrap();
config.apps.clone()
}
/// Obtiene solo las apps eliminadas
pub fn get_deleted_apps(&self) -> Vec<MonitoredApp> {
let config = self.config.read().unwrap();
config.apps.iter()
.filter(|app| app.deleted)
.cloned()
.collect()
}
/// Agrega una app con información completa
pub fn add_app_full(&self, app: MonitoredApp) -> Result<(), String> {
let mut config = self.config.write().unwrap();
@@ -170,6 +202,9 @@ impl ConfigManager {
mode: "production".to_string(),
service_file_path: String::new(),
registered_at,
deleted: false,
deleted_at: None,
deleted_reason: None,
systemd_service: None,
created_at: None,
};
@@ -177,6 +212,55 @@ impl ConfigManager {
self.add_app_full(app)
}
/// Realiza un soft delete: marca la app como eliminada pero mantiene el registro
pub fn soft_delete_app(&self, name: &str, reason: Option<String>) -> Result<(), String> {
let mut config = self.config.write().unwrap();
// Buscar la app
let app = config.apps.iter_mut().find(|a| a.name == name && !a.deleted);
match app {
Some(app) => {
// Marcar como eliminada
app.deleted = true;
app.deleted_at = Some(chrono::Local::now().to_rfc3339());
app.deleted_reason = reason;
// Guardar en disco
match Self::save_config_to_file(&self.config_path, &config) {
Ok(_) => Ok(()),
Err(e) => Err(format!("Error al guardar configuración: {}", e))
}
}
None => Err(format!("La app '{}' no se encontró o ya está eliminada", name))
}
}
/// Restaura una app previamente eliminada (soft delete)
pub fn restore_app(&self, name: &str) -> Result<(), String> {
let mut config = self.config.write().unwrap();
// Buscar la app eliminada
let app = config.apps.iter_mut().find(|a| a.name == name && a.deleted);
match app {
Some(app) => {
// Restaurar
app.deleted = false;
app.deleted_at = None;
app.deleted_reason = None;
// Guardar en disco
match Self::save_config_to_file(&self.config_path, &config) {
Ok(_) => Ok(()),
Err(e) => Err(format!("Error al guardar configuración: {}", e))
}
}
None => Err(format!("La app '{}' no se encontró en apps eliminadas", name))
}
}
/// HARD DELETE: Elimina permanentemente una app del JSON (usar con precaución)
pub fn remove_app(&self, name: &str) -> Result<(), String> {
let mut config = self.config.write().unwrap();

View File

@@ -257,6 +257,9 @@ pub fn sync_discovered_services(services: Vec<DiscoveredService>) {
mode: service.node_env,
service_file_path: service.service_file.clone(),
registered_at,
deleted: false,
deleted_at: None,
deleted_reason: None,
systemd_service: None,
created_at: None,
};

View File

@@ -70,11 +70,13 @@ async fn main() {
.route("/api/monitored", get(api::get_monitored_apps_handler))
.route("/api/logs/errors", get(api::get_system_error_logs))
.route("/api/apps", get(api::list_apps_handler).post(api::register_app_handler))
.route("/api/apps/deleted", get(api::get_deleted_apps_handler))
.route("/api/apps/:name", delete(api::unregister_app_handler))
.route("/api/apps/:name/status", get(api::get_app_status_handler))
.route("/api/apps/:name/start", post(api::start_app_handler))
.route("/api/apps/:name/stop", post(api::stop_app_handler))
.route("/api/apps/:name/restart", post(api::restart_app_handler))
.route("/api/apps/:name/restore", post(api::restore_app_handler))
.route("/api/scan", get(api::scan_processes_handler))
.with_state(api_state);

View File

@@ -88,6 +88,9 @@ impl AppManager {
mode,
service_file_path,
registered_at,
deleted: false,
deleted_at: None,
deleted_reason: None,
systemd_service: None,
created_at: None,
};
@@ -104,7 +107,7 @@ impl AppManager {
pub fn unregister_app(&self, app_name: &str) -> Result<()> {
let logger = get_logger();
logger.info("AppManager", &format!("Desregistrando aplicación: {}", app_name));
logger.info("AppManager", &format!("Desregistrando aplicación (soft delete): {}", app_name));
// Obtener configuración
let config = self.apps.get(app_name)
@@ -119,7 +122,7 @@ impl AppManager {
// Deshabilitar el servicio
let _ = SystemCtl::disable(&service_name);
// Eliminar archivo de servicio
// Eliminar archivo de servicio (físicamente)
ServiceGenerator::delete_service_file(&service_name)?;
// Recargar daemon
@@ -128,13 +131,14 @@ impl AppManager {
// Eliminar de memoria
self.apps.remove(app_name);
// Eliminar de monitored_apps.json
// SOFT DELETE en monitored_apps.json (mantener historial)
let config_manager = get_config_manager();
if let Err(e) = config_manager.remove_app(app_name) {
logger.warning("AppManager", "No se pudo eliminar de monitored_apps.json", Some(&e));
let delete_reason = Some("Eliminada desde el panel de control".to_string());
if let Err(e) = config_manager.soft_delete_app(app_name, delete_reason) {
logger.warning("AppManager", "No se pudo hacer soft delete en monitored_apps.json", Some(&e));
}
logger.info("AppManager", &format!("Aplicación {} desregistrada exitosamente", app_name));
logger.info("AppManager", &format!("Aplicación {} desregistrada exitosamente (soft delete)", app_name));
Ok(())
}

View File

@@ -397,6 +397,100 @@
>
</div>
</div>
<!-- Historial de Apps Eliminadas Section -->
<div
id="deleted-apps-section"
class="mt-10 bg-white dark:bg-slate-800/30 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden hidden"
>
<div
class="p-6 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between"
>
<div class="flex items-center gap-3">
<div
class="size-10 rounded-full bg-red-500/10 flex items-center justify-center"
>
<span
class="material-symbols-outlined text-red-500"
>history</span
>
</div>
<div>
<h2
class="text-slate-900 dark:text-white text-xl font-bold"
>
Historial de Apps Eliminadas
</h2>
<p class="text-slate-500 text-sm">
Aplicaciones eliminadas que pueden ser
restauradas
</p>
</div>
</div>
<button
onclick="toggleDeletedApps()"
class="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
<span class="material-symbols-outlined"
>expand_more</span
>
</button>
</div>
<div id="deleted-apps-content" class="hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-slate-50 dark:bg-slate-800/50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider"
>
Aplicación
</th>
<th
class="px-6 py-3 text-left text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider"
>
Puerto
</th>
<th
class="px-6 py-3 text-left text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider"
>
Eliminada
</th>
<th
class="px-6 py-3 text-left text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider"
>
Razón
</th>
<th
class="px-6 py-3 text-right text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider"
>
Acciones
</th>
</tr>
</thead>
<tbody
id="deleted-apps-list"
class="divide-y divide-slate-200 dark:divide-slate-800"
>
<!-- Deleted apps will be loaded here -->
</tbody>
</table>
</div>
<div
id="deleted-apps-empty"
class="hidden p-8 text-center"
>
<span
class="material-symbols-outlined text-6xl text-slate-300 dark:text-slate-700 mb-3"
>check_circle</span
>
<p class="text-slate-500 dark:text-slate-400">
No hay apps eliminadas en el historial
</p>
</div>
</div>
</div>
<!-- Quick Action Links -->
<div class="mt-10 grid grid-cols-1 md:grid-cols-2 gap-6">
<div
@@ -800,6 +894,7 @@
if (result.success) {
alert(`${result.data.message}`);
loadApps();
loadDeletedApps(); // Recargar historial de eliminadas
} else {
alert(`❌ Error: ${result.error}`);
}
@@ -814,7 +909,128 @@
menu.classList.toggle("hidden");
}
window.addEventListener("DOMContentLoaded", loadApps);
// Funciones para apps eliminadas (soft delete)
async function loadDeletedApps() {
try {
const response = await fetch("/api/apps/deleted");
const result = await response.json();
if (
!result.success ||
!result.data ||
!result.data.apps ||
result.data.apps.length === 0
) {
// No hay apps eliminadas, ocultar sección
document
.getElementById("deleted-apps-section")
.classList.add("hidden");
return;
}
// Mostrar sección si hay apps eliminadas
document
.getElementById("deleted-apps-section")
.classList.remove("hidden");
const deletedAppsList =
document.getElementById("deleted-apps-list");
const emptyMessage =
document.getElementById("deleted-apps-empty");
deletedAppsList.innerHTML = result.data.apps
.map((app) => {
const deletedDate = app.deleted_at
? new Date(app.deleted_at).toLocaleString(
"es-ES",
)
: "Desconocida";
const reason =
app.deleted_reason || "Sin razón especificada";
return `
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="size-10 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
<span class="material-symbols-outlined text-slate-500 text-xl">deployed_code_history</span>
</div>
<div>
<p class="text-slate-900 dark:text-white font-semibold text-sm">${app.name}</p>
<p class="text-slate-500 text-xs">${app.path || "Sin ruta"}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<span class="text-slate-600 dark:text-slate-400 text-sm">${app.port}</span>
</td>
<td class="px-6 py-4">
<span class="text-slate-600 dark:text-slate-400 text-sm">${deletedDate}</span>
</td>
<td class="px-6 py-4">
<span class="text-slate-600 dark:text-slate-400 text-sm">${reason}</span>
</td>
<td class="px-6 py-4 text-right">
<button
onclick="restoreApp('${app.name}')"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-green-500 hover:bg-green-600 text-white text-xs font-medium rounded-lg transition-colors"
>
<span class="material-symbols-outlined text-sm">restore</span>
Restaurar
</button>
</td>
</tr>
`;
})
.join("");
emptyMessage.classList.add("hidden");
} catch (error) {
console.error("Error loading deleted apps:", error);
}
}
function toggleDeletedApps() {
const content = document.getElementById("deleted-apps-content");
content.classList.toggle("hidden");
}
async function restoreApp(appName) {
const confirmed = confirm(
`¿Estás seguro de restaurar la aplicación "${appName}"?\n\nNota: Solo se restaurará el registro en el JSON. El servicio systemd debe ser recreado manualmente.`,
);
if (!confirmed) return;
try {
const response = await fetch(
`/api/apps/${appName}/restore`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
const result = await response.json();
if (result.success) {
alert(`${result.data.message}`);
loadApps();
loadDeletedApps();
} else {
alert(`❌ Error: ${result.error}`);
}
} catch (error) {
console.error("Error:", error);
alert("❌ Error al restaurar la aplicación");
}
}
window.addEventListener("DOMContentLoaded", () => {
loadApps();
loadDeletedApps();
});
</script>
</body>
</html>