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

@ -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',

View file

@ -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.",

View file

@ -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.",

View file

@ -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; }