feat: redesign portal UX and stabilize web session runtime

This commit is contained in:
2026-04-13 08:35:07 +00:00
commit fc46d90194
29 changed files with 3915 additions and 0 deletions

21
kiosk/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
chromium \
xvfb \
x11vnc \
fluxbox \
novnc \
websockify \
python3 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
COPY manager.py /manager.py
RUN chmod +x /entrypoint.sh
EXPOSE 6080
ENTRYPOINT ["/entrypoint.sh"]

159
kiosk/entrypoint.sh Executable file
View File

@@ -0,0 +1,159 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_URL="${TARGET_URL:-https://example.com}"
SESSION_ID="${SESSION_ID:-unknown}"
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}"
UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}"
START_URL="${START_URL:-about:blank}"
HOME_URL="${HOME_URL:-$TARGET_URL}"
SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}"
CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}"
if [ "$UNIVERSAL_WEB" = "1" ]; then
HOME_URL="${HOME_URL:-$START_URL}"
fi
mkdir -p /opt/portal
cp -r /usr/share/novnc/* /opt/portal/
cat > /opt/portal/index.html <<HTML
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Инфра полигон МОНТ</title>
<style>
html,body,#screen{margin:0;height:100%;background:#111}
.nav-panel{
position:fixed;left:14px;top:14px;z-index:99;display:flex;gap:8px;
background:rgba(12,18,26,.88);border:1px solid rgba(255,255,255,.14);
box-shadow:0 8px 22px rgba(0,0,0,.35);padding:8px;border-radius:10px
}
.nav-btn{
border:1px solid rgba(255,255,255,.14);border-radius:8px;padding:8px 12px;cursor:pointer;
background:linear-gradient(180deg,#1a73b3,#0f5b94);color:#fff;font:600 13px/1 sans-serif
}
.nav-btn:hover{filter:brightness(1.08)}
.nav-btn:active{transform:translateY(1px)}
</style>
</head>
<body>
<div id="screen"></div>
<div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button">Назад</button>
<button class="nav-btn" id="btn-forward" type="button">Вперед</button>
<button class="nav-btn" id="btn-home" type="button">Домой</button>
</div>
<script type="module">
import RFB from './core/rfb.js';
const XK_ALT_L = 0xffe9;
const XK_CONTROL_L = 0xffe3;
const XK_LEFT = 0xff51;
const XK_RIGHT = 0xff53;
const XK_ENTER = 0xff0d;
const HOME_URL = ${HOME_URL@Q};
const wsBase = location.pathname.replace(/\/+$/, '');
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify';
const rfb = new RFB(document.getElementById('screen'), wsUrl);
rfb.viewOnly = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
async function touch() {
try {
await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'});
} catch(e) {}
}
if (enableHeartbeat) {
setInterval(touch, 60000);
touch();
}
function keyTap(keysym, code) {
rfb.sendKey(keysym, code, true);
rfb.sendKey(keysym, code, false);
}
function chord(mod, key, modCode, keyCode) {
rfb.sendKey(mod, modCode, true);
keyTap(key, keyCode);
rfb.sendKey(mod, modCode, false);
}
function typeText(text) {
for (const ch of text) {
const code = ch.codePointAt(0);
keyTap(code, ch);
}
}
function goHome() {
try {
if (window.top && window.top !== window) {
window.top.location.href = '/';
return;
}
} catch (e) {}
window.location.href = '/';
}
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, "AltLeft", "ArrowLeft"));
document.getElementById('btn-forward').addEventListener('click', () => chord(XK_ALT_L, XK_RIGHT, "AltLeft", "ArrowRight"));
document.getElementById('btn-home').addEventListener('click', goHome);
document.addEventListener('contextmenu', (e) => e.preventDefault());
</script>
</body>
</html>
HTML
export DISPLAY=:1
Xvfb :1 -screen 0 "$SCREEN_GEOMETRY" &
fluxbox >/tmp/fluxbox.log 2>&1 &
sleep 1
if [ "$UNIVERSAL_WEB" = "1" ]; then
chromium \
--no-sandbox \
--disable-dev-shm-usage \
--disable-gpu \
--use-gl=swiftshader \
--kiosk \
--remote-debugging-address=0.0.0.0 \
--remote-debugging-port=9222 \
--remote-allow-origins=* \
--disable-translate \
--disable-features=TranslateUI,ExtensionsToolbarMenu \
--disable-pinch \
--overscroll-history-navigation=0 \
--ignore-certificate-errors \
--allow-insecure-localhost \
--allow-running-insecure-content \
--window-size="$CHROME_WINDOW_SIZE" \
--no-first-run \
--no-default-browser-check \
"$START_URL" \
>/tmp/chromium.log 2>&1 &
python3 /manager.py >/tmp/manager.log 2>&1 &
else
chromium \
--no-sandbox \
--disable-dev-shm-usage \
--disable-gpu \
--use-gl=swiftshader \
--kiosk \
--app="$TARGET_URL" \
--disable-translate \
--disable-features=TranslateUI,ExtensionsToolbarMenu \
--disable-pinch \
--overscroll-history-navigation=0 \
--ignore-certificate-errors \
--allow-insecure-localhost \
--allow-running-insecure-content \
--window-size="$CHROME_WINDOW_SIZE" \
--no-first-run \
--no-default-browser-check \
>/tmp/chromium.log 2>&1 &
fi
x11vnc -display :1 -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 &
exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900

74
kiosk/manager.py Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
import json
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
def _json_get(path: str):
with urllib.request.urlopen(f"http://127.0.0.1:9222{path}", timeout=2) as resp:
return json.loads(resp.read().decode("utf-8"))
def _json_put(path: str):
req = urllib.request.Request(f"http://127.0.0.1:9222{path}", method="PUT")
with urllib.request.urlopen(req, timeout=2) as resp:
return json.loads(resp.read().decode("utf-8"))
def chromium_open(url: str) -> None:
encoded = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%')
opened = _json_put(f"/json/new?{encoded}")
opened_id = opened.get("id")
# Keep exactly one active page tab to prevent tab/memory explosion in warm containers.
pages = _json_get("/json/list")
for page in pages:
page_id = page.get("id")
if page_id and page_id != opened_id:
try:
_json_put(f"/json/close/{page_id}")
except Exception:
pass
class Handler(BaseHTTPRequestHandler):
def _json(self, code: int, payload: dict):
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
self._json(200, {"ok": True})
return
self._json(404, {"detail": "Not found"})
def do_POST(self):
if self.path != "/open":
self._json(404, {"detail": "Not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
data = json.loads(raw.decode("utf-8")) if raw else {}
url = (data.get("url") or "").strip()
if not url.startswith("http://") and not url.startswith("https://"):
self._json(400, {"detail": "Invalid URL"})
return
chromium_open(url)
print(f"open_ok url={url}", flush=True)
self._json(200, {"ok": True, "url": url})
except Exception as exc:
print(f"open_fail err={exc}", flush=True)
self._json(500, {"detail": str(exc)})
def log_message(self, format, *args):
return
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", 7000), Handler)
server.serve_forever()