web: start: add vnc-viewer feature
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
b67e79973c
commit
14c5f41e2b
7 changed files with 261 additions and 8 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -35,3 +35,4 @@ data/
|
|||
web/components.json
|
||||
web/navigation.json
|
||||
web/pages.json
|
||||
web/vendor/
|
||||
|
|
|
|||
67
web/build.sh
67
web/build.sh
|
|
@ -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=()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
web/components/vnc-viewer/manifest.json
Normal file
4
web/components/vnc-viewer/manifest.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"tag": "vnc-viewer",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
164
web/components/vnc-viewer/vnc-viewer.js
Normal file
164
web/components/vnc-viewer/vnc-viewer.js
Normal 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)
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue