diff --git a/.gitignore b/.gitignore index aaee194..68ae417 100644 --- a/.gitignore +++ b/.gitignore @@ -30,9 +30,3 @@ go.work.sum # ignore local info data/ - - -web/components.json -web/navigation.json -web/pages.json -web/vendor/ diff --git a/web/build.sh b/web/build.sh deleted file mode 100755 index b75143c..0000000 --- a/web/build.sh +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env bash -# Build the web UI component set from components.yml. -# -# ./build.sh build (download remote components + generate components.json) -# ./build.sh --check validate manifest and entry files without downloading -# -# Reads components.yml, downloads each remote component into components// -# at the requested ref, then generates components.json (runtime load list) and -# components.lock.json (resolved commit pins). -# -# Dependencies: yq (mikefarah/yq v4), jq, curl -# Optional env: -# GIT_TOKEN forge token for private repos (sent as "Authorization: token …") - -set -euo pipefail - -WEB_DIR="$(cd "$(dirname "$0")" && pwd)" -MANIFEST="${WEB_DIR}/components.yml" -COMPONENTS_DIR="${WEB_DIR}/components" -OUTPUT="${WEB_DIR}/components.json" -LOCKFILE="${WEB_DIR}/components.lock.json" -GIT_TOKEN="${GIT_TOKEN:-}" - -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' -log() { echo -e "${GREEN}[+]${NC} $*"; } -warn() { echo -e "${YELLOW}[!]${NC} $*"; } -info() { echo -e "${BLUE}[i]${NC} $*"; } -die() { echo -e "${RED}[-]${NC} $*" >&2; exit 1; } - -CHECK_ONLY=false -[[ "${1:-}" == "--check" ]] && CHECK_ONLY=true - -# ── Dependency + manifest guards ──────────────────────────────────────────────── - -command -v yq &>/dev/null || die "yq is required (mikefarah/yq v4)" -command -v jq &>/dev/null || die "jq is required" -command -v curl &>/dev/null || die "curl is required" -[[ -f "$MANIFEST" ]] || die "manifest not found: $MANIFEST" - -# ── Forge API helpers ─────────────────────────────────────────────────────────── - -api_get() { - local url="$1" - local args=(-sf -H "Accept: application/json") - [[ -n "$GIT_TOKEN" ]] && args+=(-H "Authorization: token ${GIT_TOKEN}") - curl "${args[@]}" "$url" || die "API request failed: $url" -} - -raw_dl() { - local url="$1" out="$2" - local args=(-sfL) - [[ -n "$GIT_TOKEN" ]] && args+=(-H "Authorization: token ${GIT_TOKEN}") - curl "${args[@]}" "$url" -o "$out" || die "download failed: $url" -} - -# Split a repo URL into "server owner repo". -# https://git.g3e.fr/team-reseau/vpc-panel → https://git.g3e.fr team-reseau vpc-panel -parse_repo_url() { - local url="$1" - url="${url%.git}"; url="${url%/}" - local proto="${url%%://*}" - local rest="${url#*://}" - local host="${rest%%/*}" - local path="${rest#*/}" - [[ "$path" == "$rest" || -z "$path" ]] && die "invalid repo URL (need owner/repo): $1" - local owner="${path%/*}" repo="${path##*/}" - [[ -z "$owner" || -z "$repo" ]] && die "invalid repo URL (need owner/repo): $1" - echo "${proto}://${host}" "$owner" "$repo" -} - -# Build the commits API URL — handles GitHub (api.github.com) vs Gitea (/api/v1). -forge_commits_url() { - local server="$1" owner="$2" repo="$3" ref="$4" - if [[ "$server" == "https://github.com" ]]; then - echo "https://api.github.com/repos/${owner}/${repo}/commits?sha=${ref}&per_page=1" - else - echo "${server}/api/v1/repos/${owner}/${repo}/commits?sha=${ref}&limit=1" - fi -} - -# Build the contents API URL for a given path (empty = repo root). -forge_contents_url() { - local server="$1" owner="$2" repo="$3" ref="$4" path="$5" - if [[ "$server" == "https://github.com" ]]; then - if [[ -z "$path" ]]; then - echo "https://api.github.com/repos/${owner}/${repo}/contents?ref=${ref}" - else - echo "https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}" - fi - else - if [[ -z "$path" ]]; then - echo "${server}/api/v1/repos/${owner}/${repo}/contents?ref=${ref}" - else - echo "${server}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${ref}" - fi - fi -} - -resolve_commit() { - local server="$1" owner="$2" repo="$3" ref="$4" - api_get "$(forge_commits_url "$server" "$owner" "$repo" "$ref")" \ - | jq -r '.[0].sha // empty' -} - -# Recursively download a repo path (relative to repo root) into dest dir. -download_path() { - local server="$1" owner="$2" repo="$3" ref="$4" rpath="$5" dest="$6" - - local api - api="$(forge_contents_url "$server" "$owner" "$repo" "$ref" "$rpath")" - - local listing - listing="$(api_get "$api")" - - mkdir -p "$dest" - while IFS= read -r entry; do - local type name dl path - type="$(jq -r '.type' <<<"$entry")" - name="$(jq -r '.name' <<<"$entry")" - dl="$( jq -r '.download_url // ""' <<<"$entry")" - path="$(jq -r '.path' <<<"$entry")" - - if [[ "$type" == "dir" ]]; then - download_path "$server" "$owner" "$repo" "$ref" "$path" "${dest}/${name}" - else - [[ -z "$dl" ]] && die "no download_url for ${path} in ${owner}/${repo}@${ref}" - raw_dl "$dl" "${dest}/${name}" - fi - done < <(echo "$listing" | jq -c 'if type=="array" then .[] else . end') -} - -# ── 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 VENDORS < <(yq '(.vendors // [])[] | [.name, .repo, (.ref // "main"), (.path // ""), (.dest // "")] | join("|")' "$MANIFEST") - -# ── Download vendors ──────────────────────────────────────────────────────────── -# Vendors are third-party libraries downloaded into web/vendor/ at build time. -# They are gitignored and never loaded by components.json — components import -# them directly via relative paths (e.g. ../../vendor/novnc/core/rfb.js). - -for spec in "${VENDORS[@]}"; do - [[ -z "$spec" ]] && continue - IFS='|' read -r name repo ref vpath dest <<<"$spec" - [[ -z "$name" || -z "$repo" ]] && die "vendor entry missing name or repo: '$spec'" - - read -r server owner gitrepo <<<"$(parse_repo_url "$repo")" - [[ -z "$dest" ]] && dest="vendor/${name}" - local_dest="${WEB_DIR}/${dest}" - - if [[ "$CHECK_ONLY" == true ]]; then - info "would fetch vendor ${name} from ${repo}@${ref} path=${vpath:-/}" - continue - fi - - log "fetching vendor ${name} ← ${owner}/${gitrepo}@${ref}${vpath:+ path=}${vpath}" - commit="$(resolve_commit "$server" "$owner" "$gitrepo" "$ref")" - [[ -z "$commit" ]] && die "cannot resolve ref '${ref}' in ${owner}/${gitrepo}" - - rm -rf "$local_dest" - download_path "$server" "$owner" "$gitrepo" "$ref" "$vpath" "$local_dest" - info " vendor '${name}' → ${dest} (${commit:0:12})" -done - -# Ordered list of load paths for components.json -declare -a LOAD_PATHS=() - -# ── Validate locals ────────────────────────────────────────────────────────────── - -for name in "${LOCALS[@]}"; do - [[ -z "$name" ]] && continue - entry="${COMPONENTS_DIR}/${name}/${name}.js" - if [[ ! -f "$entry" ]]; then - die "local component '${name}' missing entry file: components/${name}/${name}.js" - fi - LOAD_PATHS+=("components/${name}/${name}.js") -done - -# ── Download remotes ───────────────────────────────────────────────────────────── - -LOCK_ENTRIES="[]" - -for spec in "${REMOTES[@]}"; do - [[ -z "$spec" ]] && continue - IFS='|' read -r name repo ref <<<"$spec" - [[ -z "$name" || -z "$repo" ]] && die "manifest entry missing name or repo: '$spec'" - - read -r server owner gitrepo <<<"$(parse_repo_url "$repo")" - dest="${COMPONENTS_DIR}/${name}" - - if [[ "$CHECK_ONLY" == true ]]; then - info "would fetch ${name} from ${repo}@${ref}" - commit="$(resolve_commit "$server" "$owner" "$gitrepo" "$ref" || true)" - [[ -z "$commit" ]] && warn " could not resolve ref '${ref}' in ${owner}/${gitrepo}" - LOAD_PATHS+=("components/${name}/${name}.js") - continue - fi - - log "fetching ${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" - - entry="${dest}/${name}.js" - [[ -f "$entry" ]] || die "component '${name}' has no ${name}.js at repo root" - - LOAD_PATHS+=("components/${name}/${name}.js") - LOCK_ENTRIES="$(jq \ - --arg name "$name" --arg repo "$repo" --arg ref "$ref" --arg commit "$commit" \ - '. + [{name:$name, repo:$repo, ref:$ref, commit:$commit}]' <<<"$LOCK_ENTRIES")" - info " pinned ${commit:0:12}" -done - -# ── Generate components.json ───────────────────────────────────────────────────── - -if [[ "$CHECK_ONLY" == true ]]; then - log "check passed: ${#LOAD_PATHS[@]} components, manifest valid" - exit 0 -fi - -printf '%s\n' "${LOAD_PATHS[@]}" | jq -R . | jq -s . > "$OUTPUT" -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 - # Build navigation.json from menu: section. - # Convert full manifest to JSON once (single yq call) then jq handles all logic. - # Menu items: string → page lookup, object → pass-through (separator/section). - manifest_json=$(yq -o json '.' "$MANIFEST") - menu_json=$(echo "$manifest_json" | jq -c '.menu // []') - menu_len=$(echo "$menu_json" | jq 'length') - - if [[ "$menu_len" -gt 0 ]]; then - NAV_ENTRIES=$(jq -n \ - --argjson menu "$menu_json" \ - --argjson pages "$PAGE_ENTRIES" \ - 'def resolve($pages): - . as $r | $pages[] | select(.route == $r) | { label, href: ("#/" + .route), icon }; - [ $menu[] | - if type == "string" - then resolve($pages) - elif .type == "group" - then { type: "group", label: .label, icon: (.icon // ""), - children: [ .children[]? | - if type == "string" then resolve($pages) else . end ] } - else . - end - ]') - else - # No menu defined — use all pages in discovery order - NAV_ENTRIES=$(echo "$PAGE_ENTRIES" | \ - jq '[.[] | { label, href: ("#/" + .route), icon }]') - fi - - # pages.json — full list (all pages, original discovery order) - echo "$PAGE_ENTRIES" | jq '.' > "${WEB_DIR}/pages.json" - - # navigation.json — write NAV_ENTRIES as-is (already correctly shaped) - echo "$NAV_ENTRIES" | jq '.' > "${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.lock.json b/web/components.lock.json deleted file mode 100644 index 0d4f101..0000000 --- a/web/components.lock.json +++ /dev/null @@ -1,2 +0,0 @@ -[ -] diff --git a/web/components.yml b/web/components.yml deleted file mode 100644 index 0b2e70b..0000000 --- a/web/components.yml +++ /dev/null @@ -1,101 +0,0 @@ -# Component manifest — source of truth for the web UI. -# -# Edit this file, then run ./build.sh to: -# - download every remote component into components// -# - generate components.json (the runtime load list) in declared order -# -# Load order matters: service components (login-gate, api-client) must come -# before the components that consume them. - -# Local components shipped inside this repo. Not downloaded — listed here only -# so build.sh can place them in the generated load order. -local_components: - - login-gate - - api-client - - logout-button - - side-panel - - date-display - - form-vm - - vnc-viewer - -# Remote components fetched from git at build time. -# Each is its own repo; its root must contain .js and manifest.json. -# -# - name: vpc-panel # → components/vpc-panel/ -# repo: https://git.g3e.fr/team-reseau/vpc-panel -# ref: v1.2.0 # tag, branch or commit (default: main) -components: - - name: side-nav - repo: https://git.g3e.fr/syonad/syn-side-nav - ref: main - -# Vendor libraries — downloaded into web/vendor/ at build time (gitignored). -# Components import them via relative paths, never via components.json. -# path: restrict download to a subdirectory of the repo (optional). -# dest: destination under web/ (default: vendor/). -vendors: - - name: novnc-core - repo: https://github.com/novnc/noVNC - ref: v1.5.0 - path: core - dest: vendor/novnc/core - - name: novnc-vendor - repo: https://github.com/novnc/noVNC - ref: v1.5.0 - path: vendor - dest: vendor/novnc/vendor - -# 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 - - account - -# 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 - - type: separator - - type: group - label: Réseau - icon: vpc - children: - - vpcs - - subnets - - type: group - label: Empty - icon: vpc - children: [] - - type: group - label: Compute - icon: vm - children: - - vms - - type: group - label: Comptes - icon: account - children: [] - - type: separator - - dashboard - - type: section - label: Réseau - - vpcs - - subnets - - vms diff --git a/web/components/api-client/api-client.js b/web/components/api-client/api-client.js deleted file mode 100644 index 4973e1e..0000000 --- a/web/components/api-client/api-client.js +++ /dev/null @@ -1,132 +0,0 @@ -// API service component. -// -// Declare after login-gate in the shell: -// -// -// -// Use from any other component: -// const api = document.querySelector('api-client') -// const vrfs = await api.netbox.list('/ipam/vrfs/') -// const vpc = await api.agent.get('/vpcs/vp-admin') -// await api.agent.post('/vpcs', { name: 'vp-admin', cidr: '10.0.0.0/8' }) -// await api.agent.delete('/vpcs/vp-admin') -// await api.agent.waitFor('/vms/i-test1', 'started') -// -// Phase 2: swap login-gate for oidc-gate → this component unchanged. - -export class ApiError extends Error { - constructor(origin, status, message) { - super(`[${origin}] HTTP ${status}: ${message}`) - this.origin = origin // 'netbox' | 'agent' - this.status = status - } -} - -class ApiClient extends HTMLElement { - // Resolves once login-gate is ready (or immediately if no gate in DOM). - #credentials = null - - async connectedCallback() { - this.style.display = 'none' - - // Support login-gate (static token) and biscuit-gate (attenuated token). - const gate = document.querySelector('login-gate') - this.#credentials = gate ? await gate.ready : null - - this.netbox = this.#buildNetbox() - this.agent = this.#buildAgent() - } - - - get creds() { - return this.#credentials ?? {} - } - - // ── Internal ────────────────────────────────────────────────────────────── - - async #request(origin, url, options = {}) { - let response - try { - response = await fetch(url, options) - } catch (e) { - throw new ApiError(origin, 0, `Network error: ${e.message}`) - } - - if (response.status === 204) return null - - const text = await response.text() - let json - try { json = JSON.parse(text) } catch { json = null } - - if (!response.ok) { - const detail = json?.detail ?? json?.message ?? text - throw new ApiError(origin, response.status, detail) - } - - return json - } - - // ── Netbox ──────────────────────────────────────────────────────────────── - - #buildNetbox() { - const headers = () => ({ - 'Authorization': `Token ${this.creds.netbox_token}`, - 'Accept': 'application/json', - }) - - const url = (path, params = {}) => { - const u = new URL(path, this.creds.netbox_url + '/api/') - Object.entries(params).forEach(([k, v]) => { - if (v != null) u.searchParams.set(k, v) - }) - return u.toString() - } - - return { - list: (path, params = {}) => - this.#request('netbox', url(path, { limit: 1000, ...params }), { headers: headers() }) - .then(d => d?.results ?? []), - - get: (path, params = {}) => - this.#request('netbox', url(path, params), { headers: headers() }), - } - } - - // ── Agent ───────────────────────────────────────────────────────────────── - - #buildAgent() { - const headers = (body = false) => ({ - 'Accept': 'application/json', - ...(body ? { 'Content-Type': 'application/json' } : {}), - }) - - const url = path => new URL(path, this.creds.agent_url + '/').toString() - - const req = (method, path, body) => - this.#request('agent', url(path), { - method, - headers: headers(body != null), - ...(body != null ? { body: JSON.stringify(body) } : {}), - }) - - return { - get: path => req('GET', path), - list: path => req('GET', path), - post: (path, b) => req('POST', path, b), - delete: path => req('DELETE', path), - - waitFor: async (path, desired, { timeout = 120_000, interval = 2_000 } = {}) => { - const deadline = Date.now() + timeout - while (Date.now() < deadline) { - const r = await req('GET', path) - if (r?.state === desired) return r - if (r?.state === 'error') throw new ApiError('agent', 'error', `${path} reached error state`) - await new Promise(ok => setTimeout(ok, interval)) - } - throw new ApiError('agent', 'timeout', `${path} did not reach '${desired}' in ${timeout}ms`) - }, - } - } -} - -customElements.define('api-client', ApiClient) diff --git a/web/components/api-client/manifest.json b/web/components/api-client/manifest.json deleted file mode 100644 index 49c3c0c..0000000 --- a/web/components/api-client/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "tag": "api-client", - "version": "0.1.0", - "description": "Service component. Provides Netbox and agent API access to all other components via document.querySelector('api-client')." -} 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 deleted file mode 100644 index 38f5598..0000000 --- a/web/components/date-display/date-display.js +++ /dev/null @@ -1,32 +0,0 @@ -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.#tick() - this.#interval = setInterval(() => this.#tick(), 1000) - } - - disconnectedCallback() { - clearInterval(this.#interval) - } - - #tick() { - const now = new Date() - this.shadowRoot.getElementById('time').textContent = now.toLocaleTimeString() - this.shadowRoot.getElementById('date').textContent = now.toLocaleDateString(undefined, { - weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' - }) - } -} - -customElements.define('date-display', DateDisplay) diff --git a/web/components/date-display/manifest.json b/web/components/date-display/manifest.json deleted file mode 100644 index 6320a47..0000000 --- a/web/components/date-display/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "tag": "date-display", - "version": "0.1.0", - "description": "Displays current date and time, refreshed every second." -} 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 deleted file mode 100644 index 929539b..0000000 --- a/web/components/login-gate/login-gate.js +++ /dev/null @@ -1,111 +0,0 @@ -// Auth service component. -// -// Declare before api-client in the shell: -// -// -// Exposes: -// gate.ready → Promise — awaited by api-client before any call -// gate.credentials → current credentials or null -// gate.logout() → clears session and reloads -// -// Credentials shape (extensible for Phase 2): -// { -// netbox_url: string, -// netbox_token: string, -// agent_url: string, -// } -// -// 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 - - get credentials() { - const raw = sessionStorage.getItem(SESSION_KEY) - return raw ? JSON.parse(raw) : null - } - - logout() { - sessionStorage.removeItem(SESSION_KEY) - window.location.reload() - } - - async connectedCallback() { - this.style.display = 'none' - - this.ready = new Promise(resolve => { this.#resolve = resolve }) - - const stored = this.credentials - if (stored) { - this.#resolve(stored) - return - } - - let defaults = {} - try { - const r = await fetch('./config.json') - if (r.ok) defaults = await r.json() - } catch { /* no defaults */ } - - this.#renderOverlay(defaults) - } - - #renderOverlay(defaults) { - const overlay = document.createElement('div') - overlay.attachShadow({ mode: 'open' }) - overlay.shadowRoot.innerHTML = ` - -
-
-
-

