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 = `
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Name |
+ Hotkey |
+ Show |
+ |
+
+
+
+
+
+ `
+
+ 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);