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:
parent
780e5ec81d
commit
21e1e0ed1e
6 changed files with 230 additions and 10 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue