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