feat(webui): FastAPI REST + WebSocket-консоль + MCP-эндпоинт + браузерный UI

- server.py: /api/power/*, /api/status, /api/history, /ws/console, /mcp (MCPServer streamable-http)
- auth: Bearer-токен (ATXPI_WEB_TOKEN) для /api/* и /mcp/*; WS — через ?token=
- статика: index.html, app.js, style.css + vendored xterm.js (работает офлайн на Pi)
- lightweight: один процесс uvicorn, без node/сборки
- install.sh: отдаю сервис-юзеру владение atxpi/ и /etc/atxpi (pyc/tmp/audit)
This commit is contained in:
toros
2026-09-08 22:55:20 +05:00
parent 12e6a453de
commit a1997b00ac
8 changed files with 673 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
/* atxpi — браузерный клиент (без сборки, vanilla JS). */
'use strict';
const TOKEN = 'atxpi_token';
let term = null, ws = null, termOpen = false;
function getToken() {
return localStorage.getItem(TOKEN) || sessionStorage.getItem(TOKEN) || '';
}
function setToken(t) {
localStorage.setItem(TOKEN, t);
}
function hdr() {
return { 'Authorization': 'Bearer ' + getToken() };
}
/* ── API ─────────────────────────────────────────────────────────── */
async function api(path, opts = {}) {
const headers = Object.assign({}, hdr(), opts.headers || {});
const r = await fetch(path, Object.assign({}, opts, { headers }));
if (r.status === 401) { showLogin(); throw new Error('unauthorized'); }
return r.json();
}
async function refresh() {
try {
const s = await api('/api/status');
document.getElementById('powerState').textContent =
s.power === 'on' ? '🟢 ON' : '🔴 OFF';
document.getElementById('status').textContent =
`driver=${s.driver} · serial=${s.serial_port}@${s.serial_baud}`;
} catch (e) { /* уже обработано в api() */ }
}
async function doPower(action) {
try {
await api('/api/power/' + action, { method: 'POST' });
await refresh();
} catch (e) { console.error(e); }
}
/* ── логин ───────────────────────────────────────────────────────── */
async function login() {
const t = document.getElementById('tok').value.trim();
const r = await fetch('/api/status', { headers: { 'Authorization': 'Bearer ' + t } });
if (r.ok) {
setToken(t);
showApp();
} else {
document.getElementById('loginMsg').textContent = 'Неверный токен';
}
}
function showLogin() {
document.getElementById('login').classList.remove('hidden');
document.getElementById('app').classList.add('hidden');
}
function showApp() {
document.getElementById('login').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
refresh();
}
/* ── консоль (xterm.js + WebSocket) ──────────────────────────────── */
function toggleConsole() {
if (termOpen) { disconnectConsole(); return; }
term = new Terminal({
cursorBlink: true,
fontSize: 13,
convertEol: true,
theme: { background: '#0b0f14', foreground: '#d7d7d7', cursor: '#4e8cff' },
});
term.open(document.getElementById('term'));
const scheme = (location.protocol === 'https:') ? 'wss' : 'ws';
ws = new WebSocket(
scheme + '://' + location.host + '/ws/console?token=' + encodeURIComponent(getToken())
);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
termOpen = true;
document.getElementById('consoleState').textContent = 'подключена';
term.writeln('\x1b[32m(консоль подключена)\x1b[0m');
};
ws.onmessage = (ev) => {
if (typeof ev.data === 'string') term.write(ev.data);
else term.write(new Uint8Array(ev.data));
};
ws.onclose = () => { disconnectConsole(true); };
term.onData((d) => { if (ws && ws.readyState === 1) ws.send(d); });
}
function disconnectConsole(byRemote = false) {
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
if (term) { try { term.dispose(); } catch (e) {} term = null; }
termOpen = false;
document.getElementById('consoleState').textContent = 'не подключена';
if (byRemote) { /* уже закрыто сервером */ }
}
/* ── init ────────────────────────────────────────────────────────── */
if (getToken()) showApp(); else showLogin();
+50
View File
@@ -0,0 +1,50 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>atxpi — управление сервером</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
</head>
<body>
<!-- Логин -->
<div id="login" class="hidden">
<div class="card">
<h1>atxpi</h1>
<p class="muted">Введите токен доступа (ATXPI_WEB_TOKEN)</p>
<input id="tok" type="password" placeholder="Токен" autocomplete="off">
<button class="btn primary" onclick="login()">Войти</button>
<p id="loginMsg" class="err"></p>
</div>
</div>
<!-- Приложение -->
<main id="app" class="hidden">
<header>
<h1>⚡ atxpi</h1>
<span id="powerState" class="badge"></span>
<span class="spacer"></span>
<button class="btn" onclick="doPower('on')" title="Нажать кнопку питания">⏻ ON</button>
<button class="btn" onclick="doPower('off')" title="Принудительно выключить (долгое нажатие)">⭘ OFF</button>
<button class="btn" onclick="doPower('reset')" title="Аппаратный сброс">↻ Reset</button>
<button class="btn" onclick="doPower('cycle')" title="Жёсткий перезапуск">⟳ Cycle</button>
</header>
<section id="status" class="status-line"></section>
<section class="console-panel">
<div class="console-bar">
<span>RS232-консоль</span>
<span id="consoleState" class="muted">не подключена</span>
<span class="spacer"></span>
<button class="btn" onclick="toggleConsole()">Подключить</button>
</div>
<div id="term"></div>
</section>
</main>
<script src="/static/vendor/xterm.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
:root {
--bg: #0b0f14;
--panel: #121a22;
--panel2: #17222d;
--text: #d7d7d7;
--muted: #7a8691;
--accent: #4e8cff;
--danger: #ff5c5c;
--ok: #48d597;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.hidden { display: none !important; }
.muted { color: var(--muted); }
.err { color: var(--danger); }
.spacer { flex: 1; }
/* логин */
#login {
display: flex; align-items: center; justify-content: center;
min-height: 100vh;
}
.card {
background: var(--panel);
border: 1px solid var(--panel2);
border-radius: 12px;
padding: 32px;
width: 320px;
text-align: center;
}
.card input {
width: 100%; padding: 10px 12px; margin: 8px 0 14px;
border-radius: 8px; border: 1px solid var(--panel2);
background: var(--bg); color: var(--text);
}
/* app */
#app { max-width: 900px; margin: 0 auto; padding: 24px; }
header {
display: flex; align-items: center; gap: 10px;
padding-bottom: 14px; border-bottom: 1px solid var(--panel2);
}
header h1 { margin: 0; font-size: 20px; }
.badge {
padding: 3px 10px; border-radius: 999px; font-size: 13px; font-weight: 600;
background: var(--panel2); color: var(--muted);
}
.status-line { color: var(--muted); margin: 14px 0; font-size: 13px; }
.btn {
border: 1px solid var(--panel2); background: var(--panel2); color: var(--text);
padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 14px;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #04121f; font-weight: 600; }
.btn.danger:hover { border-color: var(--danger); }
/* консоль */
.console-panel {
margin-top: 16px;
background: var(--panel);
border: 1px solid var(--panel2);
border-radius: 12px;
overflow: hidden;
}
.console-bar {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px; background: var(--panel2); font-size: 13px;
}
#term { height: 360px; padding: 8px; }
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long