diff --git a/web/components.json b/web/components.json index c4e8217..8377a10 100644 --- a/web/components.json +++ b/web/components.json @@ -4,5 +4,6 @@ "components/side-nav/side-nav.js", "components/logout-button/logout-button.js", "components/side-panel/side-panel.js", - "components/date-display/date-display.js" + "components/date-display/date-display.js", + "components/form-vm/form-vm.js" ] diff --git a/web/components.yml b/web/components.yml index dcdb15a..7b80ec3 100644 --- a/web/components.yml +++ b/web/components.yml @@ -16,6 +16,7 @@ local_components: - logout-button - side-panel - date-display + - form-vm # Remote components fetched from git at build time. # Each is its own repo; its root must contain .js and manifest.json. diff --git a/web/components/form-vm/form-vm.css b/web/components/form-vm/form-vm.css new file mode 100644 index 0000000..9fdd662 --- /dev/null +++ b/web/components/form-vm/form-vm.css @@ -0,0 +1,138 @@ +:host { + display: block; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #cdd6f4; +} + +form { + display: flex; + flex-direction: column; + gap: 20px; +} + +/* ── Sections ─────────────────────────────────────────────────────────────── */ + +.section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6c7086; + padding-bottom: 8px; + border-bottom: 1px solid #313244; +} + +/* ── Fields ───────────────────────────────────────────────────────────────── */ + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +label { + font-size: 12px; + font-weight: 500; + color: #a6adc8; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +input, select { + background: #181825; + border: 1px solid #313244; + border-radius: 6px; + padding: 8px 12px; + color: #cdd6f4; + font-size: 13px; + font-family: monospace; + outline: none; + width: 100%; + box-sizing: border-box; + transition: border-color 0.15s; +} + +input:focus, select:focus { border-color: #89b4fa; } +input::placeholder { color: #45475a; } +input[readonly] { color: #6c7086; cursor: not-allowed; } + +/* ── Disk list ────────────────────────────────────────────────────────────── */ + +.disk-row { + display: grid; + grid-template-columns: 80px 1fr auto; + gap: 8px; + align-items: center; +} + +.disk-row input { min-width: 0; } + +/* ── Buttons ──────────────────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + border-radius: 6px; + padding: 8px 14px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s, opacity 0.12s; +} + +.btn-primary { background: #89b4fa; color: #1e1e2e; } +.btn-primary:hover { background: #b4d0fa; } +.btn-danger { background: transparent; border: 1px solid #f38ba8; color: #f38ba8; } +.btn-danger:hover { background: rgba(243,139,168,0.1); } +.btn-ghost { background: #313244; color: #a6adc8; padding: 6px 10px; font-size: 12px; } +.btn-ghost:hover { background: #45475a; } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.actions { + display: flex; + gap: 8px; + padding-top: 4px; +} + +/* ── Status ───────────────────────────────────────────────────────────────── */ + +.status { + font-size: 13px; + padding: 10px 14px; + border-radius: 6px; + display: none; +} +.status.error { background: #1e1e2e; border: 1px solid #f38ba8; color: #f38ba8; display: block; } +.status.success { background: #1e1e2e; border: 1px solid #a6e3a1; color: #a6e3a1; display: block; } +.status.loading { color: #6c7086; display: block; } + +/* ── Toggle ───────────────────────────────────────────────────────────────── */ + +.toggle-row { + display: flex; + align-items: center; + gap: 10px; +} + +input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #89b4fa; + cursor: pointer; +} + +.toggle-label { font-size: 13px; color: #a6adc8; text-transform: none; letter-spacing: 0; } diff --git a/web/components/form-vm/form-vm.js b/web/components/form-vm/form-vm.js new file mode 100644 index 0000000..8cb6b3c --- /dev/null +++ b/web/components/form-vm/form-vm.js @@ -0,0 +1,311 @@ +// VM create / edit form. +// +// Usage: +// → create mode +// → edit mode (loads VM from agent) +// +// Events dispatched on the element: +// vm-saved → { detail: { name, mode: 'create'|'edit' } } +// vm-error → { detail: { message } } +// +// Edit mode stops the VM then recreates it with the new parameters. +// The name field is read-only in edit mode. + +const CSS = new URL('./form-vm.css', import.meta.url).href + +const PLUS_ICON = `` +const TRASH_ICON = `` + +class FormVm extends HTMLElement { + #api = null + #mode = 'create' + #vmData = null // loaded in edit mode + + async connectedCallback() { + this.attachShadow({ mode: 'open' }) + this.shadowRoot.innerHTML = `
` + + this.#api = document.querySelector('api-client') + this.#mode = this.hasAttribute('vm-name') ? 'edit' : 'create' + + if (this.#mode === 'edit') { + this.#setStatus('loading', 'Chargement…') + try { + this.#vmData = await this.#api.agent.get(`/vms/${this.getAttribute('vm-name')}`) + } catch (e) { + this.#setStatus('error', `Impossible de charger la VM : ${e.message}`) + return + } + } + + this.#render() + } + + // ── Render ────────────────────────────────────────────────────────────────── + + #render() { + const d = this.#vmData + const edit = this.#mode === 'edit' + + this.#root().innerHTML = ` +
+ +
+
Général
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+
Authentification
+
+ + +
+
+ + +
+
+ +
+
Interface réseau
+
+
+ + +
+
+ + +
+
+
+ +
+
Stockage
+
+ ${this.#renderDisks(d?.storage ?? [{ dev: 'vda', path: '' }])} +
+ +
+ +
+ +
+ + ${edit ? `` : ''} +
+ +
+ ` + + this.#bindEvents() + } + + #renderDisks(disks) { + return disks.map((disk, i) => ` +
+ + + +
+ `).join('') + } + + // ── Events ────────────────────────────────────────────────────────────────── + + #bindEvents() { + const form = this.#root().querySelector('#vm-form') + + form.addEventListener('submit', e => { e.preventDefault(); this.#submit() }) + + this.#root().querySelector('#add-disk')?.addEventListener('click', () => { + this.#addDisk() + }) + + this.#root().querySelector('#delete-btn')?.addEventListener('click', () => { + this.#delete() + }) + + this.#root().addEventListener('click', e => { + const btn = e.target.closest('[data-remove-disk]') + if (btn) this.#removeDisk(Number(btn.dataset.removeDisk)) + }) + } + + // ── Disk list helpers ──────────────────────────────────────────────────────── + + #currentDisks() { + return [...this.#root().querySelectorAll('[data-disk]')].map(row => { + const i = row.dataset.disk + return { + dev: row.querySelector(`[name="dev_${i}"]`).value.trim(), + path: row.querySelector(`[name="path_${i}"]`).value.trim(), + } + }) + } + + #addDisk() { + const container = this.#root().querySelector('#disks') + const idx = container.querySelectorAll('[data-disk]').length + const row = document.createElement('div') + row.dataset.disk = idx + row.className = 'disk-row' + row.innerHTML = ` + + + + ` + container.appendChild(row) + } + + #removeDisk(idx) { + this.#root().querySelector(`[data-disk="${idx}"]`)?.remove() + this.#reindexDisks() + } + + #reindexDisks() { + this.#root().querySelectorAll('[data-disk]').forEach((row, i) => { + row.dataset.disk = i + row.querySelector('[name^="dev_"]').name = `dev_${i}` + row.querySelector('[name^="path_"]').name = `path_${i}` + const trash = row.querySelector('[data-remove-disk]') + if (trash) trash.dataset.removeDisk = i + }) + } + + // ── Build payload ──────────────────────────────────────────────────────────── + + #buildPayload() { + const f = this.#root().querySelector('#vm-form') + const data = new FormData(f) + const val = k => data.get(k)?.trim() ?? '' + + return { + name: val('name'), + memory: Number(val('memory')), + cpus: Number(val('cpus')), + uefi: f.querySelector('[name="uefi"]').checked, + sshkey: val('sshkey'), + password: val('password'), + interfaces: [{ subnet: val('subnet'), ip: val('ip'), primary: true }], + storage: this.#currentDisks(), + } + } + + // ── Submit ─────────────────────────────────────────────────────────────────── + + async #submit() { + const btn = this.#root().querySelector('#submit-btn') + btn.disabled = true + this.#clearStatus() + + try { + const payload = this.#buildPayload() + + if (this.#mode === 'edit') { + // Stop existing VM, then recreate with new params + this.#setStatus('loading', 'Arrêt de la VM…') + await this.#api.agent.delete(`/vms/${payload.name}`) + await this.#api.agent.waitFor(`/vms/${payload.name}`, 'stopped') + this.#setStatus('loading', 'Recréation…') + } else { + this.#setStatus('loading', 'Création…') + } + + await this.#api.agent.post('/vms', payload) + await this.#api.agent.waitFor(`/vms/${payload.name}`, 'started') + + this.#setStatus('success', this.#mode === 'edit' ? 'VM mise à jour.' : 'VM créée.') + this.dispatchEvent(new CustomEvent('vm-saved', { + bubbles: true, + detail: { name: payload.name, mode: this.#mode }, + })) + + // Auto-close parent side-panel after 1s + setTimeout(() => this.closest('side-panel')?.close(), 1000) + + } catch (e) { + this.#setStatus('error', e.message) + this.dispatchEvent(new CustomEvent('vm-error', { + bubbles: true, + detail: { message: e.message }, + })) + } finally { + btn.disabled = false + } + } + + // ── Delete ─────────────────────────────────────────────────────────────────── + + async #delete() { + if (!confirm(`Supprimer la VM ${this.getAttribute('vm-name')} ?`)) return + const btn = this.#root().querySelector('#delete-btn') + btn.disabled = true + this.#setStatus('loading', 'Suppression…') + + try { + const name = this.getAttribute('vm-name') + await this.#api.agent.delete(`/vms/${name}`) + await this.#api.agent.waitFor(`/vms/${name}`, 'stopped') + this.#setStatus('success', 'VM supprimée.') + this.dispatchEvent(new CustomEvent('vm-saved', { + bubbles: true, + detail: { name, mode: 'delete' }, + })) + setTimeout(() => this.closest('side-panel')?.close(), 1000) + } catch (e) { + this.#setStatus('error', e.message) + btn.disabled = false + } + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + #root() { return this.shadowRoot.getElementById('root') } + + #setStatus(type, msg) { + const el = this.#root().querySelector('#status') + if (!el) return + el.className = `status ${type}` + el.textContent = msg + } + + #clearStatus() { + const el = this.#root().querySelector('#status') + if (el) { el.className = 'status'; el.textContent = '' } + } +} + +customElements.define('form-vm', FormVm) diff --git a/web/components/form-vm/manifest.json b/web/components/form-vm/manifest.json new file mode 100644 index 0000000..06cedd0 --- /dev/null +++ b/web/components/form-vm/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "form-vm", + "version": "0.1.0", + "description": "Create or edit a VM. Auto-detects mode via vm-name attribute." +} diff --git a/web/pages/dashboard/dashboard.js b/web/pages/dashboard/dashboard.js index 43d7fea..773ec8f 100644 --- a/web/pages/dashboard/dashboard.js +++ b/web/pages/dashboard/dashboard.js @@ -1,8 +1,46 @@ -// Dashboard page — called by the router each time this route is activated. -// Export init(main) to run logic after the HTML fragment is injected. -// -// `main` is the
DOM element containing the injected HTML. +import { SidePanel } from '../../components/side-panel/side-panel.js' export function init(main) { - // Page is ready — add event listeners, fetch data, etc. + main.querySelectorAll('[data-panel-title]').forEach(btn => { + btn.addEventListener('click', () => { + SidePanel.open({ + title: btn.dataset.panelTitle, + width: btn.dataset.panelWidth ?? '480px', + content: `

+ Contenu du panneau ${btn.dataset.panelTitle}.
+ Largeur : ${btn.dataset.panelWidth}.

+ Tu peux ouvrir plusieurs panneaux — ils s'empilent vers la gauche. + Ferme avec ✕, Échap, ou en cliquant le fond. +

`, + }) + }) + }) + + main.querySelectorAll('[data-action="create-vm"]').forEach(btn => { + btn.addEventListener('click', () => { + SidePanel.open({ + title: 'Nouvelle VM', + width: '520px', + content: ``, + }) + // form-vm gère son propre submit et dispatche vm-saved — pas besoin de querySelector + }) + }) + + main.querySelectorAll('[data-action="edit-vm-i-test1"]').forEach(btn => { + btn.addEventListener('click', () => { + SidePanel.open({ + title: 'Modifier i-test1', + width: '520px', + content: `` + }) + // form-vm gère son propre submit et dispatche vm-saved — pas besoin de querySelector + }) + }) + + // vm-saved est attaché sur main (pas document) → nettoyé automatiquement + // quand le router remplace le contenu de
+ main.addEventListener('vm-saved', e => { + console.log('vm-saved', e.detail) + }) } diff --git a/web/pages/dashboard/index.html b/web/pages/dashboard/index.html index 15da108..ba114f1 100644 --- a/web/pages/dashboard/index.html +++ b/web/pages/dashboard/index.html @@ -1,33 +1,24 @@ - -
- - + +
- - -