feat: onglet Réparation dans paramètres — gestion modules fantômes

- GET /api/repair/modules : liste les modules non-core en DB
- DELETE /api/repair/modules/{id} : supprime un module de la DB
- settings.html : onglet Réparation avec liste + bouton Supprimer
- app.js : loadRepair() + resetModule() dans settingsPage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-22 18:27:37 +01:00
parent ab834600ba
commit 3bc55a4c6f
4 changed files with 132 additions and 1 deletions

View file

@ -533,3 +533,52 @@ func (h *SettingsHandler) InstallRegistryModule(w http.ResponseWriter, r *http.R
"rebuilding": rebuilding,
})
}
// ── Réparation ────────────────────────────────────────────────────────────
// repairModule est une entrée retournée par GetRepairStatus.
type repairModule struct {
ID string `json:"id"`
Name string `json:"name"`
IsEnabled bool `json:"is_enabled"`
HasBackend bool `json:"has_backend"`
InstalledAt string `json:"installed_at"`
}
// GetRepairStatus retourne les modules non-core présents en DB (potentiellement fantômes).
// GET /api/repair/modules
func (h *SettingsHandler) GetRepairStatus(w http.ResponseWriter, r *http.Request) {
rows, err := h.db.Query(
`SELECT id, name, is_enabled, has_backend, COALESCE(installed_at,'') FROM modules WHERE is_core = 0`)
if err != nil {
JSONResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
defer rows.Close()
modules := []repairModule{}
for rows.Next() {
var m repairModule
rows.Scan(&m.ID, &m.Name, &m.IsEnabled, &m.HasBackend, &m.InstalledAt)
modules = append(modules, m)
}
JSONResponse(w, http.StatusOK, map[string]interface{}{"modules": modules})
}
// ResetModule supprime un module non-core de la DB (permet de le réinstaller proprement).
// DELETE /api/repair/modules/{id}
func (h *SettingsHandler) ResetModule(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
res, err := h.db.Exec(`DELETE FROM modules WHERE id = ? AND is_core = 0`, id)
if err != nil {
JSONResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
n, _ := res.RowsAffected()
if n == 0 {
JSONResponse(w, http.StatusNotFound, map[string]string{"error": "module introuvable ou module core"})
return
}
h.auditLogger.Log(r.Context(), "repair.reset_module", id, "")
JSONResponse(w, http.StatusOK, map[string]string{"message": "Module supprimé de la DB"})
}

View file

@ -185,6 +185,13 @@ func main() {
r.Post("/api/registry/modules/{id}/install", settingsHandler.InstallRegistryModule)
})
// Réparation DB — admin uniquement
r.Group(func(r chi.Router) {
r.Use(api.RequireAdmin)
r.Get("/api/repair/modules", settingsHandler.GetRepairStatus)
r.Delete("/api/repair/modules/{id}", settingsHandler.ResetModule)
})
// WebSocket — les routes WS extraient le token via query param
r.Get("/ws/proxmox", proxmoxHandler.WebSocket)
r.Get("/ws/updates/{jobId}", updatesHandler.WebSocketUpdate)

View file

@ -980,6 +980,9 @@ document.addEventListener('alpine:init', () => {
saved: false,
error: '',
shortcuts: [],
repairModules: [],
repairLoading: false,
resetting: {},
settings: {
instance_name: '',
public_url: '',
@ -1070,6 +1073,38 @@ document.addEventListener('alpine:init', () => {
}
},
async loadRepair() {
this.repairLoading = true
try {
const res = await apiFetch('/api/repair/modules')
if (res.ok) {
const data = await res.json()
this.repairModules = data.modules || []
}
} finally {
this.repairLoading = false
}
},
async resetModule(mod) {
if (!confirm(`Supprimer "${mod.name || mod.id}" de la base de données ?\nLe module pourra être réinstallé depuis le Store.`)) return
this.resetting[mod.id] = true
try {
const res = await apiFetch(`/api/repair/modules/${mod.id}`, { method: 'DELETE' })
if (res.ok) {
this.repairModules = this.repairModules.filter(m => m.id !== mod.id)
Alpine.store('toasts').success(`Module "${mod.name || mod.id}" supprimé de la DB`)
} else {
const d = await res.json().catch(() => ({}))
Alpine.store('toasts').error(d.error || 'Erreur suppression')
}
} catch(e) {
Alpine.store('toasts').error(e.message)
} finally {
this.resetting[mod.id] = false
}
},
t(key) { return Alpine.store('i18n').t(key) },
}))

View file

@ -71,6 +71,9 @@
<button class="tab-btn" :class="{ active: tab === 'shortcuts' }" @click="tab = 'shortcuts'">
<i class="lnid-link-1"></i> Raccourcis
</button>
<button class="tab-btn" :class="{ active: tab === 'repair' }" @click="tab = 'repair'; loadRepair()">
<i class="lnid-wrench-1"></i> Réparation
</button>
</div>
<!-- Général -->
@ -175,8 +178,45 @@
</div>
</div>
<!-- Réparation -->
<div class="tab-panel" x-show="tab === 'repair'">
<p class="form-hint" style="margin-bottom:1rem">
Les <strong>modules fantômes</strong> sont des modules présents en base de données
mais dont le code n'est pas chargé. Les supprimer permet de les réinstaller proprement.
</p>
<div class="loading-state" x-show="repairLoading">
<div class="spinner-lg"></div><span>Chargement…</span>
</div>
<div x-show="!repairLoading">
<p class="empty-state" x-show="repairModules.length === 0">Aucun module non-core en base de données.</p>
<div class="modules-grid" x-show="repairModules.length > 0">
<template x-for="mod in repairModules" :key="mod.id">
<div class="neu-card module-card">
<div class="module-header">
<div class="module-icon"><i class="lnid-puzzle"></i></div>
<div class="module-info">
<span class="module-name" x-text="mod.name || mod.id"></span>
<span class="module-version" x-text="mod.is_enabled ? 'Activé' : 'Désactivé'"></span>
<span class="module-desc" x-text="mod.installed_at ? 'Installé le ' + mod.installed_at.slice(0,10) : 'Date inconnue'"></span>
</div>
<div class="module-toggle">
<button class="neu-btn neu-btn--sm neu-btn--danger"
@click="resetModule(mod)"
:disabled="resetting[mod.id]">
<span x-show="resetting[mod.id]" class="spinner-sm"></span>
<i x-show="!resetting[mod.id]" class="lnid-trash-1"></i>
Supprimer de la DB
</button>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Save actions -->
<div class="save-bar" x-show="tab !== 'shortcuts'">
<div class="save-bar" x-show="tab !== 'shortcuts' && tab !== 'repair'">
<div class="save-feedback">
<span class="save-success" x-show="saved">
<i class="lnid-check-circle-1"></i> Paramètres sauvegardés