feat: add Services and Logs modules (systemctl + journalctl via SSH)

Backend:
- modules/services: list, status, start/stop/restart systemctl services
  with pct exec support for LXC targets
- modules/logs: journalctl unit listing + WebSocket live streaming
  (direct SSH connection, journalctl -f, graceful teardown on WS close)
- migrations/003: seed services and logs modules in DB
- main.go: register services.New() and logs.New() in module loader

Frontend:
- services.html: target selector, search/filter, services table with
  active state indicators and start/stop/restart buttons
- logs.html: target + unit selectors, live follow toggle, scrollable
  terminal output with 3000-line cap
- app.js: servicePage() and logsPage() Alpine components + navItems
- locales: services and logs i18n keys (fr + en)
- pages.css: services table, state dots, logs output styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-22 02:03:55 +01:00
parent 98cdabf3e1
commit 5836f2201a
10 changed files with 1012 additions and 2 deletions

View file

@ -328,6 +328,8 @@ document.addEventListener('alpine:init', () => {
{ id: 'proxmox', iconClass: 'lnid-server-1', iconColor: '#22c55e', labelKey: 'nav.proxmox', href: '/proxmox.html' },
{ id: 'updates', iconClass: 'lnid-arrow-upward', iconColor: '#f59e0b', labelKey: 'nav.updates', href: '/updates.html' },
{ id: 'terminal', iconClass: 'lnid-terminal', iconColor: '#a78bfa', labelKey: 'nav.terminal', href: '/terminal.html' },
{ id: 'services', iconClass: 'lnid-gear-loading', iconColor: '#fb923c', labelKey: 'nav.services', href: '/services.html' },
{ id: 'logs', iconClass: 'lnid-scroll-document-1', iconColor: '#38bdf8', labelKey: 'nav.logs', href: '/logs.html' },
{ id: 'settings', iconClass: 'lnid-gear-1', iconColor: '#94a3b8', labelKey: 'nav.settings', href: '/settings.html' },
{ id: 'modules', iconClass: 'lnid-puzzle', iconColor: '#f472b6', labelKey: 'nav.modules', href: '/modules.html' },
],
@ -1083,6 +1085,169 @@ document.addEventListener('alpine:init', () => {
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: servicePage ──────────────────────────────────────────────
Alpine.data('servicePage', () => ({
services: [],
loading: false,
target: 'host',
targets: [{ value: 'host', label: 'Host Proxmox' }],
filter: '',
actioning: {},
get filtered() {
const q = this.filter.toLowerCase()
if (!q) return this.services
return this.services.filter(s =>
s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q)
)
},
async init() {
await this.loadTargets()
await this.load()
},
async loadTargets() {
try {
const res = await apiFetch('/api/proxmox/lxc')
if (res.ok) {
const lxc = await res.json() || []
this.targets = [
{ value: 'host', label: 'Host Proxmox' },
...lxc.map(c => ({ value: `lxc:${c.vmid}`, label: `LXC ${c.vmid}${c.name || 'CT'+c.vmid}` }))
]
}
} catch(e) {}
},
async load() {
this.loading = true
try {
const res = await apiFetch(`/api/services?target=${this.target}`)
if (res.ok) this.services = await res.json() || []
} finally {
this.loading = false
}
},
async action(name, act) {
this.actioning[name] = act
try {
const res = await apiFetch(`/api/services/${name}/${act}`, {
method: 'POST',
body: JSON.stringify({ target: this.target })
})
if (res.ok) {
Alpine.store('toasts').success(`${act} ${name}`)
await this.load()
} else {
const b = await res.json().catch(() => ({}))
Alpine.store('toasts').error(b.error || `Erreur ${act}`)
}
} catch(e) {
Alpine.store('toasts').error(e.message)
} finally {
this.actioning[name] = false
}
},
stateClass(svc) {
if (svc.active_state === 'active') return 'state-active'
if (svc.active_state === 'failed') return 'state-failed'
if (svc.active_state === 'inactive') return 'state-inactive'
return 'state-other'
},
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: logsPage ─────────────────────────────────────────────────
Alpine.data('logsPage', () => ({
lines: [],
target: 'host',
targets: [{ value: 'host', label: 'Host Proxmox' }],
unit: '',
units: [],
linesCount: '100',
following: false,
ws: null,
async init() {
await this.loadTargets()
await this.loadUnits()
},
async loadTargets() {
try {
const res = await apiFetch('/api/proxmox/lxc')
if (res.ok) {
const lxc = await res.json() || []
this.targets = [
{ value: 'host', label: 'Host Proxmox' },
...lxc.map(c => ({ value: `lxc:${c.vmid}`, label: `LXC ${c.vmid}${c.name || 'CT'+c.vmid}` }))
]
}
} catch(e) {}
},
async loadUnits() {
try {
const res = await apiFetch(`/api/logs/units?target=${this.target}`)
if (res.ok) this.units = await res.json() || []
} catch(e) {}
},
async onTargetChange() {
this.stopFollow()
this.unit = ''
await this.loadUnits()
},
toggleFollow() {
if (this.following) {
this.stopFollow()
} else {
this.startFollow()
}
},
startFollow() {
this.lines = []
const token = encodeURIComponent(localStorage.getItem('pxp_token') || '')
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const unit = this.unit ? `&unit=${encodeURIComponent(this.unit)}` : ''
const url = `${proto}://${location.host}/ws/logs?token=${token}&target=${this.target}&lines=${this.linesCount}${unit}`
this.ws = new WebSocket(url)
this.following = true
this.ws.onmessage = (e) => {
const incoming = e.data.split('\n').filter(l => l !== '')
this.lines.push(...incoming)
if (this.lines.length > 3000) this.lines = this.lines.slice(-3000)
this.$nextTick(() => {
const el = this.$refs.logOutput
if (el) el.scrollTop = el.scrollHeight
})
}
this.ws.onclose = () => { this.following = false; this.ws = null }
this.ws.onerror = () => { this.following = false; this.ws = null }
},
stopFollow() {
if (this.ws) {
this.ws.close()
this.ws = null
}
this.following = false
},
clearLog() {
this.lines = []
},
t(key) { return Alpine.store('i18n').t(key) },
}))
}) // end alpine:init
// ── DOMContentLoaded : init stores + Swup ─────────────────────────────────