feat: nettoyage menu + suppression modules inexistants + log viewer

- Sidebar : retrait des liens files, logs, services (non implémentés)
- Migration 001 : suppression des inserts files/logs/services
- Migration 002 : DELETE des modules inexistants en DB existante
- logbuffer : ring buffer mémoire branché sur log.SetOutput
- GET /api/settings/logs : retourne les 300 dernières lignes de log
- Settings : onglet Logs avec auto-refresh (5s/10s/30s/60s/désactivé, défaut 10s)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-20 23:57:07 +01:00
parent 07af66ad81
commit 88831e3967
9 changed files with 200 additions and 27 deletions

View file

@ -3,9 +3,11 @@ package api
import ( import (
"net/http" "net/http"
"strconv"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db" "git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/logbuffer"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@ -168,6 +170,22 @@ func (h *SettingsHandler) DisableModule(w http.ResponseWriter, r *http.Request)
JSONResponse(w, http.StatusOK, map[string]string{"message": "Module désactivé"}) JSONResponse(w, http.StatusOK, map[string]string{"message": "Module désactivé"})
} }
// GetLogs retourne les dernières lignes du log applicatif (tampon mémoire).
// GET /api/settings/logs?lines=200
func (h *SettingsHandler) GetLogs(w http.ResponseWriter, r *http.Request) {
n := 200
if s := r.URL.Query().Get("lines"); s != "" {
if v, err := strconv.Atoi(s); err == nil && v > 0 {
n = v
}
}
lines := logbuffer.Global.Lines(n)
if lines == nil {
lines = []string{}
}
JSONResponse(w, http.StatusOK, lines)
}
// GetAuditLog retourne le journal d'audit paginé. // GetAuditLog retourne le journal d'audit paginé.
// GET /api/settings/audit // GET /api/settings/audit
func (h *SettingsHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) { func (h *SettingsHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {

View file

@ -93,8 +93,5 @@ INSERT OR IGNORE INTO modules (id, name, description, version, is_core, is_enabl
('dashboard', 'Dashboard', 'Tableau de bord avec widgets configurables', '1.0.0', 1, 1), ('dashboard', 'Dashboard', 'Tableau de bord avec widgets configurables', '1.0.0', 1, 1),
('proxmox', 'Proxmox', 'Gestion des LXC et VM Proxmox', '1.0.0', 1, 1), ('proxmox', 'Proxmox', 'Gestion des LXC et VM Proxmox', '1.0.0', 1, 1),
('updates', 'Mises à jour', 'Mises à jour de paquets apt avec streaming', '1.0.0', 1, 1), ('updates', 'Mises à jour', 'Mises à jour de paquets apt avec streaming', '1.0.0', 1, 1),
('settings', 'Paramètres', 'Configuration de l''application', '1.0.0', 1, 1),
('files', 'Fichiers', 'Navigateur de fichiers SFTP', '1.0.0', 0, 0),
('terminal', 'Terminal', 'Terminal SSH interactif', '1.0.0', 0, 0), ('terminal', 'Terminal', 'Terminal SSH interactif', '1.0.0', 0, 0),
('logs', 'Logs', 'Streaming de logs en temps réel', '1.0.0', 0, 0), ('settings', 'Paramètres', 'Configuration de l''application', '1.0.0', 1, 1);
('services', 'Services', 'Gestion des services systemd', '1.0.0', 0, 0);

View file

@ -0,0 +1,3 @@
-- Migration 002 : Suppression des modules non implémentés
-- Les modules files, logs et services n'ont pas d'implémentation backend.
DELETE FROM modules WHERE id IN ('files', 'logs', 'services');

View file

@ -0,0 +1,53 @@
// Package logbuffer maintient un tampon circulaire en mémoire des lignes de log.
// Il implémente io.Writer pour être branché sur log.SetOutput via io.MultiWriter.
package logbuffer
import (
"bytes"
"sync"
)
const maxLines = 500
// Buffer est un tampon circulaire thread-safe de lignes de log.
type Buffer struct {
mu sync.RWMutex
lines []string
}
// Write implémente io.Writer. Découpe p en lignes et les ajoute au tampon.
func (b *Buffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
for _, line := range bytes.Split(p, []byte("\n")) {
s := string(bytes.TrimRight(line, "\r"))
if s == "" {
continue
}
b.lines = append(b.lines, s)
if len(b.lines) > maxLines {
b.lines = b.lines[len(b.lines)-maxLines:]
}
}
return len(p), nil
}
// Lines retourne les n dernières lignes (ou toutes si n <= 0 ou n > taille).
func (b *Buffer) Lines(n int) []string {
b.mu.RLock()
defer b.mu.RUnlock()
total := len(b.lines)
if n <= 0 || n >= total {
result := make([]string, total)
copy(result, b.lines)
return result
}
result := make([]string, n)
copy(result, b.lines[total-n:])
return result
}
// Global est l'instance partagée utilisée par main.go et les handlers.
var Global = &Buffer{}

View file

@ -4,6 +4,7 @@
package main package main
import ( import (
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -12,6 +13,7 @@ import (
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth" "git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto" "git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db" "git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/logbuffer"
sshpool "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh" sshpool "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket" "git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket"
"git.geronzi.fr/proxmoxPanel/core/backend/modules" "git.geronzi.fr/proxmoxPanel/core/backend/modules"
@ -21,6 +23,9 @@ import (
) )
func main() { func main() {
// Brancher le buffer de logs (stderr + mémoire) avant tout autre log
log.SetOutput(io.MultiWriter(os.Stderr, logbuffer.Global))
// Répertoire de données persistantes (volume Docker) // Répertoire de données persistantes (volume Docker)
dataDir := getEnv("DATA_DIR", "/app/data") dataDir := getEnv("DATA_DIR", "/app/data")
@ -150,6 +155,7 @@ func main() {
r.Get("/api/settings", settingsHandler.GetAll) r.Get("/api/settings", settingsHandler.GetAll)
r.Put("/api/settings/{key}", settingsHandler.UpdateSetting) r.Put("/api/settings/{key}", settingsHandler.UpdateSetting)
r.Get("/api/settings/audit", settingsHandler.GetAuditLog) r.Get("/api/settings/audit", settingsHandler.GetAuditLog)
r.Get("/api/settings/logs", settingsHandler.GetLogs)
}) })
// Modules // Modules

View file

@ -101,24 +101,6 @@ const navItems = computed(() => {
label: 'nav.terminal', label: 'nav.terminal',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`, icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
}, },
{
name: 'files',
path: '/files',
label: 'nav.files',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
},
{
name: 'logs',
path: '/logs',
label: 'nav.logs',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
},
{
name: 'services',
path: '/services',
label: 'nav.services',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93A10 10 0 1 0 4.93 19.07"/></svg>`,
},
{ {
name: 'settings', name: 'settings',
path: '/settings', path: '/settings',

View file

@ -118,7 +118,11 @@
"sidebarPosition": "Sidebar position", "sidebarPosition": "Sidebar position",
"left": "Left", "left": "Left",
"right": "Right", "right": "Right",
"noAuditLog": "No audit log entries" "noAuditLog": "No audit log entries",
"logs": "Logs",
"logsRefresh": "Auto-refresh",
"logsNoRefresh": "Disabled",
"noLogs": "No logs available"
}, },
"modules": { "modules": {
"desc": "Manage installed modules on ProxmoxPanel.", "desc": "Manage installed modules on ProxmoxPanel.",

View file

@ -118,7 +118,11 @@
"sidebarPosition": "Position de la sidebar", "sidebarPosition": "Position de la sidebar",
"left": "Gauche", "left": "Gauche",
"right": "Droite", "right": "Droite",
"noAuditLog": "Aucune entrée dans le journal" "noAuditLog": "Aucune entrée dans le journal",
"logs": "Logs",
"logsRefresh": "Rafraîchissement",
"logsNoRefresh": "Désactivé",
"noLogs": "Aucun log disponible"
}, },
"modules": { "modules": {
"desc": "Gérez les modules installés sur ProxmoxPanel.", "desc": "Gérez les modules installés sur ProxmoxPanel.",

View file

@ -101,8 +101,32 @@
</div> </div>
</div> </div>
<!-- Bouton sauvegarder (sauf audit) --> <!-- Logs applicatifs -->
<div v-if="activeTab !== 'audit'" class="settings-actions"> <div v-if="activeTab === 'logs'">
<div class="logs-header">
<h3>{{ t('settings.logs') }}</h3>
<div class="logs-controls">
<label class="text-muted">{{ t('settings.logsRefresh') }}</label>
<select v-model="logsRefreshInterval" class="neu-input neu-input--sm" @change="resetLogsRefresh">
<option :value="5000">5s</option>
<option :value="10000">10s</option>
<option :value="30000">30s</option>
<option :value="60000">60s</option>
<option :value="0">{{ t('settings.logsNoRefresh') }}</option>
</select>
<button class="neu-btn neu-btn--sm" @click="loadLogs">{{ t('common.refresh') }}</button>
</div>
</div>
<div class="logs-viewer neu-inset" ref="logsViewerRef">
<p v-if="logLines.length === 0" class="text-muted logs-empty">{{ t('settings.noLogs') }}</p>
<div v-else class="logs-lines">
<span v-for="(line, i) in logLines" :key="i" class="log-line">{{ line }}</span>
</div>
</div>
</div>
<!-- Bouton sauvegarder (sauf audit et logs) -->
<div v-if="activeTab !== 'audit' && activeTab !== 'logs'" class="settings-actions">
<button class="neu-btn neu-btn--primary" :disabled="saving" @click="saveSettings"> <button class="neu-btn neu-btn--primary" :disabled="saving" @click="saveSettings">
{{ saving ? t('common.saving') : t('common.save') }} {{ saving ? t('common.saving') : t('common.save') }}
</button> </button>
@ -114,7 +138,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth.store' import { useAuthStore } from '@/stores/auth.store'
import { useUiStore } from '@/stores/ui.store' import { useUiStore } from '@/stores/ui.store'
@ -127,12 +151,17 @@ const activeTab = ref('general')
const saving = ref(false) const saving = ref(false)
const saveSuccess = ref(false) const saveSuccess = ref(false)
const auditLog = ref<any[]>([]) const auditLog = ref<any[]>([])
const logLines = ref<string[]>([])
const logsRefreshInterval = ref(10000)
const logsViewerRef = ref<HTMLElement | null>(null)
let logsTimer: ReturnType<typeof setInterval> | null = null
const tabs = [ const tabs = [
{ id: 'general', label: 'settings.general', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>` }, { id: 'general', label: 'settings.general', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>` },
{ id: 'infrastructure', label: 'settings.infrastructure', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/></svg>` }, { id: 'infrastructure', label: 'settings.infrastructure', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/></svg>` },
{ id: 'appearance', label: 'settings.appearance', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/></svg>` }, { id: 'appearance', label: 'settings.appearance', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/></svg>` },
{ id: 'audit', label: 'settings.audit', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12"/></svg>` }, { id: 'audit', label: 'settings.audit', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12"/></svg>` },
{ id: 'logs', label: 'settings.logs', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>` },
] ]
const settings = ref({ const settings = ref({
@ -149,6 +178,18 @@ onMounted(async () => {
await loadAuditLog() await loadAuditLog()
}) })
// Charger les logs quand on active l'onglet + gérer le timer
watch(activeTab, (tab) => {
if (tab === 'logs') {
loadLogs()
startLogsRefresh()
} else {
stopLogsRefresh()
}
})
onUnmounted(() => stopLogsRefresh())
async function loadSettings() { async function loadSettings() {
const res = await fetch('/api/settings', { const res = await fetch('/api/settings', {
headers: { Authorization: `Bearer ${authStore.accessToken}` }, headers: { Authorization: `Bearer ${authStore.accessToken}` },
@ -195,6 +236,39 @@ function setSidebar(pos: 'left' | 'right') {
function formatDate(iso: string): string { function formatDate(iso: string): string {
return new Date(iso).toLocaleString() return new Date(iso).toLocaleString()
} }
async function loadLogs() {
const res = await fetch('/api/settings/logs?lines=300', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
logLines.value = await res.json() || []
// Scroll vers le bas après rendu
await nextTick()
if (logsViewerRef.value) {
logsViewerRef.value.scrollTop = logsViewerRef.value.scrollHeight
}
}
}
function startLogsRefresh() {
stopLogsRefresh()
if (logsRefreshInterval.value > 0) {
logsTimer = setInterval(loadLogs, logsRefreshInterval.value)
}
}
function stopLogsRefresh() {
if (logsTimer) {
clearInterval(logsTimer)
logsTimer = null
}
}
function resetLogsRefresh() {
stopLogsRefresh()
startLogsRefresh()
}
</script> </script>
<style scoped> <style scoped>
@ -251,6 +325,38 @@ function formatDate(iso: string): string {
.text-muted { color: var(--neu-text-muted); } .text-muted { color: var(--neu-text-muted); }
.logs-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--neu-space-md); flex-wrap: wrap; gap: var(--neu-space-sm); }
.logs-header h3 { margin-bottom: 0; }
.logs-controls { display: flex; align-items: center; gap: var(--neu-space-sm); }
.neu-input--sm { padding: 4px 8px; font-size: var(--neu-font-sm); height: auto; }
.logs-viewer {
height: 420px;
overflow-y: auto;
padding: var(--neu-space-sm);
font-family: monospace;
font-size: 12px;
line-height: 1.5;
}
.logs-empty { padding: var(--neu-space-sm); }
.logs-lines {
display: flex;
flex-direction: column;
gap: 1px;
}
.log-line {
white-space: pre-wrap;
word-break: break-all;
color: var(--neu-text);
padding: 1px 0;
border-bottom: 1px solid var(--neu-border);
}
.log-line:last-child { border-bottom: none; }
@media (max-width: 640px) { @media (max-width: 640px) {
.settings-layout { grid-template-columns: 1fr; } .settings-layout { grid-template-columns: 1fr; }
.settings-tabs { flex-direction: row; flex-wrap: wrap; } .settings-tabs { flex-direction: row; flex-wrap: wrap; }