diff --git a/web/build.sh b/web/build.sh index 6a33d4f..e882219 100755 --- a/web/build.sh +++ b/web/build.sh @@ -109,8 +109,12 @@ download_path() { # ── Read manifest ──────────────────────────────────────────────────────────────── -mapfile -t LOCALS < <(yq '(.local // [])[]' "$MANIFEST") -mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") +PAGES_DIR="${WEB_DIR}/pages" + +mapfile -t LOCALS < <(yq '(.local_components // [])[]' "$MANIFEST") +mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") +mapfile -t LOCAL_PAGES < <(yq '(.local_pages // [])[]' "$MANIFEST") +mapfile -t REMOTE_PAGES < <(yq '(.remote_pages // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") # Ordered list of load paths for components.json declare -a LOAD_PATHS=() @@ -175,3 +179,92 @@ echo "$LOCK_ENTRIES" | jq '.' > "$LOCKFILE" log "wrote ${OUTPUT} (${#LOAD_PATHS[@]} components)" log "wrote ${LOCKFILE} ($(jq 'length' <<<"$LOCK_ENTRIES") remote pins)" + +# ── Process pages ──────────────────────────────────────────────────────────────── + +PAGE_ENTRIES="[]" + +# Collect page metadata from a directory into PAGE_ENTRIES. +# Detects css/js presence automatically — both are optional. +add_page_entry() { + local name="$1" dir="$2" + local manifest="${dir}/manifest.json" + local html="${dir}/index.html" + + [[ -f "$manifest" ]] || die "page '${name}' missing manifest.json in ${dir}" + [[ -f "$html" ]] || die "page '${name}' missing index.html in ${dir}" + + local route label icon css_arg js_arg + route=$(jq -r '.route' "$manifest") + label=$(jq -r '.label' "$manifest") + icon=$(jq -r '.icon' "$manifest") + css_arg="null"; js_arg="null" + [[ -f "${dir}/${name}.css" ]] && css_arg="\"pages/${name}/${name}.css\"" + [[ -f "${dir}/${name}.js" ]] && js_arg="\"pages/${name}/${name}.js\"" + + PAGE_ENTRIES="$(jq \ + --arg route "$route" --arg label "$label" --arg icon "$icon" \ + --arg html "pages/${name}/index.html" \ + --argjson css "$css_arg" --argjson js "$js_arg" \ + '. + [{route:$route, label:$label, icon:$icon, html:$html, css:$css, js:$js}]' \ + <<<"$PAGE_ENTRIES")" + info " page '${name}' → /#/${route}$([ "$css_arg" != "null" ] && echo " +css")$([ "$js_arg" != "null" ] && echo " +js")" +} + +# Local pages +for name in "${LOCAL_PAGES[@]}"; do + [[ -z "$name" ]] && continue + add_page_entry "$name" "${PAGES_DIR}/${name}" +done + +# Remote pages — download then register +for spec in "${REMOTE_PAGES[@]}"; do + [[ -z "$spec" ]] && continue + IFS='|' read -r name repo ref <<<"$spec" + [[ -z "$name" || -z "$repo" ]] && die "remote_pages entry missing name or repo" + + read -r server owner gitrepo <<<"$(parse_repo_url "$repo")" + dest="${PAGES_DIR}/${name}" + + if [[ "$CHECK_ONLY" == true ]]; then + info "would fetch page ${name} from ${repo}@${ref}" + continue + fi + + log "fetching page ${name} ← ${owner}/${gitrepo}@${ref}" + commit="$(resolve_commit "$server" "$owner" "$gitrepo" "$ref")" + [[ -z "$commit" ]] && die "cannot resolve ref '${ref}' in ${owner}/${gitrepo}" + + rm -rf "$dest" + download_path "$server" "$owner" "$gitrepo" "$ref" "" "$dest" + add_page_entry "$name" "$dest" + info " pinned ${commit:0:12}" +done + +if [[ "$CHECK_ONLY" == false ]]; then + # Apply menu order if defined — reorder PAGE_ENTRIES and filter nav visibility + mapfile -t MENU_ORDER < <(yq '(.menu // [])[]' "$MANIFEST") + + if [[ ${#MENU_ORDER[@]} -gt 0 ]]; then + ORDERED="[]" + for route in "${MENU_ORDER[@]}"; do + [[ -z "$route" ]] && continue + entry="$(echo "$PAGE_ENTRIES" | jq --arg r "$route" '.[] | select(.route == $r)')" + [[ -z "$entry" ]] && warn "menu: route '${route}' not found in pages, skipping" + [[ -n "$entry" ]] && ORDERED="$(echo "$ORDERED" | jq --argjson e "$entry" '. + [$e]')" + done + NAV_ENTRIES="$ORDERED" + else + NAV_ENTRIES="$PAGE_ENTRIES" + fi + + # pages.json — full list (all pages, original discovery order) + echo "$PAGE_ENTRIES" | jq '.' > "${WEB_DIR}/pages.json" + + # navigation.json — ordered + filtered by menu: + echo "$NAV_ENTRIES" | \ + jq '[.[] | {label: .label, href: ("#/" + .route), icon: .icon}]' \ + > "${WEB_DIR}/navigation.json" + + log "wrote pages.json ($(echo "$PAGE_ENTRIES" | jq 'length') pages) + navigation.json ($(echo "$NAV_ENTRIES" | jq 'length') in menu)" +fi diff --git a/web/components.json b/web/components.json index 4d6c2ee..8377a10 100644 --- a/web/components.json +++ b/web/components.json @@ -3,5 +3,7 @@ "components/api-client/api-client.js", "components/side-nav/side-nav.js", "components/logout-button/logout-button.js", - "components/date-display/date-display.js" + "components/side-panel/side-panel.js", + "components/date-display/date-display.js", + "components/form-vm/form-vm.js" ] diff --git a/web/components.yml b/web/components.yml index 646d294..7b80ec3 100644 --- a/web/components.yml +++ b/web/components.yml @@ -9,12 +9,14 @@ # Local components shipped inside this repo. Not downloaded — listed here only # so build.sh can place them in the generated load order. -local: +local_components: - login-gate - api-client - side-nav - 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. @@ -23,3 +25,34 @@ local: # repo: https://git.g3e.fr/team-reseau/vpc-panel # ref: v1.2.0 # tag, branch or commit (default: main) components: [] + +# Pages — each page is a self-contained directory (JS + CSS + manifest.json). +# Same model as components: local pages ship in this repo, remote pages are +# downloaded from git at build time. build.sh reads each manifest.json to +# generate navigation.json and pages.json. +# +# Page manifest.json must contain: tag, route, label, icon. +# Page JS must: customElements.define('', ...) and use display:contents. + +local_pages: + - dashboard + - vpcs + - subnets + - vms + +# Remote pages fetched from git — same mechanism as components. +# +# - name: vpcs +# repo: https://git.g3e.fr/team-reseau/page-vpcs +# ref: v1.0.0 +remote_pages: [] + +# Menu — order and visibility in the side-nav. +# List routes in desired display order. +# A page not listed here is accessible via /#/route but hidden from the nav. +menu: + - dashboard + - vpcs + - subnets + - vms + diff --git a/web/components/date-display/date-display.css b/web/components/date-display/date-display.css new file mode 100644 index 0000000..06c3390 --- /dev/null +++ b/web/components/date-display/date-display.css @@ -0,0 +1,38 @@ +:host { + display: block; + background: #1e1e2e; + border: 1px solid #313244; + border-radius: 8px; + padding: 16px 20px; + font-family: monospace; + color: #cdd6f4; + min-width: 260px; +} + +.label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6c7086; + margin-bottom: 6px; +} + +.time { + font-size: 28px; + font-weight: 600; + color: #89b4fa; +} + +.date { + font-size: 13px; + color: #a6adc8; + margin-top: 4px; +} + +.agent { + margin-top: 12px; + font-size: 11px; + color: #585b70; + border-top: 1px solid #313244; + padding-top: 10px; +} diff --git a/web/components/date-display/date-display.js b/web/components/date-display/date-display.js index dab208e..c008c93 100644 --- a/web/components/date-display/date-display.js +++ b/web/components/date-display/date-display.js @@ -1,53 +1,19 @@ import { config } from '../../core/config.js' +const CSS = new URL('./date-display.css', import.meta.url).href + class DateDisplay extends HTMLElement { #interval = null connectedCallback() { this.attachShadow({ mode: 'open' }) this.shadowRoot.innerHTML = ` - +
Current time
agent →
` - this.shadowRoot.getElementById('agent-url').textContent = config.agent_url this.#tick() this.#interval = setInterval(() => this.#tick(), 1000) 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/components/login-gate/login-gate.css b/web/components/login-gate/login-gate.css new file mode 100644 index 0000000..f843d38 --- /dev/null +++ b/web/components/login-gate/login-gate.css @@ -0,0 +1,81 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +.overlay { + position: fixed; + inset: 0; + background: #181825; + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +.card { + background: #1e1e2e; + border: 1px solid #313244; + border-radius: 12px; + padding: 36px 40px; + width: 100%; + max-width: 420px; + display: flex; + flex-direction: column; + gap: 24px; +} + +@media (max-width: 480px) { + .card { padding: 24px 20px; margin: 0 12px; } +} + +.header { display: flex; flex-direction: column; gap: 6px; } + +.header h1 { + font-size: 20px; + font-weight: 600; + color: #89b4fa; + letter-spacing: 0.02em; +} + +.header p { font-size: 13px; color: #6c7086; } + +.fields { display: flex; flex-direction: column; gap: 14px; } +.field { display: flex; flex-direction: column; gap: 6px; } + +label { + font-size: 12px; + font-weight: 500; + color: #a6adc8; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +input { + background: #181825; + border: 1px solid #313244; + border-radius: 6px; + padding: 9px 12px; + color: #cdd6f4; + font-size: 14px; + font-family: monospace; + outline: none; + transition: border-color 0.15s; + width: 100%; +} + +input:focus { border-color: #89b4fa; } +input::placeholder { color: #45475a; } + +button { + background: #89b4fa; + color: #1e1e2e; + border: none; + border-radius: 6px; + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + width: 100%; +} + +button:hover { background: #b4d0fa; } diff --git a/web/components/login-gate/login-gate.js b/web/components/login-gate/login-gate.js index 247fa06..1de65de 100644 --- a/web/components/login-gate/login-gate.js +++ b/web/components/login-gate/login-gate.js @@ -18,6 +18,7 @@ // Phase 2: swap for that resolves ready with a JWT — api-client unchanged. const SESSION_KEY = 'two:credentials' +const CSS = new URL('./login-gate.css', import.meta.url).href class LoginGate extends HTMLElement { #resolve = null @@ -57,107 +58,7 @@ class LoginGate extends HTMLElement { const overlay = document.createElement('div') overlay.attachShadow({ mode: 'open' }) overlay.shadowRoot.innerHTML = ` - - +
diff --git a/web/components/logout-button/logout-button.css b/web/components/logout-button/logout-button.css new file mode 100644 index 0000000..2a6aa10 --- /dev/null +++ b/web/components/logout-button/logout-button.css @@ -0,0 +1,30 @@ +:host { + display: inline-flex; +} + +button { + display: inline-flex; + align-items: center; + gap: 7px; + background: transparent; + border: 1px solid #313244; + border-radius: 6px; + padding: 7px 12px; + color: #a6adc8; + font-size: 13px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} + +button:hover { + background: #313244; + color: #f38ba8; + border-color: #f38ba8; +} + +.icon { display: flex; align-items: center; } + +/* compact attribute — icon only */ +:host([compact]) button { padding: 7px; } +:host([compact]) .label { display: none; } diff --git a/web/components/logout-button/logout-button.js b/web/components/logout-button/logout-button.js index 45aa80f..69b651f 100644 --- a/web/components/logout-button/logout-button.js +++ b/web/components/logout-button/logout-button.js @@ -1,63 +1,21 @@ -// Logout button — drop anywhere in the DOM. -// -// full button with label -// icon only (for tight spaces) -// -// Delegates to .logout(). Works regardless of where it sits -// (header, members panel, dropdown…) since it resolves the gate from the document. -// -// Phase 2: if login-gate is swapped for oidc-gate, this still works as long as -// the auth component exposes a logout() method (see #gate()). +const CSS = new URL('./logout-button.css', import.meta.url).href const ICON = `` class LogoutButton extends HTMLElement { connectedCallback() { this.attachShadow({ mode: 'open' }) - - const compact = this.hasAttribute('compact') - this.shadowRoot.innerHTML = ` - - + ` - this.shadowRoot.querySelector('button') .addEventListener('click', () => this.#logout()) } - // Resolves the auth component. Today: login-gate. Tomorrow: any element - // exposing logout() (oidc-gate, oauth-gate…). #gate() { return document.querySelector('login-gate, [data-auth-gate]') } diff --git a/web/components/side-nav/side-nav.css b/web/components/side-nav/side-nav.css new file mode 100644 index 0000000..6e371c1 --- /dev/null +++ b/web/components/side-nav/side-nav.css @@ -0,0 +1,40 @@ +:host { + display: flex; + flex-direction: column; + width: 220px; + min-width: 220px; + background: #1e1e2e; + border-right: 1px solid #313244; + padding: 16px 12px; + gap: 4px; + height: 100%; + overflow: hidden; + box-sizing: border-box; +} + +.item { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 12px; + border-radius: 6px; + text-decoration: none; + color: #a6adc8; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + min-height: 44px; + transition: background 0.1s, color 0.1s; +} + +.item:hover { background: #313244; color: #cdd6f4; } +.item.active { background: #313244; color: #89b4fa; } +.item.active .icon { color: #89b4fa; } + +.icon { + display: flex; + align-items: center; + color: #6c7086; + flex-shrink: 0; +} + +.item:hover .icon { color: #cdd6f4; } diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js index f8d7435..3c7f7db 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -1,7 +1,15 @@ // Side navigation component. -// Reads items from navigation.json (path relative to web root via data-base attribute, -// defaults to ./navigation.json). -// Highlights the active entry by matching href against window.location.pathname. +// Desktop : sidebar fixe à gauche dans le flux flex. +// Mobile (≤768px) : overlay depuis la gauche, déclenché par un bouton hamburger +// inséré dans
. Géométrie gérée en inline style pour +// éviter les conflits de spécificité avec le shadow DOM. + +const HAMBURGER_ICON = ` + + + +` const ICONS = { dashboard: ``, @@ -10,90 +18,189 @@ const ICONS = { vm: ``, } +const CSS = new URL('./side-nav.css', import.meta.url).href +const MQ = window.matchMedia('(max-width: 768px)') +const NAV_W = 220 // px + function resolveIcon(name) { return ICONS[name] ?? `` } class SideNav extends HTMLElement { + #items = [] + #hamburger = null + #backdrop = null + #mqHandler = null + #hashHandler = null + #isMobile = false + async connectedCallback() { this.attachShadow({ mode: 'open' }) - const navPath = this.dataset.src ?? './navigation.json' + const navPath = this.dataset.src ?? './navigation.json' const response = await fetch(navPath) if (!response.ok) throw new Error(`side-nav: failed to load ${navPath}`) - const items = await response.json() + this.#items = await response.json() - this.#render(items) + this.#render() + + this.#mqHandler = () => this.#applyMode() + this.#hashHandler = () => this.#updateActive() + MQ.addEventListener('change', this.#mqHandler) + window.addEventListener('hashchange', this.#hashHandler) + this.#applyMode() } - #render(items) { - const currentPage = window.location.pathname.split('/').pop() || 'index.html' + disconnectedCallback() { + MQ.removeEventListener('change', this.#mqHandler) + window.removeEventListener('hashchange', this.#hashHandler) + this.#removeHamburger() + this.#removeBackdrop() + } - const links = items.map(item => { - const isActive = item.href === currentPage - return ` - - ${resolveIcon(item.icon)} - ${item.label} - - ` + // ── Rendu des items ───────────────────────────────────────────────────────── + + #currentHref() { + return window.location.hash || `#/${this.#items[0]?.href?.replace(/^#\//, '') ?? 'dashboard'}` + } + + // Update active class without re-rendering the whole nav. + #updateActive() { + const current = this.#currentHref() + this.shadowRoot?.querySelectorAll('.item').forEach(a => + a.classList.toggle('active', a.getAttribute('href') === current) + ) + } + + #render() { + const current = this.#currentHref() + + const links = this.#items.map(item => { + const active = item.href === current + return ` + ${resolveIcon(item.icon)} + ${item.label} + ` }).join('') this.shadowRoot.innerHTML = ` - - + ${links} ` + + // Fermer l'overlay sur tap d'un lien (mobile) + this.shadowRoot.querySelectorAll('.item').forEach(a => + a.addEventListener('click', () => { if (this.#isMobile) this.#close() }) + ) + } + + // ── Basculement desktop / mobile ──────────────────────────────────────────── + + #applyMode() { + if (MQ.matches) this.#toMobile() + else this.#toDesktop() + } + + #toMobile() { + this.#isMobile = true + + // inline style = spécificité maximale, pas de conflit avec le shadow DOM + Object.assign(this.style, { + position: 'fixed', + top: '0', + left: '0', + width: `${NAV_W}px`, + minWidth: `${NAV_W}px`, + height: '100vh', + zIndex: '500', + transform: 'translateX(-100%)', + transition: 'transform 0.25s cubic-bezier(0.4,0,0.2,1)', + overflow: 'hidden', + boxShadow: '4px 0 24px rgba(0,0,0,0.45)', + }) + + this.#addHamburger() + } + + #toDesktop() { + this.#isMobile = false + // Réinitialise tous les styles inline → shadow DOM reprend le contrôle + this.style.cssText = '' + this.#removeHamburger() + this.#removeBackdrop() + } + + // ── Hamburger ─────────────────────────────────────────────────────────────── + + #addHamburger() { + if (this.#hamburger) return + + const btn = document.createElement('button') + btn.innerHTML = HAMBURGER_ICON + btn.setAttribute('aria-label', 'Menu') + btn.setAttribute('data-sidenav-toggle', '') + Object.assign(btn.style, { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + background: 'transparent', + border: '1px solid #313244', + borderRadius: '6px', + padding: '7px', + color: '#a6adc8', + cursor: 'pointer', + minWidth: '36px', + minHeight: '36px', + flexShrink: '0', + }) + btn.addEventListener('click', () => this.#toggle()) + this.#hamburger = btn + + // Insère après le h1 dans le header, ou en premier si pas de h1 + const header = document.querySelector('header') + if (!header) { document.body.prepend(btn); return } + const h1 = header.querySelector('h1') + h1 ? h1.after(btn) : header.prepend(btn) + } + + #removeHamburger() { + this.#hamburger?.remove() + this.#hamburger = null + } + + // ── Open / Close (mobile) ─────────────────────────────────────────────────── + + #toggle() { this.#isMobile && (this.style.transform === 'translateX(0px)' + ? this.#close() : this.#open()) } + + #open() { + this.style.transform = 'translateX(0)' + + const bd = document.createElement('div') + Object.assign(bd.style, { + position: 'fixed', + inset: '0', + background: 'rgba(0,0,0,0)', + zIndex: '499', + transition: 'background 0.25s', + }) + bd.addEventListener('click', () => this.#close()) + document.body.appendChild(bd) + this.#backdrop = bd + requestAnimationFrame(() => { bd.style.background = 'rgba(0,0,0,0.45)' }) + } + + #close() { + this.style.transform = 'translateX(-100%)' + this.#removeBackdrop() + } + + #removeBackdrop() { + if (!this.#backdrop) return + const bd = this.#backdrop + this.#backdrop = null + bd.style.background = 'rgba(0,0,0,0)' + bd.addEventListener('transitionend', () => bd.remove(), { once: true }) } } diff --git a/web/components/side-panel/manifest.json b/web/components/side-panel/manifest.json new file mode 100644 index 0000000..460f54b --- /dev/null +++ b/web/components/side-panel/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "side-panel", + "version": "0.1.0", + "description": "Configurable right-side drawer. Multiple instances stack independently. Opens declaratively or via SidePanel.open()." +} diff --git a/web/components/side-panel/side-panel.css b/web/components/side-panel/side-panel.css new file mode 100644 index 0000000..017585c --- /dev/null +++ b/web/components/side-panel/side-panel.css @@ -0,0 +1,83 @@ +:host { + --width: 480px; /* overridden via this.style.setProperty in JS */ + --offset: 0px; + --dur: 220ms; + --actual-width: min(var(--width), 100vw); + + position: fixed; + top: 0; + right: calc(-1 * var(--actual-width)); + width: var(--actual-width); + height: 100vh; + background: #1e1e2e; + border-left: 1px solid #313244; + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + z-index: calc(1000 + var(--stack, 0)); + transition: right var(--dur) cubic-bezier(0.4, 0, 0.2, 1); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #cdd6f4; +} + +:host([data-open]) { + right: var(--offset); +} + +@media (max-width: 600px) { + :host { + --actual-width: 100vw; + } + :host([data-open]) { + right: 0; + } +} + +.header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid #313244; + flex-shrink: 0; +} + +.title { + flex: 1; + font-size: 15px; + font-weight: 600; + color: #cdd6f4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.close { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + padding: 5px; + color: #6c7086; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; + flex-shrink: 0; +} + +.close:hover { + background: #313244; + border-color: #45475a; + color: #f38ba8; +} + +.body { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.body::-webkit-scrollbar { width: 6px; } +.body::-webkit-scrollbar-track { background: transparent; } +.body::-webkit-scrollbar-thumb { background: #45475a; border-radius: 3px; } diff --git a/web/components/side-panel/side-panel.js b/web/components/side-panel/side-panel.js new file mode 100644 index 0000000..63e68c9 --- /dev/null +++ b/web/components/side-panel/side-panel.js @@ -0,0 +1,165 @@ +// Configurable right-side drawer panel. +// +// ── Declarative ────────────────────────────────────────────────────────────── +// +// +// +// +// panel.open() +// panel.close() +// +// ── Programmatic (from any component) ──────────────────────────────────────── +// +// import { SidePanel } from '../side-panel/side-panel.js' +// +// const panel = SidePanel.open({ +// title: 'VPC — vp-admin', +// width: '520px', +// content: '', +// }) +// panel.close() +// +// ── Multiple panels ─────────────────────────────────────────────────────────── +// +// Each open panel stacks with a slight depth offset. +// Clicking the backdrop closes only the topmost panel. +// ESC closes the topmost panel. + +const CSS = new URL('./side-panel.css', import.meta.url).href + +const CLOSE_ICON = ` + +` + +const STACK_OFFSET = 12 // px shift per stacked panel +const ANIM_DURATION = 220 // ms + +function openCount() { + return document.querySelectorAll('side-panel[data-open]').length +} + +function topPanel() { + const panels = [...document.querySelectorAll('side-panel[data-open]')] + return panels[panels.length - 1] ?? null +} + +// Global ESC handler — closes topmost panel only. +document.addEventListener('keydown', e => { + if (e.key === 'Escape') topPanel()?.close() +}) + +export class SidePanel extends HTMLElement { + // ── Static factory ────────────────────────────────────────────────────────── + + static open({ title = '', content = '', width = '480px' } = {}) { + const panel = document.createElement('side-panel') + if (title) panel.setAttribute('title', title) + if (width) panel.setAttribute('width', width) + if (content) panel.innerHTML = content + document.body.appendChild(panel) + panel.open() + return panel + } + + // ── Lifecycle ─────────────────────────────────────────────────────────────── + + connectedCallback() { + this.attachShadow({ mode: 'open' }) + this.#render() + } + + // ── Public API ────────────────────────────────────────────────────────────── + + open() { + const depth = openCount() + const offset = depth * STACK_OFFSET + + // Shift panel left based on stack depth + this.style.setProperty('--offset', `${offset}px`) + this.setAttribute('data-open', '') + + // Backdrop: only show/darken the shared one + this.#ensureBackdrop() + + this.dispatchEvent(new CustomEvent('panel-open', { bubbles: true })) + } + + close() { + if (!this.hasAttribute('data-open')) return + + this.removeAttribute('data-open') + this.#updateBackdrop() + + this.addEventListener('transitionend', () => { + // If created programmatically (appended to body by factory), remove from DOM + if (this.dataset.programmatic) this.remove() + }, { once: true }) + + this.dispatchEvent(new CustomEvent('panel-close', { bubbles: true })) + } + + // ── Rendering ─────────────────────────────────────────────────────────────── + + #render() { + const title = this.getAttribute('title') ?? '' + const width = this.getAttribute('width') ?? '480px' + + // Passe la largeur comme custom property — référencée dans le CSS via var(--width) + this.style.setProperty('--width', width) + + this.shadowRoot.innerHTML = ` + +
+ ${title} + +
+
+ +
+ ` + + this.shadowRoot.querySelector('.close').addEventListener('click', () => this.close()) + + // Mark programmatic panels so they self-remove on close + if (!this.parentElement || this.parentElement === document.body) { + this.dataset.programmatic = 'true' + } + } + + // ── Backdrop ───────────────────────────────────────────────────────────────── + + #ensureBackdrop() { + let bd = document.getElementById('__side-panel-backdrop__') + if (!bd) { + bd = document.createElement('div') + bd.id = '__side-panel-backdrop__' + Object.assign(bd.style, { + position: 'fixed', inset: '0', + background: 'rgba(0,0,0,0)', + transition: `background ${ANIM_DURATION}ms`, + zIndex: '999', + }) + bd.addEventListener('click', () => topPanel()?.close()) + document.body.appendChild(bd) + } + // Opacity scales with stack depth (max 0.5) + const opacity = Math.min(0.5, openCount() * 0.15) + requestAnimationFrame(() => { bd.style.background = `rgba(0,0,0,${opacity})` }) + } + + #updateBackdrop() { + const bd = document.getElementById('__side-panel-backdrop__') + if (!bd) return + const remaining = openCount() + if (remaining === 0) { + bd.style.background = 'rgba(0,0,0,0)' + bd.addEventListener('transitionend', () => bd.remove(), { once: true }) + } else { + const opacity = Math.min(0.5, remaining * 0.15) + bd.style.background = `rgba(0,0,0,${opacity})` + } + } +} + +customElements.define('side-panel', SidePanel) diff --git a/web/core/router.js b/web/core/router.js new file mode 100644 index 0000000..06dbe9c --- /dev/null +++ b/web/core/router.js @@ -0,0 +1,54 @@ +// Hash-based SPA router. +// Reads pages.json (generated by build.sh from components.yml). +// Each page is an HTML fragment (index.html) + optional CSS + optional JS module. +// +// Load order enforced by index.html: +// await import('./core/registry.js') ← defines all components +// await import('./core/router.js') ← this file + +const response = await fetch('./pages.json') +if (!response.ok) throw new Error('router: failed to load pages.json — run build.sh') +const PAGES = await response.json() + +// Cache of imported JS modules — modules are only fetched once per session. +const MODULE_CACHE = new Map() + +// Currently active page CSS — removed on navigation. +let activeCSS = null + +function currentRoute() { + return window.location.hash.replace(/^#\//, '') || PAGES[0]?.route || '' +} + +async function render(routeName) { + const page = PAGES.find(p => p.route === routeName) ?? PAGES[0] + const main = document.querySelector('main') + if (!main || !page) return + + // ── HTML ──────────────────────────────────────────────────────────────────── + const htmlRes = await fetch(page.html) + if (!htmlRes.ok) throw new Error(`router: failed to load ${page.html}`) + main.innerHTML = await htmlRes.text() + + // ── CSS ───────────────────────────────────────────────────────────────────── + activeCSS?.remove() + activeCSS = null + if (page.css) { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = page.css + document.head.appendChild(link) + activeCSS = link + } + + // ── JS ────────────────────────────────────────────────────────────────────── + if (page.js) { + if (!MODULE_CACHE.has(page.js)) { + MODULE_CACHE.set(page.js, await import(`../${page.js}`)) + } + MODULE_CACHE.get(page.js)?.init?.(main) + } +} + +await render(currentRoute()) +window.addEventListener('hashchange', () => render(currentRoute())) diff --git a/web/index.html b/web/index.html index 4c75ef4..021fb07 100644 --- a/web/index.html +++ b/web/index.html @@ -59,6 +59,11 @@ gap: 20px; } + /* Mobile: side-nav gère sa propre géométrie via inline styles (JS) */ + @media (max-width: 768px) { + main { padding: 16px; } + } + #error { display: none; background: #313244; @@ -87,21 +92,22 @@
-
-
- - -
+
diff --git a/web/navigation.json b/web/navigation.json index 96a2b94..2845612 100644 --- a/web/navigation.json +++ b/web/navigation.json @@ -1,22 +1,22 @@ [ { "label": "Dashboard", - "href": "index.html", + "href": "#/dashboard", "icon": "dashboard" }, { "label": "VPCs", - "href": "vpc.html", + "href": "#/vpcs", "icon": "vpc" }, { "label": "Subnets", - "href": "subnet.html", + "href": "#/subnets", "icon": "subnet" }, { "label": "VMs", - "href": "vm.html", + "href": "#/vms", "icon": "vm" } ] diff --git a/web/pages.json b/web/pages.json new file mode 100644 index 0000000..e018e51 --- /dev/null +++ b/web/pages.json @@ -0,0 +1,34 @@ +[ + { + "route": "dashboard", + "label": "Dashboard", + "icon": "dashboard", + "html": "pages/dashboard/index.html", + "css": "pages/dashboard/dashboard.css", + "js": "pages/dashboard/dashboard.js" + }, + { + "route": "vpcs", + "label": "VPCs", + "icon": "vpc", + "html": "pages/vpcs/index.html", + "css": null, + "js": null + }, + { + "route": "subnets", + "label": "Subnets", + "icon": "subnet", + "html": "pages/subnets/index.html", + "css": null, + "js": null + }, + { + "route": "vms", + "label": "VMs", + "icon": "vm", + "html": "pages/vms/index.html", + "css": null, + "js": null + } +] diff --git a/web/pages/dashboard/dashboard.css b/web/pages/dashboard/dashboard.css new file mode 100644 index 0000000..204e2c0 --- /dev/null +++ b/web/pages/dashboard/dashboard.css @@ -0,0 +1,2 @@ +/* Styles scoped to the dashboard page. + Applied when the route is active, removed on navigation. */ diff --git a/web/pages/dashboard/dashboard.js b/web/pages/dashboard/dashboard.js new file mode 100644 index 0000000..773ec8f --- /dev/null +++ b/web/pages/dashboard/dashboard.js @@ -0,0 +1,46 @@ +import { SidePanel } from '../../components/side-panel/side-panel.js' + +export function init(main) { + 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 new file mode 100644 index 0000000..ba114f1 --- /dev/null +++ b/web/pages/dashboard/index.html @@ -0,0 +1,24 @@ + + +
+ + + + +
diff --git a/web/pages/dashboard/manifest.json b/web/pages/dashboard/manifest.json new file mode 100644 index 0000000..1a0f7ba --- /dev/null +++ b/web/pages/dashboard/manifest.json @@ -0,0 +1,6 @@ +{ + "route": "dashboard", + "label": "Dashboard", + "icon": "dashboard", + "version": "0.1.0" +} diff --git a/web/pages/subnets/index.html b/web/pages/subnets/index.html new file mode 100644 index 0000000..3ff00dd --- /dev/null +++ b/web/pages/subnets/index.html @@ -0,0 +1 @@ +

Subnets — à venir

diff --git a/web/pages/subnets/manifest.json b/web/pages/subnets/manifest.json new file mode 100644 index 0000000..189d0c2 --- /dev/null +++ b/web/pages/subnets/manifest.json @@ -0,0 +1 @@ +{ "route": "subnets", "label": "Subnets", "icon": "subnet", "version": "0.1.0" } diff --git a/web/pages/vms/index.html b/web/pages/vms/index.html new file mode 100644 index 0000000..ffe3861 --- /dev/null +++ b/web/pages/vms/index.html @@ -0,0 +1 @@ +

VMs — à venir

diff --git a/web/pages/vms/manifest.json b/web/pages/vms/manifest.json new file mode 100644 index 0000000..597d281 --- /dev/null +++ b/web/pages/vms/manifest.json @@ -0,0 +1 @@ +{ "route": "vms", "label": "VMs", "icon": "vm", "version": "0.1.0" } diff --git a/web/pages/vpcs/index.html b/web/pages/vpcs/index.html new file mode 100644 index 0000000..6de5126 --- /dev/null +++ b/web/pages/vpcs/index.html @@ -0,0 +1 @@ +

VPCs — à venir

diff --git a/web/pages/vpcs/manifest.json b/web/pages/vpcs/manifest.json new file mode 100644 index 0000000..7dde73b --- /dev/null +++ b/web/pages/vpcs/manifest.json @@ -0,0 +1 @@ +{ "route": "vpcs", "label": "VPCs", "icon": "vpc", "version": "0.1.0" } diff --git a/web/vpc.html b/web/vpc.html new file mode 100644 index 0000000..0961b64 --- /dev/null +++ b/web/vpc.html @@ -0,0 +1,140 @@ + + + + + + two — dashboard + + + + + + + + +
+

two

+ network orchestrator +
+ +
+ +
+ + +
+
+ + + +
+ + +
+
+
+ + + + +