web: start: add vnc-viewer feature
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
c805169747
commit
1d0e046ff4
7 changed files with 261 additions and 8 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -35,3 +35,4 @@ data/
|
||||||
web/components.json
|
web/components.json
|
||||||
web/navigation.json
|
web/navigation.json
|
||||||
web/pages.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"
|
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() {
|
resolve_commit() {
|
||||||
local server="$1" owner="$2" repo="$3" ref="$4"
|
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'
|
| 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 server="$1" owner="$2" repo="$3" ref="$4" rpath="$5" dest="$6"
|
||||||
|
|
||||||
local api
|
local api
|
||||||
if [[ -z "$rpath" ]]; then
|
api="$(forge_contents_url "$server" "$owner" "$repo" "$ref" "$rpath")"
|
||||||
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
|
local listing
|
||||||
listing="$(api_get "$api")"
|
listing="$(api_get "$api")"
|
||||||
|
|
@ -105,8 +129,6 @@ download_path() {
|
||||||
done < <(echo "$listing" | jq -c 'if type=="array" then .[] else . end')
|
done < <(echo "$listing" | jq -c 'if type=="array" then .[] else . end')
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Download WASM libs ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# ── Read manifest ────────────────────────────────────────────────────────────────
|
# ── Read manifest ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
PAGES_DIR="${WEB_DIR}/pages"
|
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 REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST")
|
||||||
mapfile -t LOCAL_PAGES < <(yq '(.local_pages // [])[]' "$MANIFEST")
|
mapfile -t LOCAL_PAGES < <(yq '(.local_pages // [])[]' "$MANIFEST")
|
||||||
mapfile -t REMOTE_PAGES < <(yq '(.remote_pages // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$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
|
# Ordered list of load paths for components.json
|
||||||
declare -a LOAD_PATHS=()
|
declare -a LOAD_PATHS=()
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ local_components:
|
||||||
- side-panel
|
- side-panel
|
||||||
- date-display
|
- date-display
|
||||||
- form-vm
|
- form-vm
|
||||||
|
- vnc-viewer
|
||||||
|
|
||||||
# Remote components fetched from git at build time.
|
# Remote components fetched from git at build time.
|
||||||
# Each is its own repo; its root must contain <name>.js and manifest.json.
|
# 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)
|
# ref: v1.2.0 # tag, branch or commit (default: main)
|
||||||
components: []
|
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).
|
# 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
|
# 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
|
# 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
|
// vm-saved est attaché sur main (pas document) → nettoyé automatiquement
|
||||||
// quand le router remplace le contenu de <main>
|
// quand le router remplace le contenu de <main>
|
||||||
main.addEventListener('vm-saved', e => {
|
main.addEventListener('vm-saved', e => {
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,9 @@
|
||||||
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
|
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
|
||||||
Edition
|
Edition
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue