Compare commits
5 commits
d1ef83e949
...
9980e03449
| Author | SHA1 | Date | |
|---|---|---|---|
|
9980e03449 |
|||
|
b479493ac1 |
|||
|
7946129a75 |
|||
|
9e1e125d00 |
|||
|
8674f5d91d |
32 changed files with 1541 additions and 267 deletions
95
web/build.sh
95
web/build.sh
|
|
@ -109,8 +109,12 @@ download_path() {
|
|||
|
||||
# ── Read manifest ────────────────────────────────────────────────────────────────
|
||||
|
||||
mapfile -t LOCALS < <(yq '(.local // [])[]' "$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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 <name>.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('<tag>', ...) 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
|
||||
|
||||
|
|
|
|||
38
web/components/date-display/date-display.css
Normal file
38
web/components/date-display/date-display.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 = `
|
||||
<style>
|
||||
: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;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="${CSS}">
|
||||
<div class="label">Current time</div>
|
||||
<div class="time" id="time"></div>
|
||||
<div class="date" id="date"></div>
|
||||
<div class="agent">agent → <span id="agent-url"></span></div>
|
||||
`
|
||||
|
||||
this.shadowRoot.getElementById('agent-url').textContent = config.agent_url
|
||||
this.#tick()
|
||||
this.#interval = setInterval(() => this.#tick(), 1000)
|
||||
|
|
|
|||
138
web/components/form-vm/form-vm.css
Normal file
138
web/components/form-vm/form-vm.css
Normal file
|
|
@ -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; }
|
||||
311
web/components/form-vm/form-vm.js
Normal file
311
web/components/form-vm/form-vm.js
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
// VM create / edit form.
|
||||
//
|
||||
// Usage:
|
||||
// <form-vm></form-vm> → create mode
|
||||
// <form-vm vm-name="i-test1"></form-vm> → 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 = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`
|
||||
const TRASH_ICON = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>`
|
||||
|
||||
class FormVm extends HTMLElement {
|
||||
#api = null
|
||||
#mode = 'create'
|
||||
#vmData = null // loaded in edit mode
|
||||
|
||||
async connectedCallback() {
|
||||
this.attachShadow({ mode: 'open' })
|
||||
this.shadowRoot.innerHTML = `<link rel="stylesheet" href="${CSS}"><div id="root"></div>`
|
||||
|
||||
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 = `
|
||||
<form id="vm-form">
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Général</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<input name="name" placeholder="i-mon-vm" value="${d?.name ?? ''}"
|
||||
${edit ? 'readonly' : 'required'} />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>Mémoire (MB)</label>
|
||||
<input name="memory" type="number" min="128" step="128"
|
||||
placeholder="1024" value="${d?.memory ?? ''}" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>vCPUs</label>
|
||||
<input name="cpus" type="number" min="1" max="32"
|
||||
placeholder="2" value="${d?.cpus ?? ''}" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field toggle-row">
|
||||
<input name="uefi" type="checkbox" id="uefi-cb"
|
||||
${d?.uefi ? 'checked' : ''} />
|
||||
<label for="uefi-cb" class="toggle-label">UEFI (OVMF)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Authentification</div>
|
||||
<div class="field">
|
||||
<label>Clé SSH</label>
|
||||
<input name="sshkey" placeholder="ssh-ed25519 AAAA…"
|
||||
value="${d?.sshkey ?? ''}" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Mot de passe (optionnel)</label>
|
||||
<input name="password" type="password" placeholder="laisser vide = désactivé" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Interface réseau</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>Subnet</label>
|
||||
<input name="subnet" placeholder="sn-000000"
|
||||
value="${d?.interfaces?.[0]?.subnet ?? ''}" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>IP</label>
|
||||
<input name="ip" placeholder="192.168.14.x"
|
||||
value="${d?.interfaces?.[0]?.ip ?? ''}" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Stockage</div>
|
||||
<div id="disks">
|
||||
${this.#renderDisks(d?.storage ?? [{ dev: 'vda', path: '' }])}
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost" id="add-disk">
|
||||
${PLUS_ICON} Ajouter un disque
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">
|
||||
${edit ? 'Mettre à jour' : 'Créer'}
|
||||
</button>
|
||||
${edit ? `<button type="button" class="btn btn-danger" id="delete-btn">Supprimer</button>` : ''}
|
||||
</div>
|
||||
|
||||
</form>
|
||||
`
|
||||
|
||||
this.#bindEvents()
|
||||
}
|
||||
|
||||
#renderDisks(disks) {
|
||||
return disks.map((disk, i) => `
|
||||
<div class="disk-row" data-disk="${i}">
|
||||
<input name="dev_${i}" placeholder="vda" value="${disk.dev ?? ''}" required />
|
||||
<input name="path_${i}" placeholder="/vm/nom.qcow2" value="${disk.path ?? ''}" required />
|
||||
<button type="button" class="btn btn-ghost" data-remove-disk="${i}">${TRASH_ICON}</button>
|
||||
</div>
|
||||
`).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 = `
|
||||
<input name="dev_${idx}" placeholder="vda" required />
|
||||
<input name="path_${idx}" placeholder="/vm/nom.qcow2" required />
|
||||
<button type="button" class="btn btn-ghost" data-remove-disk="${idx}">${TRASH_ICON}</button>
|
||||
`
|
||||
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)
|
||||
5
web/components/form-vm/manifest.json
Normal file
5
web/components/form-vm/manifest.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"tag": "form-vm",
|
||||
"version": "0.1.0",
|
||||
"description": "Create or edit a VM. Auto-detects mode via vm-name attribute."
|
||||
}
|
||||
81
web/components/login-gate/login-gate.css
Normal file
81
web/components/login-gate/login-gate.css
Normal file
|
|
@ -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; }
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
// Phase 2: swap for <oidc-gate> 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 = `
|
||||
<style>
|
||||
* { 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;
|
||||
}
|
||||
|
||||
.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, opacity 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button:hover { background: #b4d0fa; }
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" href="${CSS}">
|
||||
<div class="overlay">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
|
|
|
|||
30
web/components/logout-button/logout-button.css
Normal file
30
web/components/logout-button/logout-button.css
Normal file
|
|
@ -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; }
|
||||
|
|
@ -1,63 +1,21 @@
|
|||
// Logout button — drop anywhere in the DOM.
|
||||
//
|
||||
// <logout-button></logout-button> full button with label
|
||||
// <logout-button compact></logout-button> icon only (for tight spaces)
|
||||
//
|
||||
// Delegates to <login-gate>.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 = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`
|
||||
|
||||
class LogoutButton extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.attachShadow({ mode: 'open' })
|
||||
|
||||
const compact = this.hasAttribute('compact')
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: inline-flex; }
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: transparent;
|
||||
border: 1px solid #313244;
|
||||
border-radius: 6px;
|
||||
padding: ${compact ? '7px' : '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; }
|
||||
.label { ${compact ? 'display: none;' : ''} }
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" href="${CSS}">
|
||||
<button type="button" title="Log out">
|
||||
<span class="icon">${ICON}</span>
|
||||
<span class="label">Log out</span>
|
||||
</button>
|
||||
`
|
||||
|
||||
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]')
|
||||
}
|
||||
|
|
|
|||
40
web/components/side-nav/side-nav.css
Normal file
40
web/components/side-nav/side-nav.css
Normal file
|
|
@ -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; }
|
||||
|
|
@ -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 <header>. Géométrie gérée en inline style pour
|
||||
// éviter les conflits de spécificité avec le shadow DOM.
|
||||
|
||||
const HAMBURGER_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>`
|
||||
|
||||
const ICONS = {
|
||||
dashboard: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`,
|
||||
|
|
@ -10,90 +18,189 @@ const ICONS = {
|
|||
vm: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`,
|
||||
}
|
||||
|
||||
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] ?? `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>`
|
||||
}
|
||||
|
||||
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 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 `
|
||||
<a href="${item.href}" class="nav-item ${isActive ? 'active' : ''}">
|
||||
// ── 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 `<a href="${item.href}" class="item ${active ? 'active' : ''}">
|
||||
<span class="icon">${resolveIcon(item.icon)}</span>
|
||||
<span class="label">${item.label}</span>
|
||||
</a>
|
||||
`
|
||||
</a>`
|
||||
}).join('')
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
: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%;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: #a6adc8;
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #313244;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #313244;
|
||||
color: #89b4fa;
|
||||
}
|
||||
|
||||
.nav-item.active .icon {
|
||||
color: #89b4fa;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #6c7086;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item:hover .icon {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" href="${CSS}">
|
||||
${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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
5
web/components/side-panel/manifest.json
Normal file
5
web/components/side-panel/manifest.json
Normal file
|
|
@ -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()."
|
||||
}
|
||||
83
web/components/side-panel/side-panel.css
Normal file
83
web/components/side-panel/side-panel.css
Normal file
|
|
@ -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; }
|
||||
165
web/components/side-panel/side-panel.js
Normal file
165
web/components/side-panel/side-panel.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// Configurable right-side drawer panel.
|
||||
//
|
||||
// ── Declarative ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// <side-panel title="VPC details" width="520px">
|
||||
// <vpc-detail vpc="vp-admin"></vpc-detail>
|
||||
// </side-panel>
|
||||
// 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: '<vpc-detail vpc="vp-admin"></vpc-detail>',
|
||||
// })
|
||||
// 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 = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>`
|
||||
|
||||
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 = `
|
||||
<link rel="stylesheet" href="${CSS}">
|
||||
<div class="header">
|
||||
<span class="title">${title}</span>
|
||||
<button class="close" aria-label="Close">${CLOSE_ICON}</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`
|
||||
|
||||
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)
|
||||
54
web/core/router.js
Normal file
54
web/core/router.js
Normal file
|
|
@ -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 <link> — 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()))
|
||||
|
|
@ -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 @@
|
|||
<div class="layout">
|
||||
<side-nav></side-nav>
|
||||
|
||||
<main>
|
||||
<div id="error"></div>
|
||||
<date-display></date-display>
|
||||
<biscuit-debug></biscuit-debug>
|
||||
</main>
|
||||
<main></main>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import './core/registry.js'
|
||||
|
||||
window.addEventListener('unhandledrejection', e => {
|
||||
const el = document.getElementById('error')
|
||||
if (el) {
|
||||
el.style.display = 'block'
|
||||
el.textContent = `Error: ${e.reason?.message ?? e.reason}`
|
||||
}
|
||||
})
|
||||
|
||||
// Sequential: registry defines all custom elements,
|
||||
// then router renders the current route.
|
||||
await import('./core/registry.js')
|
||||
await import('./core/router.js')
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
34
web/pages.json
Normal file
34
web/pages.json
Normal file
|
|
@ -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
|
||||
}
|
||||
]
|
||||
2
web/pages/dashboard/dashboard.css
Normal file
2
web/pages/dashboard/dashboard.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/* Styles scoped to the dashboard page.
|
||||
Applied when the route is active, removed on navigation. */
|
||||
46
web/pages/dashboard/dashboard.js
Normal file
46
web/pages/dashboard/dashboard.js
Normal file
|
|
@ -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: `<p style="color:#a6adc8;font-size:14px;line-height:1.6">
|
||||
Contenu du panneau <strong style="color:#cdd6f4">${btn.dataset.panelTitle}</strong>.<br>
|
||||
Largeur : ${btn.dataset.panelWidth}.<br><br>
|
||||
Tu peux ouvrir plusieurs panneaux — ils s'empilent vers la gauche.
|
||||
Ferme avec ✕, Échap, ou en cliquant le fond.
|
||||
</p>`,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
main.querySelectorAll('[data-action="create-vm"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
SidePanel.open({
|
||||
title: 'Nouvelle VM',
|
||||
width: '520px',
|
||||
content: `<form-vm></form-vm>`,
|
||||
})
|
||||
// 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 vm-name="i-test1"></form-vm>`
|
||||
})
|
||||
// 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>
|
||||
main.addEventListener('vm-saved', e => {
|
||||
console.log('vm-saved', e.detail)
|
||||
})
|
||||
}
|
||||
24
web/pages/dashboard/index.html
Normal file
24
web/pages/dashboard/index.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<date-display></date-display>
|
||||
|
||||
<div style="gap:8px;margin-top:0px;">
|
||||
<button data-panel-title="Panel A" data-panel-width="400px"
|
||||
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||
padding:7px 14px;color:#89b4fa;font-size:13px;cursor:pointer;">
|
||||
Open panel A
|
||||
</button>
|
||||
<button data-panel-title="Panel B" data-panel-width="520px"
|
||||
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
|
||||
Open panel B
|
||||
</button>
|
||||
<button data-action="create-vm"
|
||||
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
|
||||
Create-vm
|
||||
</button>
|
||||
<button data-action="edit-vm-i-test1"
|
||||
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
|
||||
Edition
|
||||
</button>
|
||||
</div>
|
||||
6
web/pages/dashboard/manifest.json
Normal file
6
web/pages/dashboard/manifest.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"route": "dashboard",
|
||||
"label": "Dashboard",
|
||||
"icon": "dashboard",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
1
web/pages/subnets/index.html
Normal file
1
web/pages/subnets/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<p>Subnets — à venir</p>
|
||||
1
web/pages/subnets/manifest.json
Normal file
1
web/pages/subnets/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "route": "subnets", "label": "Subnets", "icon": "subnet", "version": "0.1.0" }
|
||||
1
web/pages/vms/index.html
Normal file
1
web/pages/vms/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<p>VMs — à venir</p>
|
||||
1
web/pages/vms/manifest.json
Normal file
1
web/pages/vms/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "route": "vms", "label": "VMs", "icon": "vm", "version": "0.1.0" }
|
||||
1
web/pages/vpcs/index.html
Normal file
1
web/pages/vpcs/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<p>VPCs — à venir</p>
|
||||
1
web/pages/vpcs/manifest.json
Normal file
1
web/pages/vpcs/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "route": "vpcs", "label": "VPCs", "icon": "vpc", "version": "0.1.0" }
|
||||
140
web/vpc.html
Normal file
140
web/vpc.html
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>two — dashboard</title>
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #181825;
|
||||
color: #cdd6f4;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 24px;
|
||||
border-bottom: 1px solid #313244;
|
||||
background: #1e1e2e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #89b4fa;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
header span {
|
||||
font-size: 12px;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
header .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 28px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
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;
|
||||
border: 1px solid #f38ba8;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
color: #f38ba8;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<login-gate></login-gate>
|
||||
<api-client></api-client>
|
||||
|
||||
<header>
|
||||
<h1>two</h1>
|
||||
<span>network orchestrator</span>
|
||||
<div class="spacer"></div>
|
||||
<logout-button></logout-button>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<side-nav></side-nav>
|
||||
|
||||
<main>
|
||||
<div id="error"></div>
|
||||
<date-display></date-display>
|
||||
|
||||
<!-- Dev test: open stacked panels -->
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button onclick="openTestPanel('Panel A', '400px')"
|
||||
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||
padding:7px 14px;color:#89b4fa;font-size:13px;cursor:pointer;">
|
||||
Open panel A
|
||||
</button>
|
||||
<button onclick="openTestPanel('Panel B', '520px')"
|
||||
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
|
||||
Open panel B
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import './core/registry.js'
|
||||
import { SidePanel } from './components/side-panel/side-panel.js'
|
||||
|
||||
window.openTestPanel = (title, width) => {
|
||||
SidePanel.open({
|
||||
title,
|
||||
width,
|
||||
content: `<p style="color:#a6adc8;font-size:14px;line-height:1.6">
|
||||
Contenu du panneau <strong style="color:#cdd6f4">${title}</strong>.<br>
|
||||
Largeur : ${width}.<br><br>
|
||||
Tu peux ouvrir plusieurs panneaux — ils s'empilent vers la gauche.
|
||||
Ferme avec ✕, Échap, ou en cliquant le fond.
|
||||
</p>`,
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('unhandledrejection', e => {
|
||||
const el = document.getElementById('error')
|
||||
el.style.display = 'block'
|
||||
el.textContent = `Error: ${e.reason?.message ?? e.reason}`
|
||||
})
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue