web: start: add vnc-viewer feature

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-06-05 16:35:50 +02:00
commit 14c5f41e2b
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
7 changed files with 261 additions and 8 deletions

1
.gitignore vendored
View file

@ -35,3 +35,4 @@ data/
web/components.json
web/navigation.json
web/pages.json
web/vendor/

View file

@ -68,9 +68,37 @@ parse_repo_url() {
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 "${server}/api/v1/repos/${owner}/${repo}/commits?sha=${ref}&limit=1" \
api_get "$(forge_commits_url "$server" "$owner" "$repo" "$ref")" \
| jq -r '.[0].sha // empty'
}
@ -79,11 +107,7 @@ 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
api="$(forge_contents_url "$server" "$owner" "$repo" "$ref" "$rpath")"
local listing
listing="$(api_get "$api")"
@ -105,8 +129,6 @@ download_path() {
done < <(echo "$listing" | jq -c 'if type=="array" then .[] else . end')
}
# ── Download WASM libs ───────────────────────────────────────────────────────────
# ── Read manifest ────────────────────────────────────────────────────────────────
PAGES_DIR="${WEB_DIR}/pages"
@ -115,6 +137,35 @@ 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=()

View file

@ -17,6 +17,7 @@ local_components:
- 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 <name>.js and manifest.json.
@ -26,6 +27,22 @@ local_components:
# ref: v1.2.0 # tag, branch or commit (default: main)
components: []
# 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:
- 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

View file

@ -0,0 +1,4 @@
{
"tag": "vnc-viewer",
"version": "0.1.0"
}

View file

@ -0,0 +1,164 @@
// VNC viewer component — wraps noVNC RFB.
//
// Usage (in a side-panel or page):
// <vnc-viewer vm="i-test1"></vnc-viewer>
//
// Connects to ws://<agent-host>:9000/vnc/<vm-name> 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 = `
<style>
:host {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 400px;
background: #000;
border-radius: 6px;
overflow: hidden;
}
#screen { flex: 1; overflow: hidden; }
#statusbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #1e1e2e;
border-top: 1px solid #313244;
flex-shrink: 0;
}
.dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #6c7086;
flex-shrink: 0;
}
.dot.connected { background: #a6e3a1; }
.dot.disconnected { background: #f38ba8; }
.dot.connecting { background: #f9e2af; }
#status-text {
font-size: 12px;
font-family: monospace;
color: #6c7086;
}
.spacer { flex: 1; }
button {
background: transparent;
border: 1px solid #45475a;
border-radius: 4px;
padding: 3px 8px;
color: #a6adc8;
font-size: 11px;
cursor: pointer;
}
button:hover { background: #313244; }
</style>
<div id="screen"></div>
<div id="statusbar">
<span class="dot connecting" id="dot"></span>
<span id="status-text">Connexion</span>
<span class="spacer"></span>
<button id="fullscreen-btn" title="Plein écran"></button>
<button id="reconnect-btn" title="Reconnecter"></button>
</div>
`
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)

View file

@ -38,6 +38,17 @@ export function init(main) {
})
})
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 => {

View file

@ -21,4 +21,9 @@
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
Edition
</button>
<button data-action="console"
style="background:#313244;border:1px solid #45475a;border-radius:6px;
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
Ouvrir la console
</button>
</div>