two — connect

-

Enter your Netbox and agent details to continue.

-
- -
-
- - -
-
- - -
-
- - -
- - -
-
-
- ` - - document.body.appendChild(overlay) - - const form = overlay.shadowRoot.getElementById('form') - - form.addEventListener('submit', e => { - e.preventDefault() - - const credentials = { - netbox_url: overlay.shadowRoot.getElementById('netbox_url').value.replace(/\/$/, ''), - netbox_token: overlay.shadowRoot.getElementById('netbox_token').value.trim(), - agent_url: overlay.shadowRoot.getElementById('agent_url').value.replace(/\/$/, ''), - } - - sessionStorage.setItem(SESSION_KEY, JSON.stringify(credentials)) - overlay.remove() - this.#resolve(credentials) - }) - } -} - -customElements.define('login-gate', LoginGate) diff --git a/web/components/login-gate/manifest.json b/web/components/login-gate/manifest.json deleted file mode 100644 index 0dfe4cc..0000000 --- a/web/components/login-gate/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "tag": "login-gate", - "version": "0.1.0", - "description": "Auth service component. Exposes a 'ready' Promise that resolves with credentials. Shows a login overlay if no session exists." -} 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 deleted file mode 100644 index 69b651f..0000000 --- a/web/components/logout-button/logout-button.js +++ /dev/null @@ -1,33 +0,0 @@ -const CSS = new URL('./logout-button.css', import.meta.url).href - -const ICON = `` - -class LogoutButton extends HTMLElement { - connectedCallback() { - this.attachShadow({ mode: 'open' }) - this.shadowRoot.innerHTML = ` - - - ` - this.shadowRoot.querySelector('button') - .addEventListener('click', () => this.#logout()) - } - - #gate() { - return document.querySelector('login-gate, [data-auth-gate]') - } - - #logout() { - const gate = this.#gate() - if (gate?.logout) { - gate.logout() - } else { - console.warn('logout-button: no auth gate with logout() found in document') - } - } -} - -customElements.define('logout-button', LogoutButton) diff --git a/web/components/logout-button/manifest.json b/web/components/logout-button/manifest.json deleted file mode 100644 index 0abc50b..0000000 --- a/web/components/logout-button/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "tag": "logout-button", - "version": "0.1.0", - "description": "Drop-anywhere button that clears the session via login-gate.logout(). Reusable in header, members panel, etc." -} diff --git a/web/components/side-nav/manifest.json b/web/components/side-nav/manifest.json deleted file mode 100644 index 67cdfbd..0000000 --- a/web/components/side-nav/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "tag": "side-nav", - "version": "0.1.0", - "description": "Left sidebar navigation, driven by navigation.json." -} diff --git a/web/components/side-nav/side-nav.css b/web/components/side-nav/side-nav.css deleted file mode 100644 index 5dd311f..0000000 --- a/web/components/side-nav/side-nav.css +++ /dev/null @@ -1,107 +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: 5px 12px; - margin: 2px; - border-radius: 6px; - text-decoration: none; - color: #a6adc8; - font-size: 14px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - min-height: 25px; - 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; } - -.section-header { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; - color: #45475a; - padding: 14px 12px 4px; -} - -.separator { - height: 1px; - background: #313244; - margin: 6px 4px; -} - -/* ── Accordion group ────────────────────────────────────────────────────── */ - -.group { display: flex; flex-direction: column; } - -.group-header { - display: flex; - align-items: center; - gap: 10px; - padding: 5px 12px; - margin: 2px; - border-radius: 6px; - background: transparent; - border: none; - cursor: pointer; - color: #a6adc8; - font-size: 14px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - min-height: 25px; - transition: background 0.1s, color 0.1s; - width: 100%; - text-align: left; -} - -.group-header:hover { background: #313244; color: #cdd6f4; } -.group-header:hover .icon { color: #cdd6f4; } -.group-header.has-active { color: #89b4fa; } -.group-header.has-active .icon { color: #89b4fa; } - -.chevron { - margin-left: auto; - display: flex; - align-items: center; - color: #6c7086; - flex-shrink: 0; - transition: transform 0.2s ease; -} - -.chevron.open { transform: rotate(90deg); } - -.group-children { - display: grid; - grid-template-rows: 0fr; - transition: grid-template-rows 0.2s ease; -} - -.group-children.open { grid-template-rows: 1fr; } - -.group-children > div { overflow: hidden; } - -.item.child { padding-left: 36px; } diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js deleted file mode 100644 index 7ccfadf..0000000 --- a/web/components/side-nav/side-nav.js +++ /dev/null @@ -1,295 +0,0 @@ -// 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 = ` - - - -` - -const CHEVRON_ICON = ` - -` - -const CSS = new URL('./side-nav.css', import.meta.url).href -const MQ = window.matchMedia('(max-width: 768px)') -const NAV_W = 220 // px - -const ICON_FALLBACK = `` - -const iconsRes = await fetch('./icons.json') -const ICONS = iconsRes.ok ? await iconsRes.json() : {} - -function resolveIcon(name) { - return ICONS[name] ?? ICON_FALLBACK -} - -class SideNav extends HTMLElement { - #items = [] - #expanded = new Set() - #hamburger = null - #backdrop = null - #mqHandler = null - #hashHandler = null - #isMobile = false - #readyResolve = null - - // Resolves once navigation.json is loaded and the nav is rendered. - // Await this before calling setGroupChildren(). - ready = new Promise(r => { this.#readyResolve = r }) - - 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}`) - this.#items = await response.json() - - // Auto-expand groups that contain the active route - const current = this.#currentHref() - for (const item of this.#items) { - if (item.type === 'group' && item.children?.some(c => c.href === current)) { - this.#expanded.add(item.label) - } - } - - this.#render() - this.#readyResolve() - - this.#mqHandler = () => this.#applyMode() - this.#hashHandler = () => this.#updateActive() - MQ.addEventListener('change', this.#mqHandler) - window.addEventListener('hashchange', this.#hashHandler) - this.#applyMode() - } - - disconnectedCallback() { - MQ.removeEventListener('change', this.#mqHandler) - window.removeEventListener('hashchange', this.#hashHandler) - this.#removeHamburger() - this.#removeBackdrop() - } - - // ── Public API ────────────────────────────────────────────────────────────── - - // Inject dynamic children into an existing group (identified by label). - // Call after awaiting nav.ready. - setGroupChildren(label, children) { - const group = this.#items.find(i => i.type === 'group' && i.label === label) - if (!group) return - group.children = children - this.#render() - } - - // ── Rendu des items ───────────────────────────────────────────────────────── - - #currentHref() { - return window.location.hash || `#/${this.#items[0]?.href?.replace(/^#\//, '') ?? 'dashboard'}` - } - - // Update active state 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) - ) - - this.shadowRoot?.querySelectorAll('.group-header').forEach(btn => { - const item = this.#items.find(i => i.label === btn.dataset.group) - const hasActive = item?.children?.some(c => c.href === current) ?? false - btn.classList.toggle('has-active', hasActive) - // Auto-expand if a child becomes active - if (hasActive && !this.#expanded.has(item.label)) { - this.#expanded.add(item.label) - btn.querySelector('.chevron')?.classList.add('open') - btn.nextElementSibling?.classList.add('open') - } - }) - } - - #toggleGroup(label) { - const isOpen = this.#expanded.has(label) - isOpen ? this.#expanded.delete(label) : this.#expanded.add(label) - - const btn = this.shadowRoot.querySelector(`[data-group="${label}"]`) - const children = btn?.nextElementSibling - btn?.querySelector('.chevron')?.classList.toggle('open', !isOpen) - children?.classList.toggle('open', !isOpen) - } - - #renderItem(item, current) { - if (item.type === 'separator') { - return `
` - } - if (item.type === 'section') { - return `
${item.label ?? ''}
` - } - if (item.type === 'group') { - const isOpen = this.#expanded.has(item.label) - const hasActive = item.children?.some(c => c.href === current) ?? false - const children = (item.children ?? []).map(child => { - const active = child.href === current - return ` - ${resolveIcon(child.icon)} - ${child.label} - ` - }).join('') - return ` -
- -
-
${children}
-
-
` - } - // Default: leaf link - if (!item.href || !item.label) return '' - const active = item.href === current - return ` - ${resolveIcon(item.icon)} - ${item.label} - ` - } - - #render() { - const current = this.#currentHref() - const links = this.#items.map(item => this.#renderItem(item, current)).join('') - - this.shadowRoot.innerHTML = ` - - ${links} - ` - - // Group toggle - this.shadowRoot.querySelectorAll('.group-header').forEach(btn => - btn.addEventListener('click', () => this.#toggleGroup(btn.dataset.group)) - ) - - // 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 }) - } -} - -customElements.define('side-nav', SideNav) 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/components/vnc-viewer/manifest.json b/web/components/vnc-viewer/manifest.json deleted file mode 100644 index 7218099..0000000 --- a/web/components/vnc-viewer/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "tag": "vnc-viewer", - "version": "0.1.0" -} diff --git a/web/components/vnc-viewer/vnc-viewer.js b/web/components/vnc-viewer/vnc-viewer.js deleted file mode 100644 index 36c8644..0000000 --- a/web/components/vnc-viewer/vnc-viewer.js +++ /dev/null @@ -1,164 +0,0 @@ -// VNC viewer component — wraps noVNC RFB. -// -// Usage (in a side-panel or page): -// -// -// Connects to ws://:9000/vnc/ via the VNC gateway (cmd/vncd). -// Agent host is read from login-gate credentials; falls back to location.hostname. -// -// Attributes: -// vm — VM name (required) -// port — gateway port (default: 9000) - -import RFB from '../../vendor/novnc/core/rfb.js' - -const RECONNECT_DELAY = 3000 // ms between auto-reconnect attempts - -class VncViewer extends HTMLElement { - #rfb = null - #retryTimer = null - - connectedCallback() { - this.attachShadow({ mode: 'open' }) - this.#render() - this.#connect() - } - - disconnectedCallback() { - clearTimeout(this.#retryTimer) - this.#rfb?.disconnect() - this.#rfb = null - } - - // ── Render ────────────────────────────────────────────────────────────────── - - #render() { - this.shadowRoot.innerHTML = ` - - -
-
- - Connexion… - - - -
- ` - - this.shadowRoot.getElementById('fullscreen-btn') - .addEventListener('click', () => this.#toggleFullscreen()) - - this.shadowRoot.getElementById('reconnect-btn') - .addEventListener('click', () => { this.#rfb?.disconnect(); this.#connect() }) - } - - // ── Connection ─────────────────────────────────────────────────────────────── - - #wsUrl() { - const vm = this.getAttribute('vm') - const port = this.getAttribute('port') ?? '9000' - const creds = document.querySelector('login-gate')?.credentials - const host = creds?.agent_url - ? new URL(creds.agent_url).hostname - : location.hostname - return `ws://${host}:${port}/vnc/${vm}` - } - - #connect() { - const vm = this.getAttribute('vm') - if (!vm) { this.#setStatus('disconnected', 'Attribut vm manquant'); return } - - const url = this.#wsUrl() - this.#setStatus('connecting', `Connexion à ${url}…`) - - this.#rfb = new RFB(this.shadowRoot.getElementById('screen'), url) - this.#rfb.scaleViewport = true - this.#rfb.resizeSession = false - - this.#rfb.addEventListener('connect', () => { - this.#setStatus('connected', 'Connecté') - }) - - this.#rfb.addEventListener('disconnect', e => { - const clean = e.detail?.clean ?? false - if (clean) { - this.#setStatus('disconnected', 'Déconnecté') - } else { - this.#setStatus('disconnected', 'Déconnecté — reconnexion dans 3s…') - this.#retryTimer = setTimeout(() => this.#connect(), RECONNECT_DELAY) - } - }) - - this.#rfb.addEventListener('credentialsrequired', () => { - this.#rfb.sendCredentials({ password: '' }) - }) - } - - // ── Helpers ────────────────────────────────────────────────────────────────── - - #setStatus(state, msg) { - const dot = this.shadowRoot.getElementById('dot') - const text = this.shadowRoot.getElementById('status-text') - if (!dot || !text) return - dot.className = `dot ${state}` - text.textContent = msg - } - - #toggleFullscreen() { - const screen = this.shadowRoot.getElementById('screen') - if (!document.fullscreenElement) { - screen.requestFullscreen?.() - } else { - document.exitFullscreen?.() - } - } -} - -customElements.define('vnc-viewer', VncViewer) diff --git a/web/config.json b/web/config.json deleted file mode 100644 index bf6e218..0000000 --- a/web/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "netbox_url": "http://netbox.local", - "netbox_token": "your-token-here", - "agent_url": "http://127.0.0.1:8080" -} diff --git a/web/core/config.js b/web/core/config.js deleted file mode 100644 index 51f9b56..0000000 --- a/web/core/config.js +++ /dev/null @@ -1,4 +0,0 @@ -const response = await fetch('./config.json') -if (!response.ok) throw new Error('Failed to load config.json') - -export const config = await response.json() diff --git a/web/core/menu.js b/web/core/menu.js deleted file mode 100644 index 0a2adad..0000000 --- a/web/core/menu.js +++ /dev/null @@ -1,37 +0,0 @@ -// Dynamic menu loader. -// Fetches data for groups declared with empty children in navigation.json -// and injects them into side-nav after it is ready. -// -// Imported non-blocking from index.html — does not delay routing. -// Replace MOCK_* with real api-client calls when the backend is ready. - -// ── Mock data ──────────────────────────────────────────────────────────────── - -const MOCK_ACCOUNTS = [ - { id: 'tresorerie', name: 'Trésorerie' }, - { id: 'charges', name: 'Charges' }, - { id: 'produits', name: 'Produits' }, - { id: 'fournisseurs', name: 'Fournisseurs' }, -] - -async function fetchAccounts() { - // TODO: replace with real call - // const api = document.querySelector('api-client') - // return await api.finance.list('/accounts') - await new Promise(r => setTimeout(r, 500)) // simulate network latency - return MOCK_ACCOUNTS -} - -// ── Injection ──────────────────────────────────────────────────────────────── - -const nav = document.querySelector('side-nav') -if (!nav) throw new Error('menu.js: side-nav not found in DOM') - -await nav.ready - -const accounts = await fetchAccounts() -nav.setGroupChildren('Comptes', accounts.map(a => ({ - label: a.name, - href: `#/account?id=${a.id}`, - icon: 'account', -}))) diff --git a/web/core/registry.js b/web/core/registry.js deleted file mode 100644 index 56671cc..0000000 --- a/web/core/registry.js +++ /dev/null @@ -1,10 +0,0 @@ -// Loads and registers all components listed in components.json. -// To add a component: git clone into components/, add the path here. -const response = await fetch('./components.json') -if (!response.ok) throw new Error('Failed to load components.json') - -const components = await response.json() - -for (const path of components) { - await import(`../${path}`) -} diff --git a/web/core/router.js b/web/core/router.js deleted file mode 100644 index d23777c..0000000 --- a/web/core/router.js +++ /dev/null @@ -1,55 +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() { - const hash = window.location.hash.replace(/^#\//, '') || PAGES[0]?.route || '' - return hash.split('?')[0] -} - -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/favicon.svg b/web/favicon.svg deleted file mode 100644 index 3b8d831..0000000 --- a/web/favicon.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/web/icons.json b/web/icons.json deleted file mode 100644 index a9777cc..0000000 --- a/web/icons.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "dashboard": "", - "vpc": "", - "subnet": "", - "vm": "", - "account": "" -} diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 1af8dea..0000000 --- a/web/index.html +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - two — dashboard - - - - - - - - -
-

