diff --git a/Cargo.lock b/Cargo.lock index 1f3f4d9..a18a81f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -267,6 +267,7 @@ dependencies = [ "futures-util", "reqwest", "serde", + "serde_json", "tokio", "toml", "tower-http 0.5.2", diff --git a/crates/ericrfb-frontend/src/console.ts b/crates/ericrfb-frontend/src/console.ts deleted file mode 100644 index 1289710..0000000 --- a/crates/ericrfb-frontend/src/console.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { codeToScancode } from './input' -import { - TAG_BLIT, TAG_RESIZE, - makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel, -} from './protocol' -import { showLogin } from './login' - -export function startConsole( - app: HTMLElement, - appletId: string, - port: number, - boardName: string, -) { - app.innerHTML = ` -
- ${boardName} - - - - connecting... -
-
- -
- ` - - const canvas = document.getElementById('canvas') as HTMLCanvasElement - const ctx = canvas.getContext('2d')! - const statusEl = document.getElementById('status')! - - function setStatus(text: string, connected: boolean) { - statusEl.textContent = text - statusEl.classList.toggle('connected', connected) - } - - // WebSocket with reconnect - let ws: WebSocket | null = null - let reconnectTimer: number | undefined - let reconnectDelay = 1000 - - function connect() { - if (reconnectTimer) clearTimeout(reconnectTimer) - setStatus('connecting...', false) - - const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:' - const wsUrl = `${wsProto}//${location.host}/api/ws?applet_id=${encodeURIComponent(appletId)}&port=${port}` - ws = new WebSocket(wsUrl) - ws.binaryType = 'arraybuffer' - - ws.onopen = () => { - setStatus('connected', true) - reconnectDelay = 1000 - canvas.focus() - } - - ws.onclose = () => { - setStatus(`disconnected — reconnecting in ${reconnectDelay / 1000}s...`, false) - scheduleReconnect() - } - - ws.onerror = () => { - setStatus('connection error', false) - } - - ws.onmessage = handleMessage - } - - function scheduleReconnect() { - reconnectTimer = window.setTimeout(() => { - // Re-login to get a fresh APPLET_ID, then reconnect - relogin() - }, reconnectDelay) - reconnectDelay = Math.min(reconnectDelay * 2, 30000) - } - - async function relogin() { - setStatus('re-authenticating...', false) - try { - const resp = await fetch('/api/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: sessionStorage.getItem('blekin_user') || 'administrator', - password: sessionStorage.getItem('blekin_pass') || '', - }), - }) - if (!resp.ok) throw new Error('re-auth failed') - const data = await resp.json() - appletId = data.applet_id - port = data.port - connect() - } catch { - setStatus(`re-auth failed — retry in ${reconnectDelay / 1000}s...`, false) - scheduleReconnect() - } - } - - function handleMessage(ev: MessageEvent) { - if (!(ev.data instanceof ArrayBuffer)) return - const view = new DataView(ev.data) - const tag = view.getUint8(0) - - switch (tag) { - case TAG_RESIZE: { - const w = view.getUint16(1) - const h = view.getUint16(3) - canvas.width = w - canvas.height = h - break - } - case TAG_BLIT: { - const x = view.getUint16(1) - const y = view.getUint16(3) - const w = view.getUint16(5) - const h = view.getUint16(7) - const rgba = new Uint8ClampedArray(ev.data, 9) - const img = new ImageData(rgba, w, h) - ctx.putImageData(img, x, y) - break - } - } - } - - // Keyboard input - let buttonMask = 0 - - canvas.addEventListener('keydown', (e) => { - e.preventDefault() - const sc = codeToScancode(e.code) - if (sc !== undefined && ws?.readyState === WebSocket.OPEN) { - ws.send(makeKeyPress(sc)) - } - }) - - canvas.addEventListener('keyup', (e) => { - e.preventDefault() - const sc = codeToScancode(e.code) - if (sc !== undefined && ws?.readyState === WebSocket.OPEN) { - ws.send(makeKeyRelease(sc)) - } - }) - - // Mouse input - function sendPointer(e: MouseEvent) { - if (ws?.readyState !== WebSocket.OPEN) return - const rect = canvas.getBoundingClientRect() - const scaleX = canvas.width / rect.width - const scaleY = canvas.height / rect.height - const x = Math.round((e.clientX - rect.left) * scaleX) - const y = Math.round((e.clientY - rect.top) * scaleY) - ws.send(makePointer( - Math.max(0, Math.min(x, canvas.width - 1)), - Math.max(0, Math.min(y, canvas.height - 1)), - buttonMask, - )) - } - - canvas.addEventListener('mousemove', sendPointer) - - canvas.addEventListener('mousedown', (e) => { - e.preventDefault() - canvas.focus() - if (e.button === 0) buttonMask |= 1 - else if (e.button === 1) buttonMask |= 2 - else if (e.button === 2) buttonMask |= 4 - sendPointer(e) - }) - - canvas.addEventListener('mouseup', (e) => { - e.preventDefault() - if (e.button === 0) buttonMask &= ~1 - else if (e.button === 1) buttonMask &= ~2 - else if (e.button === 2) buttonMask &= ~4 - sendPointer(e) - }) - - canvas.addEventListener('contextmenu', (e) => e.preventDefault()) - - canvas.addEventListener('wheel', (e) => { - e.preventDefault() - if (ws?.readyState !== WebSocket.OPEN) return - const rect = canvas.getBoundingClientRect() - const scaleX = canvas.width / rect.width - const scaleY = canvas.height / rect.height - const x = Math.round((e.clientX - rect.left) * scaleX) - const y = Math.round((e.clientY - rect.top) * scaleY) - const scrollMask = e.deltaY < 0 ? 8 : 16 - ws.send(makePointer(x, y, buttonMask | scrollMask)) - ws.send(makePointer(x, y, buttonMask)) - }) - - // Toolbar - document.getElementById('btn-cad')!.addEventListener('click', () => { - if (ws?.readyState === WebSocket.OPEN) { - ws.send(makeCtrlAltDel()) - } - canvas.focus() - }) - - document.getElementById('btn-fs')!.addEventListener('click', () => { - if (document.fullscreenElement) { - document.exitFullscreen() - } else { - app.requestFullscreen() - } - canvas.focus() - }) - - document.getElementById('btn-disconnect')!.addEventListener('click', () => { - if (reconnectTimer) clearTimeout(reconnectTimer) - ws?.close() - ws = null - showLogin(app) - }) - - // Start connection - connect() -} diff --git a/crates/ericrfb-frontend/src/login.ts b/crates/ericrfb-frontend/src/login.ts index f1ac97b..bba2241 100644 --- a/crates/ericrfb-frontend/src/login.ts +++ b/crates/ericrfb-frontend/src/login.ts @@ -1,4 +1,4 @@ -import { startConsole } from './console' +import { mountShell } from './shell' interface LoginResponse { applet_id: string @@ -46,10 +46,13 @@ export function showLogin(app: HTMLElement) { } const data: LoginResponse = await resp.json() - // Store credentials for automatic reconnect sessionStorage.setItem('blekin_user', username) sessionStorage.setItem('blekin_pass', password) - startConsole(app, data.applet_id, data.port, data.board_name) + mountShell(app, { + appletId: data.applet_id, + port: data.port, + boardName: data.board_name, + }) } catch (err) { errorDiv.textContent = (err as Error).message errorDiv.hidden = false diff --git a/crates/ericrfb-frontend/src/pages/console.ts b/crates/ericrfb-frontend/src/pages/console.ts new file mode 100644 index 0000000..66032b0 --- /dev/null +++ b/crates/ericrfb-frontend/src/pages/console.ts @@ -0,0 +1,242 @@ +import { codeToScancode } from '../input' +import { + TAG_BLIT, TAG_RESIZE, + makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel, +} from '../protocol' +import type { SessionInfo } from '../shell' + +let ws: WebSocket | null = null +let reconnectTimer: number | undefined +let reconnectDelay = 1000 +let containerEl: HTMLElement | null = null +let session: SessionInfo +let buttonMask = 0 + +export function mountConsole(el: HTMLElement, s: SessionInfo) { + containerEl = el + session = s + + el.innerHTML = ` +
+ ${s.boardName} + + + + connecting... +
+
+ +
+ ` + + loadPortList() + connect() + wireInputHandlers() + wireToolbar() +} + +export function unmountConsole() { + if (reconnectTimer) clearTimeout(reconnectTimer) + reconnectTimer = undefined + ws?.close() + ws = null + buttonMask = 0 + if (containerEl) containerEl.innerHTML = '' + containerEl = null +} + +// --------------------------------------------------------------------------- +// WebSocket +// --------------------------------------------------------------------------- + +function setStatus(text: string, connected: boolean) { + const el = document.getElementById('status') + if (el) { + el.textContent = text + el.classList.toggle('connected', connected) + } +} + +function connect() { + if (reconnectTimer) clearTimeout(reconnectTimer) + setStatus('connecting...', false) + + const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:' + const wsUrl = `${wsProto}//${location.host}/api/ws?applet_id=${encodeURIComponent(session.appletId)}&port=${session.port}` + ws = new WebSocket(wsUrl) + ws.binaryType = 'arraybuffer' + + ws.onopen = () => { + setStatus('connected', true) + reconnectDelay = 1000 + document.getElementById('canvas')?.focus() + } + + ws.onclose = () => { + if (!containerEl) return // unmounted + setStatus(`disconnected — reconnecting in ${reconnectDelay / 1000}s...`, false) + scheduleReconnect() + } + + ws.onerror = () => setStatus('connection error', false) + ws.onmessage = handleMessage +} + +function scheduleReconnect() { + reconnectTimer = window.setTimeout(relogin, reconnectDelay) + reconnectDelay = Math.min(reconnectDelay * 2, 30000) +} + +async function relogin() { + if (!containerEl) return + setStatus('re-authenticating...', false) + try { + const resp = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: sessionStorage.getItem('blekin_user') || 'administrator', + password: sessionStorage.getItem('blekin_pass') || '', + }), + }) + if (!resp.ok) throw new Error('re-auth failed') + const data = await resp.json() + session = { ...session, appletId: data.applet_id, port: data.port } + connect() + } catch { + setStatus(`re-auth failed — retry in ${reconnectDelay / 1000}s...`, false) + scheduleReconnect() + } +} + +function handleMessage(ev: MessageEvent) { + if (!(ev.data instanceof ArrayBuffer)) return + const view = new DataView(ev.data) + const tag = view.getUint8(0) + const canvas = document.getElementById('canvas') as HTMLCanvasElement | null + if (!canvas) return + const ctx = canvas.getContext('2d')! + + switch (tag) { + case TAG_RESIZE: { + canvas.width = view.getUint16(1) + canvas.height = view.getUint16(3) + break + } + case TAG_BLIT: { + const x = view.getUint16(1) + const y = view.getUint16(3) + const w = view.getUint16(5) + const h = view.getUint16(7) + const rgba = new Uint8ClampedArray(ev.data, 9) + ctx.putImageData(new ImageData(rgba, w, h), x, y) + break + } + } +} + +// --------------------------------------------------------------------------- +// Port switcher +// --------------------------------------------------------------------------- + +async function loadPortList() { + try { + const resp = await fetch('/api/kvm/ports') + if (!resp.ok) return + const data = await resp.json() + const select = document.getElementById('port-select') as HTMLSelectElement + if (!select) return + select.innerHTML = '' + for (const p of data.ports) { + const opt = document.createElement('option') + opt.value = String(p.index) + opt.textContent = p.name || `Port ${p.index + 1}` + opt.selected = p.index === data.active_port + select.appendChild(opt) + } + select.addEventListener('change', async () => { + const port = parseInt(select.value) + await fetch('/api/kvm/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ port }), + }) + // Reconnect to pick up new video stream + ws?.close() + }) + } catch { /* port list optional */ } +} + +// --------------------------------------------------------------------------- +// Input handlers +// --------------------------------------------------------------------------- + +function wireInputHandlers() { + const canvas = document.getElementById('canvas')! as HTMLCanvasElement + + canvas.addEventListener('keydown', (e) => { + e.preventDefault() + const sc = codeToScancode(e.code) + if (sc !== undefined && ws?.readyState === WebSocket.OPEN) ws.send(makeKeyPress(sc)) + }) + + canvas.addEventListener('keyup', (e) => { + e.preventDefault() + const sc = codeToScancode(e.code) + if (sc !== undefined && ws?.readyState === WebSocket.OPEN) ws.send(makeKeyRelease(sc)) + }) + + function sendPointer(e: MouseEvent) { + if (ws?.readyState !== WebSocket.OPEN) return + const rect = canvas.getBoundingClientRect() + const x = Math.round((e.clientX - rect.left) * canvas.width / rect.width) + const y = Math.round((e.clientY - rect.top) * canvas.height / rect.height) + ws.send(makePointer( + Math.max(0, Math.min(x, canvas.width - 1)), + Math.max(0, Math.min(y, canvas.height - 1)), + buttonMask, + )) + } + + canvas.addEventListener('mousemove', sendPointer) + canvas.addEventListener('mousedown', (e) => { + e.preventDefault() + canvas.focus() + if (e.button === 0) buttonMask |= 1 + else if (e.button === 1) buttonMask |= 2 + else if (e.button === 2) buttonMask |= 4 + sendPointer(e) + }) + canvas.addEventListener('mouseup', (e) => { + e.preventDefault() + if (e.button === 0) buttonMask &= ~1 + else if (e.button === 1) buttonMask &= ~2 + else if (e.button === 2) buttonMask &= ~4 + sendPointer(e) + }) + canvas.addEventListener('contextmenu', (e) => e.preventDefault()) + canvas.addEventListener('wheel', (e) => { + e.preventDefault() + if (ws?.readyState !== WebSocket.OPEN) return + const rect = canvas.getBoundingClientRect() + const x = Math.round((e.clientX - rect.left) * canvas.width / rect.width) + const y = Math.round((e.clientY - rect.top) * canvas.height / rect.height) + const scrollMask = e.deltaY < 0 ? 8 : 16 + ws.send(makePointer(x, y, buttonMask | scrollMask)) + ws.send(makePointer(x, y, buttonMask)) + }) +} + +function wireToolbar() { + document.getElementById('btn-cad')?.addEventListener('click', () => { + if (ws?.readyState === WebSocket.OPEN) ws.send(makeCtrlAltDel()) + document.getElementById('canvas')?.focus() + }) + + document.getElementById('btn-fs')?.addEventListener('click', () => { + const shell = document.querySelector('.shell') + if (document.fullscreenElement) document.exitFullscreen() + else shell?.requestFullscreen() + document.getElementById('canvas')?.focus() + }) +} diff --git a/crates/ericrfb-frontend/src/pages/ports.ts b/crates/ericrfb-frontend/src/pages/ports.ts new file mode 100644 index 0000000..d58f6b5 --- /dev/null +++ b/crates/ericrfb-frontend/src/pages/ports.ts @@ -0,0 +1,165 @@ +import type { SessionInfo } from '../shell' + +let containerEl: HTMLElement | null = null + +interface PortInfo { + index: number + name: string + hotkey: string + show_in_rc: boolean +} + +interface PortsData { + port_count: number + key_pause_duration: number + active_port: number + ports: PortInfo[] +} + +export function mountPorts(el: HTMLElement, _session: SessionInfo) { + containerEl = el + el.innerHTML = ` +
+
+

KVM Port Configuration

+
+ + +
+
+ +
+ + +
+ + + + + + + + + + + +
#NameHotkeyShow
+
+ ` + + document.getElementById('btn-reload')?.addEventListener('click', loadPorts) + document.getElementById('btn-save')?.addEventListener('click', savePorts) + loadPorts() +} + +export function unmountPorts() { + if (containerEl) containerEl.innerHTML = '' + containerEl = null +} + +function showToast(msg: string, success: boolean) { + const el = document.getElementById('toast') + if (!el) return + el.textContent = msg + el.className = `ports-toast ${success ? 'toast-ok' : 'toast-err'}` + el.hidden = false + setTimeout(() => { el.hidden = true }, 3000) +} + +async function loadPorts() { + try { + const resp = await fetch('/api/kvm/ports') + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const data: PortsData = await resp.json() + renderPorts(data) + } catch (e) { + showToast(`Failed to load: ${e}`, false) + } +} + +function renderPorts(data: PortsData) { + const countEl = document.getElementById('port-count') as HTMLSelectElement + const pauseEl = document.getElementById('key-pause') as HTMLInputElement + const tbody = document.getElementById('ports-body')! + + if (countEl) countEl.value = String(data.port_count) + if (pauseEl) pauseEl.value = String(data.key_pause_duration) + + tbody.innerHTML = '' + for (const p of data.ports) { + const tr = document.createElement('tr') + tr.className = p.index === data.active_port ? 'active-port' : '' + tr.innerHTML = ` + ${p.index + 1} + + + + + ` + tbody.appendChild(tr) + } + + // Wire switch buttons + tbody.querySelectorAll('.btn-switch').forEach(btn => { + btn.addEventListener('click', async () => { + const idx = parseInt((btn as HTMLElement).dataset.idx!) + try { + await fetch('/api/kvm/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ port: idx }), + }) + showToast(`Switched to port ${idx + 1}`, true) + loadPorts() // refresh to show new active + } catch (e) { + showToast(`Switch failed: ${e}`, false) + } + }) + }) +} + +async function savePorts() { + const countEl = document.getElementById('port-count') as HTMLSelectElement + const pauseEl = document.getElementById('key-pause') as HTMLInputElement + const portCount = parseInt(countEl?.value || '16') + const keyPause = parseInt(pauseEl?.value || '100') + + const ports: PortInfo[] = [] + for (let i = 0; i < portCount; i++) { + const nameEl = document.querySelector(`.port-name[data-idx="${i}"]`) as HTMLInputElement + const hotkeyEl = document.querySelector(`.port-hotkey[data-idx="${i}"]`) as HTMLInputElement + const showEl = document.querySelector(`.port-show[data-idx="${i}"]`) as HTMLInputElement + ports.push({ + index: i, + name: nameEl?.value || '', + hotkey: hotkeyEl?.value || '', + show_in_rc: showEl?.checked || false, + }) + } + + try { + const resp = await fetch('/api/kvm/ports', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ port_count: portCount, key_pause_duration: keyPause, ports }), + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + showToast('Configuration saved', true) + loadPorts() + } catch (e) { + showToast(`Save failed: ${e}`, false) + } +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(/ void; unmount: () => void }> = { + console: { mount: mountConsole, unmount: unmountConsole }, + ports: { mount: mountPorts, unmount: unmountPorts }, +} + +function navigate(page: PageId) { + if (currentPage === page) return + if (currentPage) pages[currentPage].unmount() + + // Update nav links + document.querySelectorAll('.nav-link').forEach(el => { + el.classList.toggle('active', el.getAttribute('data-page') === page) + }) + + currentPage = page + pages[page].mount(contentEl, session) +} + +export function mountShell(app: HTMLElement, s: SessionInfo) { + session = s + app.innerHTML = ` +
+ +
+
+ ` + + contentEl = document.getElementById('page-content')! + + // Wire up navigation + document.querySelectorAll('.nav-link').forEach(el => { + el.addEventListener('click', (e) => { + e.preventDefault() + const page = el.getAttribute('data-page') as PageId + if (page) navigate(page) + }) + }) + + // Default to console + navigate('console') +} diff --git a/crates/ericrfb-frontend/src/style.css b/crates/ericrfb-frontend/src/style.css index bb060ff..a59984c 100644 --- a/crates/ericrfb-frontend/src/style.css +++ b/crates/ericrfb-frontend/src/style.css @@ -32,10 +32,7 @@ body { min-width: 320px; } -.login-form h1 { - font-size: 1.25rem; - text-align: center; -} +.login-form h1 { font-size: 1.25rem; text-align: center; } .login-form input { padding: 0.5rem; @@ -58,26 +55,65 @@ body { .login-form button:hover { background: #5a9c69; } .login-form button:disabled { opacity: 0.5; cursor: not-allowed; } +.login-error { color: #e74c3c; font-size: 0.85rem; text-align: center; } -.login-error { - color: #e74c3c; - font-size: 0.85rem; - text-align: center; +/* Shell layout */ +.shell { + display: flex; + height: 100vh; } -/* Toolbar */ +.sidebar { + width: 160px; + min-width: 160px; + background: #222; + border-right: 1px solid #333; + display: flex; + flex-direction: column; + padding: 0.5rem 0; +} + +.sidebar-brand { + padding: 0.75rem 1rem; + font-size: 1.1rem; + font-weight: 600; + color: #4a7c59; + border-bottom: 1px solid #333; + margin-bottom: 0.25rem; +} + +.nav-link { + display: block; + padding: 0.5rem 1rem; + color: #999; + text-decoration: none; + font-size: 0.85rem; + border-left: 3px solid transparent; +} + +.nav-link:hover { color: #ccc; background: #2a2a2a; } +.nav-link.active { color: #e0e0e0; border-left-color: #4a7c59; background: #2a2a2a; } + +.content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Console toolbar */ .toolbar { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0.5rem; - background: #222; + background: #1e1e1e; border-bottom: 1px solid #333; font-size: 0.8rem; } -.toolbar button { - padding: 0.25rem 0.75rem; +.toolbar button, .toolbar select { + padding: 0.25rem 0.5rem; border: 1px solid #444; border-radius: 3px; background: #333; @@ -86,16 +122,11 @@ body { font-size: 0.8rem; } -.toolbar button:hover { background: #444; } - -.toolbar .status { - margin-left: auto; - color: #888; -} - +.toolbar button:hover, .toolbar select:hover { background: #444; } +.toolbar .status { margin-left: auto; color: #888; } .toolbar .status.connected { color: #4a7c59; } -/* Console */ +/* Console canvas */ .console-wrap { flex: 1; display: flex; @@ -109,3 +140,107 @@ body { image-rendering: pixelated; cursor: none; } + +/* Ports config page */ +.ports-page { + padding: 1.5rem; + overflow-y: auto; + max-height: 100vh; +} + +.ports-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.ports-header h2 { font-size: 1.1rem; font-weight: 500; } + +.ports-actions { display: flex; gap: 0.5rem; } + +.ports-toast { + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.85rem; + margin-bottom: 1rem; +} + +.toast-ok { background: #1a3a1a; color: #6ece6e; } +.toast-err { background: #3a1a1a; color: #e74c3c; } + +.ports-global { + display: flex; + gap: 1.5rem; + align-items: center; + margin-bottom: 1rem; + font-size: 0.85rem; +} + +.ports-global label { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ports-global input, .ports-global select { + padding: 0.3rem 0.5rem; + border: 1px solid #444; + border-radius: 3px; + background: #2a2a2a; + color: #e0e0e0; + font-size: 0.85rem; + width: 80px; +} + +.ports-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.ports-table th { + text-align: left; + padding: 0.4rem 0.5rem; + border-bottom: 1px solid #444; + color: #999; + font-weight: 500; +} + +.ports-table td { + padding: 0.3rem 0.5rem; + border-bottom: 1px solid #2a2a2a; +} + +.ports-table .port-num { color: #666; width: 2rem; text-align: center; } + +.ports-table input[type="text"] { + padding: 0.25rem 0.4rem; + border: 1px solid #444; + border-radius: 3px; + background: #2a2a2a; + color: #e0e0e0; + font-size: 0.85rem; + width: 100%; +} + +.ports-table input[type="checkbox"] { cursor: pointer; } + +.active-port { background: #1a2a1a; } +.active-port .port-num { color: #4a7c59; font-weight: 600; } + +/* Buttons */ +.btn { + padding: 0.35rem 0.75rem; + border: 1px solid #444; + border-radius: 4px; + background: #333; + color: #ccc; + cursor: pointer; + font-size: 0.8rem; +} + +.btn:hover { background: #444; } +.btn-primary { background: #4a7c59; border-color: #4a7c59; color: white; } +.btn-primary:hover { background: #5a9c69; } +.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.75rem; } diff --git a/crates/ericrfb-proxy/Cargo.toml b/crates/ericrfb-proxy/Cargo.toml index 8aa25f0..8e0cc3b 100644 --- a/crates/ericrfb-proxy/Cargo.toml +++ b/crates/ericrfb-proxy/Cargo.toml @@ -10,6 +10,7 @@ tokio.workspace = true axum.workspace = true reqwest.workspace = true serde.workspace = true +serde_json = "1" toml.workspace = true tower-http.workspace = true tracing.workspace = true diff --git a/crates/ericrfb-proxy/src/kvm.rs b/crates/ericrfb-proxy/src/kvm.rs new file mode 100644 index 0000000..643d11a --- /dev/null +++ b/crates/ericrfb-proxy/src/kvm.rs @@ -0,0 +1,252 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use serde::{Deserialize, Serialize}; + +use crate::AppState; +use crate::login::ErrorResponse; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct PortsResponse { + pub port_count: u16, + pub key_pause_duration: u16, + pub active_port: u16, + pub ports: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct PortInfo { + pub index: u16, + pub name: String, + pub hotkey: String, + pub show_in_rc: bool, +} + +#[derive(Deserialize)] +pub struct SavePortsRequest { + pub port_count: u16, + pub key_pause_duration: u16, + pub ports: Vec, +} + +#[derive(Deserialize)] +pub struct SwitchRequest { + pub port: u16, +} + +#[derive(Serialize)] +pub struct SwitchResponse { + pub active_port: u16, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async fn get_cookie(state: &AppState) -> Result)> { + state.session_cookie.read().await.clone().ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "not logged in".into(), + }), + ) + }) +} + +fn device_url(state: &AppState, path: &str) -> String { + format!( + "http://{}:{}{path}", + state.config.omniview.host, state.config.omniview.http_port + ) +} + +fn api_err(msg: impl Into) -> (StatusCode, Json) { + ( + StatusCode::BAD_GATEWAY, + Json(ErrorResponse { error: msg.into() }), + ) +} + +// --------------------------------------------------------------------------- +// HTML scraping helpers +// --------------------------------------------------------------------------- + +fn extract_input_value(html: &str, name: &str) -> Option { + let needle = format!("name=\"{name}\""); + let pos = html.find(&needle)?; + let after = &html[pos..]; + let val_needle = "value=\""; + let val_pos = after.find(val_needle)? + val_needle.len(); + let end = after[val_pos..].find('"')? + val_pos; + Some(after[val_pos..end].to_string()) +} + +fn extract_selected_option(html: &str, name: &str) -> Option { + let needle = format!("name=\"{name}\""); + let pos = html.find(&needle)?; + let after = &html[pos..]; + // Find "selected>" then the text until newline or '<' + let sel_pos = after.find("selected>")?; + let text_start = sel_pos + "selected>".len(); + let text_end = after[text_start..] + .find(['<', '\n']) + .unwrap_or(0) + + text_start; + Some(after[text_start..text_end].trim().to_string()) +} + +fn has_checked(html: &str, name: &str) -> bool { + let needle = format!("name=\"{name}\""); + if let Some(pos) = html.find(&needle) { + // Check if "checked" appears nearby before the next input/tag + let region = &html[pos.saturating_sub(80)..html.len().min(pos + 120)]; + region.contains("checked") + } else { + false + } +} + +// --------------------------------------------------------------------------- +// GET /api/kvm/ports +// --------------------------------------------------------------------------- + +pub async fn get_ports( + State(state): State, +) -> Result, (StatusCode, Json)> { + let cookie = get_cookie(&state).await?; + + let html = state + .http_client + .get(device_url(&state, "/kvm.asp")) + .header("cookie", &cookie) + .send() + .await + .map_err(|e| api_err(format!("fetch kvm.asp: {e}")))? + .text() + .await + .map_err(|e| api_err(format!("read kvm.asp: {e}")))?; + + let port_count: u16 = extract_selected_option(&html, "ECG_kvm_nr_ports") + .and_then(|s| s.parse().ok()) + .unwrap_or(16); + + let key_pause_duration: u16 = extract_input_value(&html, "ECG_key_pause_duration") + .and_then(|s| s.parse().ok()) + .unwrap_or(100); + + let active_port: u16 = extract_selected_option(&html, "kvm_active_port_0") + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let mut ports = Vec::with_capacity(port_count as usize); + for i in 0..port_count { + let name = extract_input_value(&html, &format!("ECG_kvm_portname_{i}")).unwrap_or_default(); + let hotkey = extract_input_value(&html, &format!("ECG_kvm_hotkey_{i}")).unwrap_or_default(); + let show_in_rc = has_checked(&html, &format!("ECG_kvm_show_in_rc_{i}")); + ports.push(PortInfo { + index: i, + name, + hotkey, + show_in_rc, + }); + } + + Ok(Json(PortsResponse { + port_count, + key_pause_duration, + active_port, + ports, + })) +} + +// --------------------------------------------------------------------------- +// PUT /api/kvm/ports +// --------------------------------------------------------------------------- + +pub async fn save_ports( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let cookie = get_cookie(&state).await?; + + let mut form: Vec<(String, String)> = vec![ + ("ECG_kvm_nr_ports".into(), req.port_count.to_string()), + ( + "ECG_key_pause_duration".into(), + req.key_pause_duration.to_string(), + ), + ("ECG_kvm_portname_cnt".into(), req.port_count.to_string()), + ("ECG_kvm_hotkey_cnt".into(), req.port_count.to_string()), + ("ECG_kvm_show_in_rc_cnt".into(), req.port_count.to_string()), + ]; + + for port in &req.ports { + form.push(( + format!("ECG_kvm_portname_{}", port.index), + port.name.clone(), + )); + form.push(( + format!("ECG_kvm_hotkey_{}", port.index), + port.hotkey.clone(), + )); + if port.show_in_rc { + form.push((format!("ECG_kvm_show_in_rc_{}", port.index), "yes".into())); + } + } + + form.push(("action_apply".into(), "Apply".into())); + + let resp = state + .http_client + .post(device_url(&state, "/kvm.asp")) + .header("cookie", &cookie) + .form(&form) + .send() + .await + .map_err(|e| api_err(format!("post kvm.asp: {e}")))?; + + let body = resp + .text() + .await + .map_err(|e| api_err(format!("read response: {e}")))?; + + if body.contains("ERIC_RESPONSE_OK") { + Ok(Json(serde_json::json!({"ok": true}))) + } else { + Err(api_err("device rejected configuration update")) + } +} + +// --------------------------------------------------------------------------- +// POST /api/kvm/switch +// --------------------------------------------------------------------------- + +pub async fn switch_port( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let cookie = get_cookie(&state).await?; + + let form = [ + ("kvm_active_port_0", req.port.to_string()), + ("action_switch_0", "Switch".into()), + ]; + + state + .http_client + .post(device_url(&state, "/home2.asp")) + .header("cookie", &cookie) + .form(&form) + .send() + .await + .map_err(|e| api_err(format!("post home2.asp: {e}")))?; + + Ok(Json(SwitchResponse { + active_port: req.port, + })) +} diff --git a/crates/ericrfb-proxy/src/login.rs b/crates/ericrfb-proxy/src/login.rs index 8736148..e82a20e 100644 --- a/crates/ericrfb-proxy/src/login.rs +++ b/crates/ericrfb-proxy/src/login.rs @@ -88,6 +88,9 @@ pub async fn handle_login( let board_name = extract_param(&html, "BOARD_NAME").unwrap_or_else(|| "Remote IP Manager".into()); + // Persist session cookie for KVM API calls + *state.session_cookie.write().await = Some(cookie); + tracing::info!( "login successful: board={board_name}, applet_id={}...", &applet_id[..applet_id.len().min(16)] diff --git a/crates/ericrfb-proxy/src/main.rs b/crates/ericrfb-proxy/src/main.rs index dceca15..e7104d1 100644 --- a/crates/ericrfb-proxy/src/main.rs +++ b/crates/ericrfb-proxy/src/main.rs @@ -1,4 +1,5 @@ mod config; +mod kvm; mod login; mod ws; @@ -7,6 +8,7 @@ use std::sync::Arc; use axum::Router; use axum::routing::{get, post}; use tokio::net::TcpListener; +use tokio::sync::RwLock; use tower_http::services::ServeDir; use tracing_subscriber::EnvFilter; @@ -14,6 +16,7 @@ use tracing_subscriber::EnvFilter; pub struct AppState { pub config: Arc, pub http_client: reqwest::Client, + pub session_cookie: Arc>>, } #[tokio::main] @@ -36,11 +39,14 @@ async fn main() -> anyhow::Result<()> { .danger_accept_invalid_certs(true) .redirect(reqwest::redirect::Policy::none()) .build()?, + session_cookie: Arc::new(RwLock::new(None)), }; let app = Router::new() .route("/api/login", post(login::handle_login)) .route("/api/ws", get(ws::handle_ws)) + .route("/api/kvm/ports", get(kvm::get_ports).put(kvm::save_ports)) + .route("/api/kvm/switch", post(kvm::switch_port)) .fallback_service(ServeDir::new(&cfg.static_dir)) .with_state(state);