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:
parent
07af66ad81
commit
88831e3967
9 changed files with 200 additions and 27 deletions
|
|
@ -3,9 +3,11 @@ package api
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/logbuffer"
|
||||
"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é"})
|
||||
}
|
||||
|
||||
// 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é.
|
||||
// GET /api/settings/audit
|
||||
func (h *SettingsHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
('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),
|
||||
('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),
|
||||
('logs', 'Logs', 'Streaming de logs en temps réel', '1.0.0', 0, 0),
|
||||
('services', 'Services', 'Gestion des services systemd', '1.0.0', 0, 0);
|
||||
('settings', 'Paramètres', 'Configuration de l''application', '1.0.0', 1, 1);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
53
backend/internal/logbuffer/buffer.go
Normal file
53
backend/internal/logbuffer/buffer.go
Normal 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{}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -12,6 +13,7 @@ import (
|
|||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
|
||||
"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"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/modules"
|
||||
|
|
@ -21,6 +23,9 @@ import (
|
|||
)
|
||||
|
||||
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)
|
||||
dataDir := getEnv("DATA_DIR", "/app/data")
|
||||
|
||||
|
|
@ -150,6 +155,7 @@ func main() {
|
|||
r.Get("/api/settings", settingsHandler.GetAll)
|
||||
r.Put("/api/settings/{key}", settingsHandler.UpdateSetting)
|
||||
r.Get("/api/settings/audit", settingsHandler.GetAuditLog)
|
||||
r.Get("/api/settings/logs", settingsHandler.GetLogs)
|
||||
})
|
||||
|
||||
// Modules
|
||||
|
|
|
|||
|
|
@ -101,24 +101,6 @@ const navItems = computed(() => {
|
|||
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>`,
|
||||
},
|
||||
{
|
||||
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',
|
||||
path: '/settings',
|
||||
|
|
|
|||
|
|
@ -118,7 +118,11 @@
|
|||
"sidebarPosition": "Sidebar position",
|
||||
"left": "Left",
|
||||
"right": "Right",
|
||||
"noAuditLog": "No audit log entries"
|
||||
"noAuditLog": "No audit log entries",
|
||||
"logs": "Logs",
|
||||
"logsRefresh": "Auto-refresh",
|
||||
"logsNoRefresh": "Disabled",
|
||||
"noLogs": "No logs available"
|
||||
},
|
||||
"modules": {
|
||||
"desc": "Manage installed modules on ProxmoxPanel.",
|
||||
|
|
|
|||
|
|
@ -118,7 +118,11 @@
|
|||
"sidebarPosition": "Position de la sidebar",
|
||||
"left": "Gauche",
|
||||
"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": {
|
||||
"desc": "Gérez les modules installés sur ProxmoxPanel.",
|
||||
|
|
|
|||
|
|
@ -101,8 +101,32 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton sauvegarder (sauf audit) -->
|
||||
<div v-if="activeTab !== 'audit'" class="settings-actions">
|
||||
<!-- Logs applicatifs -->
|
||||
<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">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
|
|
@ -114,7 +138,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useUiStore } from '@/stores/ui.store'
|
||||
|
|
@ -127,12 +151,17 @@ const activeTab = ref('general')
|
|||
const saving = ref(false)
|
||||
const saveSuccess = ref(false)
|
||||
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 = [
|
||||
{ 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: '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: '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({
|
||||
|
|
@ -149,6 +178,18 @@ onMounted(async () => {
|
|||
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() {
|
||||
const res = await fetch('/api/settings', {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
|
|
@ -195,6 +236,39 @@ function setSidebar(pos: 'left' | 'right') {
|
|||
function formatDate(iso: string): string {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -251,6 +325,38 @@ function formatDate(iso: string): string {
|
|||
|
||||
.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) {
|
||||
.settings-layout { grid-template-columns: 1fr; }
|
||||
.settings-tabs { flex-direction: row; flex-wrap: wrap; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue