py-web-ssh 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_web_ssh-0.1.0.dist-info/METADATA +75 -0
- py_web_ssh-0.1.0.dist-info/RECORD +16 -0
- py_web_ssh-0.1.0.dist-info/WHEEL +4 -0
- py_web_ssh-0.1.0.dist-info/entry_points.txt +3 -0
- py_web_ssh-0.1.0.dist-info/licenses/LICENSE +21 -0
- webssh/__init__.py +5 -0
- webssh/app.py +200 -0
- webssh/files.py +189 -0
- webssh/history.py +66 -0
- webssh/models.py +76 -0
- webssh/session.py +345 -0
- webssh/ssh_client.py +288 -0
- webssh/static/app.js +316 -0
- webssh/static/index.html +120 -0
- webssh/static/logs.html +44 -0
- webssh/static/styles.css +267 -0
webssh/static/app.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
const terminalElement = document.querySelector("#terminal");
|
|
2
|
+
const logsElement = document.querySelector("#logs");
|
|
3
|
+
const statusElement = document.querySelector("#status");
|
|
4
|
+
const sessionInput = document.querySelector("#session-id");
|
|
5
|
+
const sessionLabel = document.querySelector("#session-label");
|
|
6
|
+
const logsLink = document.querySelector("#logs-link");
|
|
7
|
+
|
|
8
|
+
const term = new Terminal({
|
|
9
|
+
cursorBlink: true,
|
|
10
|
+
convertEol: false,
|
|
11
|
+
fontFamily: "Cascadia Mono, Consolas, Menlo, monospace",
|
|
12
|
+
fontSize: 14,
|
|
13
|
+
scrollback: 5000,
|
|
14
|
+
theme: {
|
|
15
|
+
background: "#0b0f14",
|
|
16
|
+
foreground: "#dbe7f3",
|
|
17
|
+
cursor: "#f8d66d",
|
|
18
|
+
selectionBackground: "#315f8c",
|
|
19
|
+
black: "#0b0f14",
|
|
20
|
+
red: "#f26d6d",
|
|
21
|
+
green: "#8fd694",
|
|
22
|
+
yellow: "#f8d66d",
|
|
23
|
+
blue: "#7db7ff",
|
|
24
|
+
magenta: "#c792ea",
|
|
25
|
+
cyan: "#82d7d1",
|
|
26
|
+
white: "#dbe7f3",
|
|
27
|
+
brightBlack: "#61707f",
|
|
28
|
+
brightRed: "#ff8a8a",
|
|
29
|
+
brightGreen: "#a6e3a1",
|
|
30
|
+
brightYellow: "#ffe08a",
|
|
31
|
+
brightBlue: "#9cc9ff",
|
|
32
|
+
brightMagenta: "#d9a8ff",
|
|
33
|
+
brightCyan: "#a5f3ed",
|
|
34
|
+
brightWhite: "#ffffff",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const fitAddon = new FitAddon.FitAddon();
|
|
38
|
+
const serializeAddon = new SerializeAddon.SerializeAddon();
|
|
39
|
+
term.loadAddon(fitAddon);
|
|
40
|
+
term.loadAddon(serializeAddon);
|
|
41
|
+
term.open(terminalElement);
|
|
42
|
+
fitAddon.fit();
|
|
43
|
+
|
|
44
|
+
let ws = null;
|
|
45
|
+
let activeSessionId = localStorage.getItem("py-web-ssh-session") || "";
|
|
46
|
+
let lastAppliedSeq = 0;
|
|
47
|
+
let snapshotTimer = null;
|
|
48
|
+
|
|
49
|
+
if (activeSessionId) {
|
|
50
|
+
sessionInput.value = activeSessionId;
|
|
51
|
+
updateSessionUi(activeSessionId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
window.addEventListener("resize", () => {
|
|
55
|
+
fitAddon.fit();
|
|
56
|
+
sendResize();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
term.onData((data) => {
|
|
60
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
ws.send(JSON.stringify({ type: "input", data: stringToBase64(data) }));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
document.querySelector("#connect-form").addEventListener("submit", async (event) => {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
setStatus("创建会话...");
|
|
69
|
+
const privateKey = await readPrivateKey();
|
|
70
|
+
const payload = {
|
|
71
|
+
host: valueOf("#host"),
|
|
72
|
+
port: Number(valueOf("#port") || 22),
|
|
73
|
+
username: valueOf("#username"),
|
|
74
|
+
password: valueOf("#password"),
|
|
75
|
+
private_key: privateKey,
|
|
76
|
+
private_key_passphrase: valueOf("#private-key-passphrase"),
|
|
77
|
+
allow_agent: checked("#allow-agent"),
|
|
78
|
+
look_for_keys: checked("#look-for-keys"),
|
|
79
|
+
legacy_algorithms: checked("#legacy-algorithms"),
|
|
80
|
+
strict_host_key: checked("#strict-host-key"),
|
|
81
|
+
term: "xterm-256color",
|
|
82
|
+
size: { cols: term.cols, rows: term.rows },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const response = await fetch("/api/sessions", {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
body: JSON.stringify(payload),
|
|
89
|
+
});
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
appendLogLine(`创建会话失败: ${await response.text()}`);
|
|
92
|
+
setStatus("创建失败");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const result = await response.json();
|
|
96
|
+
activeSessionId = result.session_id;
|
|
97
|
+
localStorage.setItem("py-web-ssh-session", activeSessionId);
|
|
98
|
+
sessionInput.value = activeSessionId;
|
|
99
|
+
updateSessionUi(activeSessionId);
|
|
100
|
+
connectWebSocket(activeSessionId);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
document.querySelector("#reconnect").addEventListener("click", () => {
|
|
104
|
+
const id = sessionInput.value.trim();
|
|
105
|
+
if (id) {
|
|
106
|
+
activeSessionId = id;
|
|
107
|
+
localStorage.setItem("py-web-ssh-session", id);
|
|
108
|
+
updateSessionUi(id);
|
|
109
|
+
connectWebSocket(id);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
document.querySelector("#disconnect").addEventListener("click", () => {
|
|
114
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
115
|
+
sendSnapshot();
|
|
116
|
+
ws.send(JSON.stringify({ type: "disconnect" }));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
document.querySelector("#upload-form").addEventListener("submit", async (event) => {
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
const file = document.querySelector("#upload-file").files[0];
|
|
123
|
+
const remotePath = valueOf("#upload-path");
|
|
124
|
+
if (!activeSessionId || !file || !remotePath) {
|
|
125
|
+
appendLogLine("上传需要会话 UUID、远端路径和本地文件。");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const form = new FormData();
|
|
129
|
+
form.append("remote_path", remotePath);
|
|
130
|
+
form.append("file", file);
|
|
131
|
+
setStatus("上传中...");
|
|
132
|
+
const response = await fetch(`/api/sessions/${activeSessionId}/files/upload`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
body: form,
|
|
135
|
+
});
|
|
136
|
+
const body = await response.text();
|
|
137
|
+
appendLogLine(response.ok ? `上传完成: ${body}` : `上传失败: ${body}`);
|
|
138
|
+
setStatus(response.ok ? "上传完成" : "上传失败");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
document.querySelector("#download-form").addEventListener("submit", (event) => {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
const remotePath = valueOf("#download-path");
|
|
144
|
+
if (!activeSessionId || !remotePath) {
|
|
145
|
+
appendLogLine("下载需要会话 UUID 和远端路径。");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
window.location.href =
|
|
149
|
+
`/api/sessions/${activeSessionId}/files/download?remote_path=${encodeURIComponent(remotePath)}`;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
document.querySelectorAll(".tab").forEach((button) => {
|
|
153
|
+
button.addEventListener("click", () => {
|
|
154
|
+
document.querySelectorAll(".tab").forEach((tab) => tab.classList.remove("active"));
|
|
155
|
+
document.querySelectorAll(".pane").forEach((pane) => pane.classList.remove("active"));
|
|
156
|
+
button.classList.add("active");
|
|
157
|
+
document.querySelector(`#${button.dataset.tab}-pane`).classList.add("active");
|
|
158
|
+
if (button.dataset.tab === "terminal") {
|
|
159
|
+
fitAddon.fit();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
function connectWebSocket(sessionId) {
|
|
165
|
+
if (ws) {
|
|
166
|
+
sendSnapshot();
|
|
167
|
+
ws.close();
|
|
168
|
+
}
|
|
169
|
+
term.focus();
|
|
170
|
+
setStatus("WebSocket 连接中...");
|
|
171
|
+
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
172
|
+
const socket = new WebSocket(`${protocol}://${window.location.host}/ws/sessions/${sessionId}`);
|
|
173
|
+
ws = socket;
|
|
174
|
+
socket.addEventListener("open", () => {
|
|
175
|
+
if (ws !== socket) return;
|
|
176
|
+
setStatus("WebSocket 已连接");
|
|
177
|
+
sendResize();
|
|
178
|
+
});
|
|
179
|
+
socket.addEventListener("message", async (event) => {
|
|
180
|
+
if (ws !== socket) return;
|
|
181
|
+
const message = JSON.parse(event.data);
|
|
182
|
+
await handleMessage(message);
|
|
183
|
+
});
|
|
184
|
+
socket.addEventListener("close", () => {
|
|
185
|
+
if (ws !== socket) return;
|
|
186
|
+
setStatus("WebSocket 已断开,可重连");
|
|
187
|
+
});
|
|
188
|
+
socket.addEventListener("error", () => {
|
|
189
|
+
if (ws === socket) setStatus("WebSocket 错误");
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function handleMessage(message) {
|
|
194
|
+
if (message.type === "session") {
|
|
195
|
+
appendLogLine(`绑定会话 ${message.session_id}`);
|
|
196
|
+
} else if (message.type === "replay") {
|
|
197
|
+
await replayTerminal(message);
|
|
198
|
+
setStatus(`会话状态: ${message.state}`);
|
|
199
|
+
if (message.warning) appendLogLine(message.warning);
|
|
200
|
+
for (const entry of message.logs || []) appendLogEntry(entry);
|
|
201
|
+
} else if (message.type === "output") {
|
|
202
|
+
await writeChunk(message);
|
|
203
|
+
} else if (message.type === "status") {
|
|
204
|
+
setStatus(`会话状态: ${message.state}`);
|
|
205
|
+
} else if (message.type === "log") {
|
|
206
|
+
appendLogEntry(message.entry);
|
|
207
|
+
} else if (message.type === "warning") {
|
|
208
|
+
appendLogLine(message.message);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function replayTerminal(message) {
|
|
213
|
+
term.reset();
|
|
214
|
+
lastAppliedSeq = 0;
|
|
215
|
+
if (message.snapshot) {
|
|
216
|
+
await writeTerminal(base64ToString(message.snapshot));
|
|
217
|
+
lastAppliedSeq = message.snapshot_seq || 0;
|
|
218
|
+
}
|
|
219
|
+
for (const chunk of message.chunks || []) {
|
|
220
|
+
await writeChunk(chunk);
|
|
221
|
+
}
|
|
222
|
+
scheduleSnapshot();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function writeChunk(chunk) {
|
|
226
|
+
const bytes = base64ToBytes(chunk.data);
|
|
227
|
+
await writeTerminal(bytes);
|
|
228
|
+
lastAppliedSeq = chunk.seq + bytes.byteLength;
|
|
229
|
+
scheduleSnapshot();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function writeTerminal(data) {
|
|
233
|
+
return new Promise((resolve) => term.write(data, resolve));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function sendResize() {
|
|
237
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
238
|
+
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function scheduleSnapshot() {
|
|
243
|
+
if (snapshotTimer) return;
|
|
244
|
+
snapshotTimer = window.setTimeout(() => {
|
|
245
|
+
snapshotTimer = null;
|
|
246
|
+
sendSnapshot();
|
|
247
|
+
}, 1500);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sendSnapshot() {
|
|
251
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const snapshot = serializeAddon.serialize();
|
|
255
|
+
ws.send(JSON.stringify({ type: "snapshot", seq: lastAppliedSeq, data: stringToBase64(snapshot) }));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
window.addEventListener("beforeunload", sendSnapshot);
|
|
259
|
+
|
|
260
|
+
async function readPrivateKey() {
|
|
261
|
+
const file = document.querySelector("#private-key-file").files[0];
|
|
262
|
+
return file ? await file.text() : "";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function updateSessionUi(sessionId) {
|
|
266
|
+
sessionLabel.textContent = sessionId ? `UUID ${sessionId}` : "";
|
|
267
|
+
logsLink.href = sessionId ? `/sessions/${sessionId}/logs` : "#";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function setStatus(text) {
|
|
271
|
+
statusElement.textContent = text;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function appendLogEntry(entry) {
|
|
275
|
+
const detail = entry.details ? `\n${entry.details}` : "";
|
|
276
|
+
appendLogLine(`[${entry.timestamp}] ${entry.level.toUpperCase()} ${entry.message}${detail}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function appendLogLine(line) {
|
|
280
|
+
logsElement.textContent += `${line}\n`;
|
|
281
|
+
logsElement.scrollTop = logsElement.scrollHeight;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function valueOf(selector) {
|
|
285
|
+
return document.querySelector(selector).value.trim();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function checked(selector) {
|
|
289
|
+
return document.querySelector(selector).checked;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function bytesToBase64(bytes) {
|
|
293
|
+
let binary = "";
|
|
294
|
+
const size = 0x8000;
|
|
295
|
+
for (let i = 0; i < bytes.length; i += size) {
|
|
296
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + size));
|
|
297
|
+
}
|
|
298
|
+
return btoa(binary);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function base64ToBytes(text) {
|
|
302
|
+
const binary = atob(text);
|
|
303
|
+
const bytes = new Uint8Array(binary.length);
|
|
304
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
305
|
+
bytes[i] = binary.charCodeAt(i);
|
|
306
|
+
}
|
|
307
|
+
return bytes;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function stringToBase64(text) {
|
|
311
|
+
return bytesToBase64(new TextEncoder().encode(text));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function base64ToString(text) {
|
|
315
|
+
return new TextDecoder().decode(base64ToBytes(text));
|
|
316
|
+
}
|
webssh/static/index.html
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>py-web-ssh</title>
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
|
|
8
|
+
<link rel="stylesheet" href="/static/styles.css" />
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<main class="app-shell">
|
|
12
|
+
<aside class="sidebar">
|
|
13
|
+
<header class="brand">
|
|
14
|
+
<h1>py-web-ssh</h1>
|
|
15
|
+
<p>Web SSH client</p>
|
|
16
|
+
</header>
|
|
17
|
+
|
|
18
|
+
<section class="panel">
|
|
19
|
+
<h2>连接</h2>
|
|
20
|
+
<form id="connect-form" class="stack">
|
|
21
|
+
<label>
|
|
22
|
+
目标服务器
|
|
23
|
+
<input id="host" name="host" required autocomplete="off" placeholder="192.168.1.10" />
|
|
24
|
+
</label>
|
|
25
|
+
<div class="grid-2">
|
|
26
|
+
<label>
|
|
27
|
+
端口
|
|
28
|
+
<input id="port" name="port" type="number" min="1" max="65535" value="22" required />
|
|
29
|
+
</label>
|
|
30
|
+
<label>
|
|
31
|
+
用户名
|
|
32
|
+
<input id="username" name="username" required autocomplete="username" />
|
|
33
|
+
</label>
|
|
34
|
+
</div>
|
|
35
|
+
<label>
|
|
36
|
+
口令
|
|
37
|
+
<input id="password" name="password" type="password" autocomplete="current-password" />
|
|
38
|
+
</label>
|
|
39
|
+
<label>
|
|
40
|
+
私钥文件
|
|
41
|
+
<input id="private-key-file" name="private-key-file" type="file" />
|
|
42
|
+
</label>
|
|
43
|
+
<label>
|
|
44
|
+
私钥口令
|
|
45
|
+
<input id="private-key-passphrase" name="private-key-passphrase" type="password" />
|
|
46
|
+
</label>
|
|
47
|
+
<div class="checks">
|
|
48
|
+
<label><input id="legacy-algorithms" type="checkbox" checked /> legacy 算法</label>
|
|
49
|
+
<label><input id="allow-agent" type="checkbox" /> SSH agent</label>
|
|
50
|
+
<label><input id="look-for-keys" type="checkbox" /> 服务端本机密钥</label>
|
|
51
|
+
<label><input id="strict-host-key" type="checkbox" /> known_hosts 校验</label>
|
|
52
|
+
</div>
|
|
53
|
+
<button class="primary" type="submit">连接</button>
|
|
54
|
+
</form>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
57
|
+
<section class="panel">
|
|
58
|
+
<h2>会话</h2>
|
|
59
|
+
<div class="stack">
|
|
60
|
+
<label>
|
|
61
|
+
UUID
|
|
62
|
+
<input id="session-id" autocomplete="off" spellcheck="false" />
|
|
63
|
+
</label>
|
|
64
|
+
<div class="button-row">
|
|
65
|
+
<button id="reconnect" type="button">重连</button>
|
|
66
|
+
<button id="disconnect" type="button">断开 SSH</button>
|
|
67
|
+
</div>
|
|
68
|
+
<a id="logs-link" class="quiet-link" href="#" target="_blank" rel="noreferrer">打开完整日志</a>
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
|
|
72
|
+
<section class="panel">
|
|
73
|
+
<h2>文件</h2>
|
|
74
|
+
<form id="upload-form" class="stack">
|
|
75
|
+
<label>
|
|
76
|
+
上传到远端路径
|
|
77
|
+
<input id="upload-path" placeholder="/tmp/file.txt" />
|
|
78
|
+
</label>
|
|
79
|
+
<label>
|
|
80
|
+
本地文件
|
|
81
|
+
<input id="upload-file" type="file" />
|
|
82
|
+
</label>
|
|
83
|
+
<button type="submit">上传</button>
|
|
84
|
+
</form>
|
|
85
|
+
<form id="download-form" class="stack compact">
|
|
86
|
+
<label>
|
|
87
|
+
下载远端路径
|
|
88
|
+
<input id="download-path" placeholder="/tmp/file.txt" />
|
|
89
|
+
</label>
|
|
90
|
+
<button type="submit">下载</button>
|
|
91
|
+
</form>
|
|
92
|
+
</section>
|
|
93
|
+
</aside>
|
|
94
|
+
|
|
95
|
+
<section class="workspace">
|
|
96
|
+
<div class="toolbar">
|
|
97
|
+
<div>
|
|
98
|
+
<strong id="status">未连接</strong>
|
|
99
|
+
<span id="session-label"></span>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="tabs" role="tablist">
|
|
102
|
+
<button class="tab active" data-tab="terminal" type="button">终端</button>
|
|
103
|
+
<button class="tab" data-tab="logs" type="button">日志</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
<div id="terminal-pane" class="pane active">
|
|
107
|
+
<div id="terminal"></div>
|
|
108
|
+
</div>
|
|
109
|
+
<div id="logs-pane" class="pane">
|
|
110
|
+
<pre id="logs"></pre>
|
|
111
|
+
</div>
|
|
112
|
+
</section>
|
|
113
|
+
</main>
|
|
114
|
+
|
|
115
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
116
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
117
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-serialize@0.13.0/lib/addon-serialize.min.js"></script>
|
|
118
|
+
<script src="/static/app.js"></script>
|
|
119
|
+
</body>
|
|
120
|
+
</html>
|
webssh/static/logs.html
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>py-web-ssh logs</title>
|
|
7
|
+
<link rel="stylesheet" href="/static/styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body class="logs-page">
|
|
10
|
+
<main class="log-document">
|
|
11
|
+
<header>
|
|
12
|
+
<h1>会话日志</h1>
|
|
13
|
+
<p id="log-session-id">__SESSION_ID__</p>
|
|
14
|
+
</header>
|
|
15
|
+
<pre id="full-logs">加载中...</pre>
|
|
16
|
+
</main>
|
|
17
|
+
<script>
|
|
18
|
+
const sessionId = "__SESSION_ID__";
|
|
19
|
+
const fullLogs = document.querySelector("#full-logs");
|
|
20
|
+
|
|
21
|
+
function formatEntry(entry) {
|
|
22
|
+
const details = entry.details ? `\n${entry.details}` : "";
|
|
23
|
+
return `[${entry.timestamp}] ${entry.level.toUpperCase()} ${entry.message}${details}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function refresh() {
|
|
27
|
+
const response = await fetch(`/api/sessions/${sessionId}/logs`);
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
fullLogs.textContent = await response.text();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const payload = await response.json();
|
|
33
|
+
fullLogs.textContent = [
|
|
34
|
+
JSON.stringify(payload.session, null, 2),
|
|
35
|
+
"",
|
|
36
|
+
...payload.logs.map(formatEntry),
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
refresh();
|
|
41
|
+
setInterval(refresh, 3000);
|
|
42
|
+
</script>
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|