feat: sync DB prefs, update history tab, configurable dashboard shortcuts

- auth store fetchMe(): sync theme/sidebar_position/lang from DB to localStorage+stores on login/refresh
- profilePage setters: PATCH /api/auth/preferences on every preference change
- updatesPage: add history tab (GET /api/updates/history) with job list, click to view output
- dashboardPage: load shortcuts from settings API, fall back to defaults if none configured
- settingsPage: new Raccourcis tab to add/remove/configure dashboard shortcuts (saved as JSON)
- settings.go: expose dashboard_shortcuts in publicSettings for GET/PUT access
- pages.css: add .history-table, .shortcut-row styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-22 00:35:24 +01:00
parent 780e5ec81d
commit 21e1e0ed1e
6 changed files with 230 additions and 10 deletions

View file

@ -32,6 +32,7 @@ var publicSettings = []string{
"proxmox_url", "proxmox_url",
"ssh_host", "ssh_host",
"ssh_username", "ssh_username",
"dashboard_shortcuts",
} }
// paramètres sensibles : modifiables en écriture seule, stockés chiffrés. // paramètres sensibles : modifiables en écriture seule, stockés chiffrés.

View file

@ -732,3 +732,44 @@
display: block; display: block;
line-height: 1; line-height: 1;
} }
/* ── Historique mises à jour ─────────────────────────────────────────────────── */
.history-table { display: flex; flex-direction: column; gap: 0; }
.history-header, .history-row {
display: grid;
grid-template-columns: 6rem 1fr 6rem 10rem 5rem;
gap: .5rem;
align-items: center;
padding: .5rem .75rem;
}
.history-header {
font-size: .75rem;
font-weight: 600;
color: var(--neu-text-muted);
text-transform: uppercase;
letter-spacing: .04em;
border-bottom: 1px solid var(--neu-border);
}
.history-row {
font-size: .85rem;
cursor: pointer;
border-bottom: 1px solid var(--neu-border);
transition: background .15s;
}
.history-row:last-child { border-bottom: none; }
.history-row:hover { background: var(--neu-bg-hover); border-radius: var(--neu-radius); }
.history-job { font-family: monospace; opacity: .7; }
.history-status.running { color: var(--neu-info); }
.history-status.success { color: var(--neu-success); }
.history-status.error { color: var(--neu-danger); }
/* ── Éditeur de raccourcis ───────────────────────────────────────────────────── */
.shortcuts-editor { display: flex; flex-direction: column; gap: .5rem; }
.shortcut-row {
display: grid;
grid-template-columns: 9rem 1fr 1fr 2rem;
gap: .5rem;
align-items: center;
}
.shortcut-icon-sel { padding: .4rem .5rem; font-size: .85rem; }
.shortcut-label, .shortcut-href { font-size: .85rem; }

View file

@ -140,15 +140,12 @@
<div class="widget-links"> <div class="widget-links">
<h4 class="widget-title" x-text="t('dashboard.widgetShortcut')"></h4> <h4 class="widget-title" x-text="t('dashboard.widgetShortcut')"></h4>
<div class="links-grid"> <div class="links-grid">
<a class="neu-btn link-btn" href="/proxmox.html" @click.prevent="navigate('/proxmox.html')"> <template x-for="s in displayShortcuts" :key="s.href">
<i class="lnid-server-1"></i> Proxmox <a class="neu-btn link-btn" :href="s.href" @click.prevent="navigate(s.href)">
</a> <i :class="s.icon"></i>
<a class="neu-btn link-btn" href="/terminal.html" @click.prevent="navigate('/terminal.html')"> <span x-text="s.label"></span>
<i class="lnid-terminal"></i> Terminal </a>
</a> </template>
<a class="neu-btn link-btn" href="/updates.html" @click.prevent="navigate('/updates.html')">
<i class="lnid-arrow-upward"></i> Updates
</a>
</div> </div>
</div> </div>
</template> </template>

View file

