// 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)