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:
@@ -0,0 +1 @@
|
||||
"""atxpi.webui package."""
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""atxpi — web-плоскость управления (FastAPI).
|
||||
|
||||
Отдаёт:
|
||||
/ браузерный UI (index.html)
|
||||
/static/* статика (в т.ч. vendored xterm.js)
|
||||
/api/power REST: GET — статус, POST on|off|reset|cycle
|
||||
/api/status сводка по устройству
|
||||
/api/history журнал действий (audit)
|
||||
/ws/console WebSocket — двунаправленная RS232-консоль
|
||||
/mcp MCP-эндпоинт (streamable-http) для агентов
|
||||
|
||||
Аутентификация: Bearer-токен (ATXPI_WEB_TOKEN) в заголовке Authorization
|
||||
(для /ws/console также принимается ?token=...).
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from atxpi.core import power as power_mod
|
||||
from atxpi.core.hardware import make_console, DRIVER, SERIAL_PORT, SERIAL_BAUD
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
log = logging.getLogger("atxpi.webui")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
||||
WEB_TOKEN = os.environ.get("ATXPI_WEB_TOKEN", "")
|
||||
AUDIT = os.environ.get("ATXPI_AUDIT", "/etc/atxpi/audit.jsonl")
|
||||
|
||||
|
||||
def audit(action: str, **kw):
|
||||
"""Логируем действие в jsonl (для /api/history)."""
|
||||
entry = {"t": time.time(), "action": action, **kw}
|
||||
try:
|
||||
Path(AUDIT).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(AUDIT, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
except Exception as e:
|
||||
log.warning("audit: %s", e)
|
||||
return entry
|
||||
|
||||
|
||||
def _history(n: int = 100):
|
||||
if not os.path.exists(AUDIT):
|
||||
return []
|
||||
try:
|
||||
with open(AUDIT, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()[-n:]
|
||||
return [json.loads(l) for l in lines if l.strip()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
app = FastAPI(title="atxpi", version="0.1.0")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_mw(request: Request, call_next):
|
||||
"""Требуем Bearer-токен для /api/* и /mcp/* (если токен задан)."""
|
||||
path = request.url.path
|
||||
if path.startswith(("/api", "/mcp")) and WEB_TOKEN:
|
||||
got = request.headers.get("authorization", "").replace("Bearer ", "").strip()
|
||||
if got != WEB_TOKEN:
|
||||
return JSONResponse({"detail": "unauthorized"}, status_code=401)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ── REST: питание ──────────────────────────────────────────────────────────
|
||||
@app.get("/api/power")
|
||||
async def api_power_status():
|
||||
return power_mod.status()
|
||||
|
||||
|
||||
@app.post("/api/power/on")
|
||||
async def api_power_on():
|
||||
audit("power_on")
|
||||
return power_mod.press_power()
|
||||
|
||||
|
||||
@app.post("/api/power/off")
|
||||
async def api_power_off():
|
||||
audit("power_off")
|
||||
return power_mod.force_off()
|
||||
|
||||
|
||||
@app.post("/api/power/reset")
|
||||
async def api_power_reset():
|
||||
audit("power_reset")
|
||||
return power_mod.press_reset()
|
||||
|
||||
|
||||
@app.post("/api/power/cycle")
|
||||
async def api_power_cycle():
|
||||
audit("power_cycle")
|
||||
return power_mod.cycle()
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_status():
|
||||
s = power_mod.status()
|
||||
return {
|
||||
"power": s["power"],
|
||||
"powered_on": s["powered_on"],
|
||||
"driver": DRIVER,
|
||||
"serial_port": SERIAL_PORT,
|
||||
"serial_baud": SERIAL_BAUD,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/history")
|
||||
async def api_history():
|
||||
return _history()
|
||||
|
||||
|
||||
# ── WebSocket консоль ───────────────────────────────────────────────────────
|
||||
@app.websocket("/ws/console")
|
||||
async def ws_console(ws: WebSocket):
|
||||
token = ws.query_params.get("token", "")
|
||||
if WEB_TOKEN and token != WEB_TOKEN:
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
await ws.accept()
|
||||
console = make_console()
|
||||
try:
|
||||
console.open()
|
||||
except Exception as e:
|
||||
await ws.send_text(json.dumps({"error": f"console open failed: {e}"}))
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
console.send(("\r\nATXPI console ready. driver=%s\r\n" % DRIVER).encode())
|
||||
|
||||
async def reader():
|
||||
try:
|
||||
while True:
|
||||
data = await asyncio.to_thread(console.read, 0.25)
|
||||
if data:
|
||||
await ws.send_bytes(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
read_task = asyncio.create_task(reader())
|
||||
try:
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if msg["type"] == "websocket.disconnect":
|
||||
break
|
||||
data = msg.get("bytes") or msg.get("text")
|
||||
if data:
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
console.send(data)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
read_task.cancel()
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
console.close()
|
||||
|
||||
|
||||
# ── MCP (streamable-http endpoint, для агентов) ─────────────────────────────
|
||||
mcp = MCPServer("atxpi")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def power_status() -> dict:
|
||||
"""Текущее состояние питания сервера (on/off)."""
|
||||
return power_mod.status()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def power_on() -> dict:
|
||||
"""Нажать кнопку питания (вкл/выкл-переключатель)."""
|
||||
audit("power_on")
|
||||
return power_mod.press_power()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def power_off() -> dict:
|
||||
"""Принудительное выключение (длинное нажатие кнопки питания)."""
|
||||
audit("power_off")
|
||||
return power_mod.force_off()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def power_reset() -> dict:
|
||||
"""Аппаратный сброс сервера."""
|
||||
audit("power_reset")
|
||||
return power_mod.press_reset()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def power_cycle() -> dict:
|
||||
"""Жёсткий перезапуск: выключить, пауза, включить."""
|
||||
audit("power_cycle")
|
||||
return power_mod.cycle()
|
||||
|
||||
|
||||
app.mount("/mcp", mcp.streamable_http_app())
|
||||
|
||||
|
||||
# ── статика ─────────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
async def index():
|
||||
return FileResponse(os.path.join(STATIC, "index.html"))
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
Vendored
+218
@@ -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;
|
||||
}
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user