@ -118,6 +118,19 @@ document.addEventListener('alpine:init', () => {
const res = await apiFetch('/api/auth/me') const res = await apiFetch('/api/auth/me')
if (res.ok) { if (res.ok) {
this.user = await res.json() this.user = await res.json()
// Sync préférences DB → localStorage + stores
const u = this.user
if (u.theme && u.theme !== Alpine.store('ui').theme) {
localStorage.setItem('pxp_theme', u.theme)
Alpine.store('ui').theme = u.theme
Alpine.store('ui').applyTheme()
}
if (u.sidebar_position && u.sidebar_position !== Alpine.store('ui').sidebarPosition) {
Alpine.store('ui').setSidebarPosition(u.sidebar_position)
}
if (u.lang && u.lang !== Alpine.store('i18n').lang) {
Alpine.store('i18n').setLang(u.lang)
}
} else { } else {
// Token expiré, invalide, ou toute autre erreur → tenter un refresh // Token expiré, invalide, ou toute autre erreur → tenter un refresh
await this.tryRefresh() await this.tryRefresh()
@ -400,6 +413,7 @@ document.addEventListener('alpine:init', () => {
dragSrcIdx: null, dragSrcIdx: null,
_dragOriginal: null, _dragOriginal: null,
hoveredWidgetId: null, hoveredWidgetId: null,
shortcuts: [],
widgets: (function() { widgets: (function() {
const defaults = [ const defaults = [
@ -485,12 +499,34 @@ document.addEventListener('alpine:init', () => {
async init() { async init() {
await this.fetchResources() await this.fetchResources()
await this.connectWS() await this.connectWS()
await this.loadShortcuts()
}, },
destroy() { destroy() {
if (this._unsubWS) { this._unsubWS(); this._unsubWS = null } if (this._unsubWS) { this._unsubWS(); this._unsubWS = null }
}, },
async loadShortcuts() {
try {
const res = await apiFetch('/api/settings')
if (res.ok) {
const data = await res.json()
if (data.dashboard_shortcuts) {
const parsed = JSON.parse(data.dashboard_shortcuts)
if (Array.isArray(parsed) && parsed.length > 0) this.shortcuts = parsed
}
}
} catch(e) { /* use defaults */ }
},
get displayShortcuts() {
return this.shortcuts.length ? this.shortcuts : [
{ href: '/proxmox.html', icon: 'lnid-server-1', label: 'Proxmox' },
{ href: '/terminal.html', icon: 'lnid-terminal', label: 'Terminal' },
{ href: '/updates.html', icon: 'lnid-arrow-upward', label: 'Updates' },
]
},
async fetchResources() { async fetchResources() {
try { try {
const res = await apiFetch('/api/proxmox/resources') const res = await apiFetch('/api/proxmox/resources')
@ -543,16 +579,19 @@ document.addEventListener('alpine:init', () => {
Alpine.store('ui').theme = t Alpine.store('ui').theme = t
Alpine.store('ui').applyTheme() Alpine.store('ui').applyTheme()
localStorage.setItem('pxp_theme', t) localStorage.setItem('pxp_theme', t)
apiFetch('/api/auth/preferences', { method: 'PATCH', body: JSON.stringify({ theme: t }) }).catch(() => {})
}, },
setSidebarPosition(pos) { setSidebarPosition(pos) {
this.sidebarPosition = pos this.sidebarPosition = pos
Alpine.store('ui').setSidebarPosition(pos) Alpine.store('ui').setSidebarPosition(pos)
apiFetch('/api/auth/preferences', { method: 'PATCH', body: JSON.stringify({ sidebar_position: pos }) }).catch(() => {})
}, },
async setLang(lang) { async setLang(lang) {
this.lang = lang this.lang = lang
await Alpine.store('i18n').setLang(lang) await Alpine.store('i18n').setLang(lang)
apiFetch('/api/auth/preferences', { method: 'PATCH', body: JSON.stringify({ lang }) }).catch(() => {})
}, },
async loadSessions() { async loadSessions() {
@ -664,6 +703,9 @@ document.addEventListener('alpine:init', () => {
currentJob: null, currentJob: null,
output: '', output: '',
jobStatus: '', jobStatus: '',
activeTab: 'targets',
history: [],
historyLoading: false,
async init() { async init() {
await this.loadTargets() await this.loadTargets()
@ -791,6 +833,21 @@ document.addEventListener('alpine:init', () => {
}) })
}, },
async loadHistory() {
this.historyLoading = true
try {
const res = await apiFetch('/api/updates/history')
if (res.ok) this.history = await res.json() || []
} catch(e) { /* ignore */ }
this.historyLoading = false
},
formatDate(iso) {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
},
get totalPackages() { get totalPackages() {
return this.targets.reduce((sum, t) => sum + (t.packages?.length || 0), 0) return this.targets.reduce((sum, t) => sum + (t.packages?.length || 0), 0)
}, },
@ -805,6 +862,7 @@ document.addEventListener('alpine:init', () => {
saving: false, saving: false,
saved: false, saved: false,
error: '', error: '',
shortcuts: [],
settings: { settings: {
instance_name: '', instance_name: '',
public_url: '', public_url: '',
@ -827,6 +885,9 @@ document.addEventListener('alpine:init', () => {
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
Object.assign(this.settings, data) Object.assign(this.settings, data)
if (data.dashboard_shortcuts) {
try { this.shortcuts = JSON.parse(data.dashboard_shortcuts) } catch(e) { this.shortcuts = [] }
}
} }
} finally { } finally {
this.loading = false this.loading = false
@ -862,6 +923,36 @@ document.addEventListener('alpine:init', () => {
} }
}, },
addShortcut() {
this.shortcuts.push({ label: '', href: '', icon: 'lnid-link-1' })
},
removeShortcut(idx) {
this.shortcuts.splice(idx, 1)
},
async saveShortcuts() {
this.saving = true
this.saved = false
this.error = ''
try {
const res = await apiFetch('/api/settings/dashboard_shortcuts', {
method: 'PUT',
body: JSON.stringify({ value: JSON.stringify(this.shortcuts) }),
})
if (!res.ok) {
const d = await res.json().catch(() => ({}))
throw new Error(d.error || 'Erreur sauvegarde raccourcis')
}
this.saved = true
setTimeout(() => { this.saved = false }, 3000)
} catch(e) {
this.error = e.message
} finally {
this.saving = false
}
},
t(key) { return Alpine.store('i18n').t(key) }, t(key) { return Alpine.store('i18n').t(key) },
})) }))

View file

@ -68,6 +68,9 @@
<button class="tab-btn" :class="{ active: tab === 'general' }" @click="tab = 'general'">Général</button> <button class="tab-btn" :class="{ active: tab === 'general' }" @click="tab = 'general'">Général</button>
<button class="tab-btn" :class="{ active: tab === 'ssh' }" @click="tab = 'ssh'">SSH</button> <button class="tab-btn" :class="{ active: tab === 'ssh' }" @click="tab = 'ssh'">SSH</button>
<button class="tab-btn" :class="{ active: tab === 'proxmox' }" @click="tab = 'proxmox'">Proxmox API</button> <button class="tab-btn" :class="{ active: tab === 'proxmox' }" @click="tab = 'proxmox'">Proxmox API</button>
<button class="tab-btn" :class="{ active: tab === 'shortcuts' }" @click="tab = 'shortcuts'">
<i class="lnid-link-1"></i> Raccourcis
</button>
</div> </div>
<!-- Général --> <!-- Général -->
@ -128,8 +131,52 @@
</div> </div>
</div> </div>
<!-- Raccourcis dashboard -->
<div class="tab-panel" x-show="tab === 'shortcuts'">
<p class="form-hint" style="margin-bottom:1rem">
Ces raccourcis apparaissent dans le widget "Raccourcis" du dashboard.
Laisser vide pour utiliser les raccourcis par défaut.
</p>
<div class="shortcuts-editor">
<template x-for="(s, idx) in shortcuts" :key="idx">
<div class="shortcut-row">
<select class="neu-input shortcut-icon-sel" x-model="s.icon">
<option value="lnid-link-1">Lien</option>
<option value="lnid-server-1">Serveur</option>
<option value="lnid-terminal">Terminal</option>
<option value="lnid-arrow-upward">Mises à jour</option>
<option value="lnid-gear-1">Paramètres</option>
<option value="lnid-dashboard-square-1">Dashboard</option>
<option value="lnid-puzzle">Module</option>
<option value="lnid-folder-1">Fichiers</option>
<option value="lnid-check-circle-1">Succès</option>
</select>
<input class="neu-input shortcut-label" type="text" x-model="s.label" placeholder="Label" />
<input class="neu-input shortcut-href" type="text" x-model="s.href" placeholder="/page.html" />
<button class="neu-btn neu-btn--sm neu-btn--danger neu-btn--icon-sm"
@click="removeShortcut(idx)" title="Supprimer">
<i class="lnid-cross"></i>
</button>
</div>
</template>
<button class="neu-btn" @click="addShortcut()">
<i class="lnid-plus"></i> Ajouter un raccourci
</button>
</div>
<div class="save-bar" style="margin-top:1rem">
<div class="save-feedback">
<span class="save-success" x-show="saved"><i class="lnid-check-circle-1"></i> Raccourcis sauvegardés</span>
<span class="save-error" x-show="error" x-text="error"></span>
</div>
<button class="neu-btn neu-btn--primary" @click="saveShortcuts()" :disabled="saving">
<span x-show="!saving"><i class="lnid-check-circle-1"></i> Sauvegarder</span>
<span x-show="saving"><span class="spinner-sm"></span></span>
</button>
</div>
</div>
<!-- Save actions --> <!-- Save actions -->
<div class="save-bar"> <div class="save-bar" x-show="tab !== 'shortcuts'">
<div class="save-feedback"> <div class="save-feedback">
<span class="save-success" x-show="saved"> <span class="save-success" x-show="saved">
<i class="lnid-check-circle-1"></i> Paramètres sauvegardés <i class="lnid-check-circle-1"></i> Paramètres sauvegardés

View file

@ -55,6 +55,17 @@
<main id="swup" class="page-content transition-fade"> <main id="swup" class="page-content transition-fade">
<div x-data="updatesPage()" x-cloak> <div x-data="updatesPage()" x-cloak>
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn" :class="{ active: activeTab === 'targets' }" @click="activeTab = 'targets'">Cibles</button>
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'; loadHistory()">
<i class="lnid-clock-1"></i> Historique
</button>
</div>
<!-- Tab: Cibles -->
<div x-show="activeTab === 'targets'">
<!-- Actions globales --> <!-- Actions globales -->
<div class="page-actions"> <div class="page-actions">
<div class="page-actions-left"> <div class="page-actions-left">
@ -150,6 +161,38 @@
</div> </div>
<pre class="output-content" x-text="output" x-ref="output"></pre> <pre class="output-content" x-text="output" x-ref="output"></pre>
</div> </div>
</div><!-- /tab targets -->
<!-- Tab: Historique -->
<div x-show="activeTab === 'history'">
<div class="loading-state" x-show="historyLoading">
<div class="spinner-lg"></div>
<span>Chargement…</span>
</div>
<div x-show="!historyLoading">
<p class="empty-state" x-show="history.length === 0">Aucune mise à jour effectuée</p>
<div class="history-table" x-show="history.length > 0">
<div class="history-header">
<span>Job</span>
<span>Cible</span>
<span>Statut</span>
<span>Date</span>
<span>Durée</span>
</div>
<template x-for="h in history" :key="h.job_id">
<div class="history-row" @click="output = h.output; currentJob = h.job_id; jobStatus = h.status; activeTab = 'targets'">
<span class="history-job" x-text="h.job_id.slice(0,8)"></span>
<span class="history-target" x-text="h.target"></span>
<span class="history-status badge" :class="h.status" x-text="h.status"></span>
<span class="history-date" x-text="formatDate(h.started_at)"></span>
<span class="history-dur" x-text="h.finished_at ? '' : '—'"></span>
</div>
</template>
</div>
</div>
</div><!-- /tab history -->
</div> </div>
</main> </main>
</div> </div>