two

- network orchestrator -
- -
- -
- - -
-
- - - - - diff --git a/web/pages/account/account.css b/web/pages/account/account.css deleted file mode 100644 index b8eb4e4..0000000 --- a/web/pages/account/account.css +++ /dev/null @@ -1,121 +0,0 @@ -#account-page { - width: 100%; - display: flex; - flex-direction: column; - gap: 24px; -} - -/* ── Header ────────────────────────────────────────────────────────────── */ - -#account-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - background: #1e1e2e; - border: 1px solid #313244; - border-radius: 10px; - padding: 20px 24px; -} - -#account-name { - font-size: 20px; - font-weight: 600; - color: #cdd6f4; - margin: 0 0 4px; -} - -#account-id { - font-size: 12px; - color: #6c7086; - font-family: monospace; -} - -#account-balance-block { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; -} - -.balance-label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: #6c7086; -} - -#account-balance { - font-size: 24px; - font-weight: 700; - color: #a6e3a1; - font-variant-numeric: tabular-nums; -} - -#account-balance.negative { color: #f38ba8; } - -/* ── Transactions ───────────────────────────────────────────────────────── */ - -#account-transactions { - background: #1e1e2e; - border: 1px solid #313244; - border-radius: 10px; - padding: 20px 24px; -} - -#account-transactions h3 { - font-size: 13px; - font-weight: 600; - color: #a6adc8; - text-transform: uppercase; - letter-spacing: 0.08em; - margin: 0 0 16px; -} - -#tx-table { - width: 100%; - border-collapse: collapse; - font-size: 14px; -} - -#tx-table th { - text-align: left; - color: #6c7086; - font-weight: 500; - font-size: 12px; - padding: 0 12px 10px; - border-bottom: 1px solid #313244; -} - -#tx-table td { - padding: 10px 12px; - color: #a6adc8; - border-bottom: 1px solid #1e1e2e; -} - -#tx-table tbody tr:last-child td { border-bottom: none; } -#tx-table tbody tr:hover td { background: #313244; } - -.amount-col { text-align: right; } - -td.amount { text-align: right; font-variant-numeric: tabular-nums; font-weight: 500; } -td.amount.positive { color: #a6e3a1; } -td.amount.negative { color: #f38ba8; } - -td.date { color: #6c7086; font-size: 13px; white-space: nowrap; } - -/* ── States ─────────────────────────────────────────────────────────────── */ - -#account-loading { - color: #6c7086; - font-size: 14px; -} - -#account-error { - background: #313244; - border: 1px solid #f38ba8; - border-radius: 8px; - padding: 12px 16px; - color: #f38ba8; - font-size: 13px; -} diff --git a/web/pages/account/account.js b/web/pages/account/account.js deleted file mode 100644 index bed9af0..0000000 --- a/web/pages/account/account.js +++ /dev/null @@ -1,117 +0,0 @@ -// Account page. -// Reads ?id= from the URL hash and renders account details. -// Replace fetchAccount() with a real api-client call when backend is ready. - -// ── Mock data ──────────────────────────────────────────────────────────────── - -const MOCK_DB = { - tresorerie: { - name: 'Trésorerie', - balance: 48_230.50, - currency: 'EUR', - transactions: [ - { date: '2026-06-03', label: 'Virement client Martin SA', amount: +12_000.00 }, - { date: '2026-06-01', label: 'Virement client Dupont SARL', amount: +5_000.00 }, - { date: '2026-05-30', label: 'Loyer bureau juin', amount: -2_500.00 }, - { date: '2026-05-28', label: 'Facture EDF', amount: -340.20 }, - { date: '2026-05-25', label: 'Abonnement SaaS infra', amount: -189.00 }, - ], - }, - charges: { - name: 'Charges', - balance: 12_890.00, - currency: 'EUR', - transactions: [ - { date: '2026-06-01', label: 'Salaires mai', amount: -8_500.00 }, - { date: '2026-05-28', label: 'Assurance professionnelle', amount: -420.00 }, - { date: '2026-05-20', label: 'Formation équipe', amount: -1_200.00 }, - ], - }, - produits: { - name: 'Produits', - balance: 67_450.00, - currency: 'EUR', - transactions: [ - { date: '2026-06-02', label: 'Facture client #2024-089', amount: +18_000.00 }, - { date: '2026-05-29', label: 'Facture client #2024-088', amount: +9_500.00 }, - { date: '2026-05-22', label: 'Avoir client Dupont', amount: -500.00 }, - ], - }, - fournisseurs: { - name: 'Fournisseurs', - balance: -8_340.00, - currency: 'EUR', - transactions: [ - { date: '2026-06-01', label: 'Facture Tech Components SAS', amount: -3_200.00 }, - { date: '2026-05-27', label: 'Règlement fournisseur Leroy', amount: +2_000.00 }, - { date: '2026-05-20', label: 'Facture Bureau & Co', amount: -640.00 }, - ], - }, -} - -async function fetchAccount(id) { - // TODO: replace with real call - // const api = document.querySelector('api-client') - // return await api.finance.get(`/accounts/${id}`) - await new Promise(r => setTimeout(r, 300)) - const data = MOCK_DB[id] - if (!data) throw new Error(`Compte introuvable : ${id}`) - return data -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function formatAmount(amount, currency) { - return new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(amount) -} - -function formatDate(iso) { - return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }).format(new Date(iso)) -} - -// ── Init ───────────────────────────────────────────────────────────────────── - -export function init(main) { - const params = new URLSearchParams(window.location.hash.split('?')[1]) - const id = params.get('id') - - const loading = main.querySelector('#account-loading') - const error = main.querySelector('#account-error') - - if (!id) { - loading.style.display = 'none' - error.style.display = 'block' - error.textContent = 'Aucun compte sélectionné.' - return - } - - fetchAccount(id) - .then(data => render(main, id, data)) - .catch(err => { - loading.style.display = 'none' - error.style.display = 'block' - error.textContent = err.message - }) -} - -function render(main, id, data) { - main.querySelector('#account-loading').style.display = 'none' - - main.querySelector('#account-name').textContent = data.name - main.querySelector('#account-id').textContent = id - - const balanceEl = main.querySelector('#account-balance') - balanceEl.textContent = formatAmount(data.balance, data.currency) - balanceEl.classList.toggle('negative', data.balance < 0) - - const tbody = main.querySelector('#tx-body') - tbody.innerHTML = data.transactions.map(tx => ` - - ${formatDate(tx.date)} - ${tx.label} - - ${formatAmount(tx.amount, data.currency)} - - - `).join('') -} diff --git a/web/pages/account/index.html b/web/pages/account/index.html deleted file mode 100644 index 9d40142..0000000 --- a/web/pages/account/index.html +++ /dev/null @@ -1,29 +0,0 @@ -
-
-
-

- -
-
- Solde - -
-
- -
-

Transactions récentes

- - - - - - - - - -
DateLibelléMontant
-
- - -
Chargement…
-
diff --git a/web/pages/account/manifest.json b/web/pages/account/manifest.json deleted file mode 100644 index 9226d9d..0000000 --- a/web/pages/account/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "route": "account", - "label": "Compte", - "icon": "account" -} 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 9df2f71..0000000 --- a/web/pages/dashboard/dashboard.js +++ /dev/null @@ -1,57 +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 - }) - }) - - main.querySelectorAll('[data-action="console"]').forEach(btn => { - btn.addEventListener('click', () => { - SidePanel.open({ - title: 'afficher une consol', - width: '700px', - 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 9e8195c..0000000 --- a/web/pages/dashboard/index.html +++ /dev/null @@ -1,29 +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" }