diff --git a/web/build.sh b/web/build.sh index e882219..6a33d4f 100755 --- a/web/build.sh +++ b/web/build.sh @@ -109,12 +109,8 @@ download_path() { # ── Read 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") +mapfile -t LOCALS < <(yq '(.local // [])[]' "$MANIFEST") +mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") # Ordered list of load paths for components.json declare -a LOAD_PATHS=() @@ -179,92 +175,3 @@ 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 8377a10..4d6c2ee 100644 --- a/web/components.json +++ b/web/components.json @@ -3,7 +3,5 @@ "components/api-client/api-client.js", "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/form-vm/form-vm.js" + "components/date-display/date-display.js" ] diff --git a/web/components.yml b/web/components.yml index 7b80ec3..646d294 100644 --- a/web/components.yml +++ b/web/components.yml @@ -9,14 +9,12 @@ # Local components shipped inside this repo. Not downloaded — listed here only # so build.sh can place them in the generated load order. -local_components: +local: - 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. @@ -25,34 +23,3 @@ local_components: # 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 deleted file mode 100644 index 06c3390..0000000 --- a/web/components/date-display/date-display.css +++ /dev/null @@ -1,38 +0,0 @@ -: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 c008c93..dab208e 100644 --- a/web/components/date-display/date-display.js +++ b/web/components/date-display/date-display.js @@ -1,19 +1,53 @@ 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 deleted file mode 100644 index 9fdd662..0000000 --- a/web/components/form-vm/form-vm.css +++ /dev/null @@ -1,138 +0,0 @@ -: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 deleted file mode 100644 index 8cb6b3c..0000000 --- a/web/components/form-vm/form-vm.js +++ /dev/null @@ -1,311 +0,0 @@ -// 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 deleted file mode 100644 index 06cedd0..0000000 --- a/web/components/form-vm/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index f843d38..0000000 --- a/web/components/login-gate/login-gate.css +++ /dev/null @@ -1,81 +0,0 @@ -* { 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 1de65de..247fa06 100644 --- a/web/components/login-gate/login-gate.js +++ b/web/components/login-gate/login-gate.js @@ -18,7 +18,6 @@ // 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 @@ -58,7 +57,107 @@ 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 deleted file mode 100644 index 2a6aa10..0000000 --- a/web/components/logout-button/logout-button.css +++ /dev/null @@ -1,30 +0,0 @@ -: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 69b651f..45aa80f 100644 --- a/web/components/logout-button/logout-button.js +++ b/web/components/logout-button/logout-button.js @@ -1,21 +1,63 @@ -const CSS = new URL('./logout-button.css', import.meta.url).href +// 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 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 deleted file mode 100644 index 6e371c1..0000000 --- a/web/components/side-nav/side-nav.css +++ /dev/null @@ -1,40 +0,0 @@ -: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 3c7f7db..f8d7435 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -1,15 +1,7 @@ // Side navigation component. -// 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 = ` - - - -` +// 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. const ICONS = { dashboard: ``, @@ -18,189 +10,90 @@ 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}`) - this.#items = await response.json() + const items = await response.json() - this.#render() - - this.#mqHandler = () => this.#applyMode() - this.#hashHandler = () => this.#updateActive() - MQ.addEventListener('change', this.#mqHandler) - window.addEventListener('hashchange', this.#hashHandler) - this.#applyMode() + this.#render(items) } - disconnectedCallback() { - MQ.removeEventListener('change', this.#mqHandler) - window.removeEventListener('hashchange', this.#hashHandler) - this.#removeHamburger() - this.#removeBackdrop() - } + #render(items) { + const currentPage = window.location.pathname.split('/').pop() || 'index.html' - // ── 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} - ` + const links = items.map(item => { + const isActive = item.href === currentPage + 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 deleted file mode 100644 index 460f54b..0000000 --- a/web/components/side-panel/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index 017585c..0000000 --- a/web/components/side-panel/side-panel.css +++ /dev/null @@ -1,83 +0,0 @@ -: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 deleted file mode 100644 index 63e68c9..0000000 --- a/web/components/side-panel/side-panel.js +++ /dev/null @@ -1,165 +0,0 @@ -// 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 deleted file mode 100644 index 06dbe9c..0000000 --- a/web/core/router.js +++ /dev/null @@ -1,54 +0,0 @@ -// 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 021fb07..4c75ef4 100644 --- a/web/index.html +++ b/web/index.html @@ -59,11 +59,6 @@ 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; @@ -92,22 +87,21 @@
-
+
+
+ + +
diff --git a/web/navigation.json b/web/navigation.json index 2845612..96a2b94 100644 --- a/web/navigation.json +++ b/web/navigation.json @@ -1,22 +1,22 @@ [ { "label": "Dashboard", - "href": "#/dashboard", + "href": "index.html", "icon": "dashboard" }, { "label": "VPCs", - "href": "#/vpcs", + "href": "vpc.html", "icon": "vpc" }, { "label": "Subnets", - "href": "#/subnets", + "href": "subnet.html", "icon": "subnet" }, { "label": "VMs", - "href": "#/vms", + "href": "vm.html", "icon": "vm" } ] diff --git a/web/pages.json b/web/pages.json deleted file mode 100644 index e018e51..0000000 --- a/web/pages.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - { - "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 deleted file mode 100644 index 204e2c0..0000000 --- a/web/pages/dashboard/dashboard.css +++ /dev/null @@ -1,2 +0,0 @@ -/* 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 deleted file mode 100644 index 773ec8f..0000000 --- a/web/pages/dashboard/dashboard.js +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index ba114f1..0000000 --- a/web/pages/dashboard/index.html +++ /dev/null @@ -1,24 +0,0 @@ - - -
- - - - -
diff --git a/web/pages/dashboard/manifest.json b/web/pages/dashboard/manifest.json deleted file mode 100644 index 1a0f7ba..0000000 --- a/web/pages/dashboard/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "route": "dashboard", - "label": "Dashboard", - "icon": "dashboard", - "version": "0.1.0" -} diff --git a/web/pages/subnets/index.html b/web/pages/subnets/index.html deleted file mode 100644 index 3ff00dd..0000000 --- a/web/pages/subnets/index.html +++ /dev/null @@ -1 +0,0 @@ -

Subnets — à venir

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

VMs — à venir

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

VPCs — à venir

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

two

- network orchestrator -
- -
- -
- - -
-
- - - -
- - -
-
-
- - - - -