initial commit
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
commit
81baed85c7
16 changed files with 705 additions and 0 deletions
332
build.sh
Executable file
332
build.sh
Executable file
|
|
@ -0,0 +1,332 @@
|
||||||
|
#!/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/<name>/
|
||||||
|
# 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
|
||||||
2
components.lock.json
Normal file
2
components.lock.json
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[
|
||||||
|
]
|
||||||
53
components.yml
Normal file
53
components.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Component manifest — source of truth for the web UI.
|
||||||
|
#
|
||||||
|
# Edit this file, then run ./build.sh to:
|
||||||
|
# - download every remote component into components/<name>/
|
||||||
|
# - 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: []
|
||||||
|
|
||||||
|
# Remote components fetched from git at build time.
|
||||||
|
# Each is its own repo; its root must contain <name>.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/<name>).
|
||||||
|
vendors: []
|
||||||
|
|
||||||
|
# Pages — each page is a self-contained directory (JS + CSS + manifest.json).
|
||||||
|
# Same model as components: local pages ship in this repo, remote pages are
|
||||||
|
# downloaded from git at build time. build.sh reads each manifest.json to
|
||||||
|
# generate navigation.json and pages.json.
|
||||||
|
#
|
||||||
|
# Page manifest.json must contain: tag, route, label, icon.
|
||||||
|
# Page JS must: customElements.define('<tag>', ...) and use display:contents.
|
||||||
|
|
||||||
|
local_pages:
|
||||||
|
- dashboard
|
||||||
|
|
||||||
|
# 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
|
||||||
0
components/.keep
Normal file
0
components/.keep
Normal file
5
config.json
Normal file
5
config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"netbox_url": "http://netbox.local",
|
||||||
|
"netbox_token": "your-token-here",
|
||||||
|
"agent_url": "http://127.0.0.1:8080"
|
||||||
|
}
|
||||||
4
core/config.js
Normal file
4
core/config.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
const response = await fetch('./config.json')
|
||||||
|
if (!response.ok) throw new Error('Failed to load config.json')
|
||||||
|
|
||||||
|
export const config = await response.json()
|
||||||
37
core/menu.js
Normal file
37
core/menu.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// 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',
|
||||||
|
})))
|
||||||
10
core/registry.js
Normal file
10
core/registry.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// 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}`)
|
||||||
|
}
|
||||||
55
core/router.js
Normal file
55
core/router.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Hash-based SPA router.
|
||||||
|
// Reads pages.json (generated by build.sh from components.yml).
|
||||||
|
// Each page is an HTML fragment (index.html) + optional CSS + optional JS module.
|
||||||
|
//
|
||||||
|
// Load order enforced by index.html:
|
||||||
|
// await import('./core/registry.js') ← defines all components
|
||||||
|
// await import('./core/router.js') ← this file
|
||||||
|
|
||||||
|
const response = await fetch('./pages.json')
|
||||||
|
if (!response.ok) throw new Error('router: failed to load pages.json — run build.sh')
|
||||||
|
const PAGES = await response.json()
|
||||||
|
|
||||||
|
// Cache of imported JS modules — modules are only fetched once per session.
|
||||||
|
const MODULE_CACHE = new Map()
|
||||||
|
|
||||||
|
// Currently active page CSS <link> — removed on navigation.
|
||||||
|
let activeCSS = null
|
||||||
|
|
||||||
|
function currentRoute() {
|
||||||
|
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()))
|
||||||
24
favicon.svg
Normal file
24
favicon.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="32" height="32" rx="6" fill="#1e1e2e"/>
|
||||||
|
|
||||||
|
<!-- Server rack unit 1 -->
|
||||||
|
<rect x="5" y="7" width="22" height="5" rx="1.5" fill="#313244"/>
|
||||||
|
<circle cx="23" cy="9.5" r="1.2" fill="#a6e3a1"/>
|
||||||
|
<rect x="8" y="9" width="10" height="1" rx="0.5" fill="#585b70"/>
|
||||||
|
|
||||||
|
<!-- Server rack unit 2 -->
|
||||||
|
<rect x="5" y="14" width="22" height="5" rx="1.5" fill="#313244"/>
|
||||||
|
<circle cx="23" cy="16.5" r="1.2" fill="#89b4fa"/>
|
||||||
|
<rect x="8" y="16" width="10" height="1" rx="0.5" fill="#585b70"/>
|
||||||
|
|
||||||
|
<!-- Server rack unit 3 -->
|
||||||
|
<rect x="5" y="21" width="22" height="5" rx="1.5" fill="#313244"/>
|
||||||
|
<circle cx="23" cy="23.5" r="1.2" fill="#89b4fa"/>
|
||||||
|
<rect x="8" y="23" width="6" height="1" rx="0.5" fill="#585b70"/>
|
||||||
|
|
||||||
|
<!-- Rack frame left -->
|
||||||
|
<rect x="3" y="5" width="2" height="23" rx="1" fill="#45475a"/>
|
||||||
|
<!-- Rack frame right -->
|
||||||
|
<rect x="27" y="5" width="2" height="23" rx="1" fill="#45475a"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 996 B |
117
index.html
Normal file
117
index.html
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>two — dashboard</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #181825;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
border-bottom: 1px solid #313244;
|
||||||
|
background: #1e1e2e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #89b4fa;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c7086;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 28px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: side-nav gère sa propre géométrie via inline styles (JS) */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
main { padding: 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#error {
|
||||||
|
display: none;
|
||||||
|
background: #313244;
|
||||||
|
border: 1px solid #f38ba8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
color: #f38ba8;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<login-gate></login-gate>
|
||||||
|
<api-client></api-client>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>two</h1>
|
||||||
|
<span>network orchestrator</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<logout-button></logout-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<side-nav></side-nav>
|
||||||
|
|
||||||
|
<main></main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
window.addEventListener('unhandledrejection', e => {
|
||||||
|
const el = document.getElementById('error')
|
||||||
|
if (el) {
|
||||||
|
el.style.display = 'block'
|
||||||
|
el.textContent = `Error: ${e.reason?.message ?? e.reason}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sequential: registry defines all custom elements,
|
||||||
|
// then router renders the current route.
|
||||||
|
await import('./core/registry.js')
|
||||||
|
await import('./core/router.js')
|
||||||
|
|
||||||
|
// Non-blocking: populates dynamic menu groups after routing.
|
||||||
|
import('./core/menu.js')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2
pages/dashboard/dashboard.css
Normal file
2
pages/dashboard/dashboard.css
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/* Styles scoped to the dashboard page.
|
||||||
|
Applied when the route is active, removed on navigation. */
|
||||||
57
pages/dashboard/dashboard.js
Normal file
57
pages/dashboard/dashboard.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { SidePanel } from '../../components/side-panel/side-panel.js'
|
||||||
|
|
||||||
|
export function init(main) {
|
||||||
|
main.querySelectorAll('[data-panel-title]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
SidePanel.open({
|
||||||
|
title: btn.dataset.panelTitle,
|
||||||
|
width: btn.dataset.panelWidth ?? '480px',
|
||||||
|
content: `<p style="color:#a6adc8;font-size:14px;line-height:1.6">
|
||||||
|
Contenu du panneau <strong style="color:#cdd6f4">${btn.dataset.panelTitle}</strong>.<br>
|
||||||
|
Largeur : ${btn.dataset.panelWidth}.<br><br>
|
||||||
|
Tu peux ouvrir plusieurs panneaux — ils s'empilent vers la gauche.
|
||||||
|
Ferme avec ✕, Échap, ou en cliquant le fond.
|
||||||
|
</p>`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
main.querySelectorAll('[data-action="create-vm"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
SidePanel.open({
|
||||||
|
title: 'Nouvelle VM',
|
||||||
|
width: '520px',
|
||||||
|
content: `<form-vm></form-vm>`,
|
||||||
|
})
|
||||||
|
// form-vm gère son propre submit et dispatche vm-saved — pas besoin de querySelector
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
main.querySelectorAll('[data-action="edit-vm-i-test1"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
SidePanel.open({
|
||||||
|
title: 'Modifier i-test1',
|
||||||
|
width: '520px',
|
||||||
|
content: `<form-vm vm-name="i-test1"></form-vm>`
|
||||||
|
})
|
||||||
|
// form-vm gère son propre submit et dispatche vm-saved — pas besoin de querySelector
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
main.querySelectorAll('[data-action="console"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
SidePanel.open({
|
||||||
|
title: 'afficher une consol',
|
||||||
|
width: '700px',
|
||||||
|
content: `<vnc-viewer vm="i-test1"></vnc-viewer>`
|
||||||
|
})
|
||||||
|
// form-vm gère son propre submit et dispatche vm-saved — pas besoin de querySelector
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// vm-saved est attaché sur main (pas document) → nettoyé automatiquement
|
||||||
|
// quand le router remplace le contenu de <main>
|
||||||
|
main.addEventListener('vm-saved', e => {
|
||||||
|
console.log('vm-saved', e.detail)
|
||||||
|
})
|
||||||
|
}
|
||||||
1
pages/dashboard/index.html
Normal file
1
pages/dashboard/index.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Cecie est la premiere page
|
||||||
6
pages/dashboard/manifest.json
Normal file
6
pages/dashboard/manifest.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"route": "dashboard",
|
||||||
|
"label": "Dashboard",
|
||||||
|
"icon": "dashboard",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
0
vendor/.keep
vendored
Normal file
0
vendor/.keep
vendored
Normal file
Loading…
Add table
Add a link
Reference in a new issue