diff --git a/web/build.sh b/web/build.sh deleted file mode 100755 index 6a33d4f..0000000 --- a/web/build.sh +++ /dev/null @@ -1,177 +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" -} - -resolve_commit() { - local server="$1" owner="$2" repo="$3" ref="$4" - api_get "${server}/api/v1/repos/${owner}/${repo}/commits?sha=${ref}&limit=1" \ - | 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 - if [[ -z "$rpath" ]]; then - api="${server}/api/v1/repos/${owner}/${repo}/contents?ref=${ref}" - else - api="${server}/api/v1/repos/${owner}/${repo}/contents/${rpath}?ref=${ref}" - fi - - 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') -} - -# ── Download WASM libs ─────────────────────────────────────────────────────────── - -# ── Read manifest ──────────────────────────────────────────────────────────────── - -mapfile -t LOCALS < <(yq '(.local // [])[]' "$MANIFEST") -mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") - -# Ordered list of load paths for components.json -declare -a LOAD_PATHS=() - -# ── 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)" diff --git a/web/components.json b/web/components.json index 4d6c2ee..6dae454 100644 --- a/web/components.json +++ b/web/components.json @@ -1,7 +1,5 @@ [ - "components/login-gate/login-gate.js", "components/api-client/api-client.js", "components/side-nav/side-nav.js", - "components/logout-button/logout-button.js", "components/date-display/date-display.js" ] diff --git a/web/components.lock.json b/web/components.lock.json deleted file mode 100644 index fe51488..0000000 --- a/web/components.lock.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/web/components.yml b/web/components.yml deleted file mode 100644 index 646d294..0000000 --- a/web/components.yml +++ /dev/null @@ -1,25 +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: - - login-gate - - api-client - - side-nav - - logout-button - - date-display - -# 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: [] diff --git a/web/components/api-client/api-client.js b/web/components/api-client/api-client.js index 1d4c104..d9eab75 100644 --- a/web/components/api-client/api-client.js +++ b/web/components/api-client/api-client.js @@ -1,7 +1,6 @@ // API service component. // -// Declare after login-gate in the shell: -// +// Declare once in the shell: // // // Use from any other component: @@ -12,8 +11,10 @@ // await api.agent.delete('/vpcs/vp-admin') // await api.agent.waitFor('/vms/i-test1', 'started') // -// Credentials come from .ready — no direct config.json dependency. -// Phase 2: swap login-gate for oidc-gate → this component unchanged. +// Phase 2: swap this component for one that points to the orchestrator. +// No other component changes. + +import { config } from '../../core/config.js' export class ApiError extends Error { constructor(origin, status, message) { @@ -24,25 +25,12 @@ export class ApiError extends Error { } class ApiClient extends HTMLElement { - // Resolves once login-gate is ready (or immediately if no gate in DOM). - #credentials = null - - async connectedCallback() { + 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 = {}) { @@ -71,12 +59,12 @@ class ApiClient extends HTMLElement { #buildNetbox() { const headers = () => ({ - 'Authorization': `Token ${this.creds.netbox_token}`, + 'Authorization': `Token ${config.netbox_token}`, 'Accept': 'application/json', }) const url = (path, params = {}) => { - const u = new URL(path, this.creds.netbox_url + '/api/') + const u = new URL(path, config.netbox_url + '/api/') Object.entries(params).forEach(([k, v]) => { if (v != null) u.searchParams.set(k, v) }) @@ -84,6 +72,7 @@ class ApiClient extends HTMLElement { } return { + // Returns results array. Netbox paginates; limit=1000 covers most cases. list: (path, params = {}) => this.#request('netbox', url(path, { limit: 1000, ...params }), { headers: headers() }) .then(d => d?.results ?? []), @@ -101,7 +90,7 @@ class ApiClient extends HTMLElement { ...(body ? { 'Content-Type': 'application/json' } : {}), }) - const url = path => new URL(path, this.creds.agent_url + '/').toString() + const url = path => new URL(path, config.agent_url + '/').toString() const req = (method, path, body) => this.#request('agent', url(path), { @@ -111,11 +100,12 @@ class ApiClient extends HTMLElement { }) return { - get: path => req('GET', path), - list: path => req('GET', path), - post: (path, b) => req('POST', path, b), - delete: path => req('DELETE', path), + get: path => req('GET', path), + list: path => req('GET', path), + post: (path, b) => req('POST', path, b), + delete: path => req('DELETE', path), + // Poll path until resource.state === desired or timeout. waitFor: async (path, desired, { timeout = 120_000, interval = 2_000 } = {}) => { const deadline = Date.now() + timeout while (Date.now() < deadline) { diff --git a/web/components/login-gate/login-gate.js b/web/components/login-gate/login-gate.js deleted file mode 100644 index 247fa06..0000000 --- a/web/components/login-gate/login-gate.js +++ /dev/null @@ -1,211 +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' - -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 - } - - // Load config.json defaults to pre-fill the form - 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.js b/web/components/logout-button/logout-button.js deleted file mode 100644 index 45aa80f..0000000 --- a/web/components/logout-button/logout-button.js +++ /dev/null @@ -1,75 +0,0 @@ -// Logout button — drop anywhere in the DOM. -// -// full button with label -// icon only (for tight spaces) -// -// Delegates to .logout(). Works regardless of where it sits -// (header, members panel, dropdown…) since it resolves the gate from the document. -// -// Phase 2: if login-gate is swapped for oidc-gate, this still works as long as -// the auth component exposes a logout() method (see #gate()). - -const ICON = `` - -class LogoutButton extends HTMLElement { - connectedCallback() { - this.attachShadow({ mode: 'open' }) - - const compact = this.hasAttribute('compact') - - this.shadowRoot.innerHTML = ` - - - - ` - - this.shadowRoot.querySelector('button') - .addEventListener('click', () => this.#logout()) - } - - // Resolves the auth component. Today: login-gate. Tomorrow: any element - // exposing logout() (oidc-gate, oauth-gate…). - #gate() { - return document.querySelector('login-gate, [data-auth-gate]') - } - - #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/index.html b/web/index.html index 4c75ef4..a9301aa 100644 --- a/web/index.html +++ b/web/index.html @@ -39,10 +39,6 @@ color: #6c7086; } - header .spacer { - flex: 1; - } - .layout { display: flex; flex: 1; @@ -74,23 +70,19 @@ - - -

two

network orchestrator -
-
+ +
-