From 2eee7c96b92329039785066b7c9aabdefd6dfd0c Mon Sep 17 00:00:00 2001 From: chris bell Date: Wed, 10 Jun 2026 21:17:12 -0500 Subject: [PATCH] Added the console tab --- README.md | 8 +-- vssm_web/vssm_web/src/api.ts | 22 ++++++ .../src/components/InstanceConsole.tsx | 68 +++++++++++++++++++ .../src/components/InstanceDetail.tsx | 3 +- vssm_web/vssm_web/src/index.css | 53 +++++++++++++++ 5 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 vssm_web/vssm_web/src/components/InstanceConsole.tsx diff --git a/README.md b/README.md index 56d3a73..6c30446 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,10 @@ WIP - [x] Server Name - [x] Port - [ ] Full instance configuration - - [ ] Console - - [ ] Fully interactive instance console - - [ ] Send commands to servers - - [ ] View logs + - [x] Console + - [x] Fully interactive instance console + - [x] Send commands to servers + - [x] View logs - [ ] Automated server backups - [ ] Access authentication diff --git a/vssm_web/vssm_web/src/api.ts b/vssm_web/vssm_web/src/api.ts index eb9d94a..24c236a 100644 --- a/vssm_web/vssm_web/src/api.ts +++ b/vssm_web/vssm_web/src/api.ts @@ -21,6 +21,15 @@ export async function fetchInstanceList(): Promise { return res.json(); } +export async function fetchInstanceLogs(name: string): Promise { + const res = await fetch(`${ADDRESS}/instances/logs?name=${name}`); + if (!res.ok) { + const message = await res.text(); + throw new Error(`Could not fetch instance logs: ${message.trim()}`); + } + return res.json(); +} + export async function sendStartInstanceRequest(name: string) { const res = await fetch(`${ADDRESS}/instances/start?name=${name}`, { method: "POST" }); if (!res.ok) { @@ -64,4 +73,17 @@ export async function sendModifyInstanceRequest(name: string, newName: string, n throw new Error(`Modify instance request failed: ${message.trim()}`); } return res.json; +} + +export async function sendCommandRequest(name: string, payload: string) { + const res = await fetch(`${ADDRESS}/instances/command?name=${name}`, { + method: "POST", + body: JSON.stringify({ command: payload }), + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + const message = await res.text(); + throw new Error(`Send Instance Command failed: ${message.trim()}`); + } + return res.json; } \ No newline at end of file diff --git a/vssm_web/vssm_web/src/components/InstanceConsole.tsx b/vssm_web/vssm_web/src/components/InstanceConsole.tsx new file mode 100644 index 0000000..400c0ba --- /dev/null +++ b/vssm_web/vssm_web/src/components/InstanceConsole.tsx @@ -0,0 +1,68 @@ +import type { Instance } from "../types"; +import { useEffect, useRef, useState } from "react"; +import { fetchInstanceLogs, sendCommandRequest } from "../api"; + +type Props = { + instance: Instance; +}; + +export default function InstanceConsole({ instance }: Props) { + const [logs, setLogs] = useState([]); + const [command, setCommand] = useState(""); + const [autoScroll, setAutoScroll] = useState(true); + const bottomRef = useRef(null); + + useEffect(() => { + async function poll() { + try { + const lines = await fetchInstanceLogs(instance.name); + setLogs(lines); + } catch (e) { + console.error(e); + } + } + + poll(); + const interval = setInterval(poll, 2000); + return () => clearInterval(interval); + }, [instance.name]); + + // useEffect(() => { + // bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + // }, [logs]); + + useEffect(() => { + if (autoScroll) { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [logs, autoScroll]); + + async function handleSendCommand() { + if (!command.trim()) return; + try { + await sendCommandRequest(instance.name, command); + setCommand(""); + } catch (e: any) { + console.error(e); + } + } + + return ( +
+
+ {logs.map((line, i) =>

)} +

+
+
+ setCommand(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") handleSendCommand(); }} + placeholder="Enter command..." /> + + +
+
+ ); +} \ No newline at end of file diff --git a/vssm_web/vssm_web/src/components/InstanceDetail.tsx b/vssm_web/vssm_web/src/components/InstanceDetail.tsx index a6e8392..0632bbc 100644 --- a/vssm_web/vssm_web/src/components/InstanceDetail.tsx +++ b/vssm_web/vssm_web/src/components/InstanceDetail.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import type { Instance } from "../types"; import { sendDeleteInstanceRequest, sendModifyInstanceRequest } from "../api"; +import InstanceConsole from "./InstanceConsole"; type Props = { instance: Instance; @@ -84,7 +85,7 @@ export default function InstanceDetail({ instance, onClose, onDeleted, onSaved } {activeTab === "config" &&

Config (coming soon)

} - {activeTab === "console" &&

Console (coming soon)

} + {activeTab === "console" && } {activeTab === "danger" &&
diff --git a/vssm_web/vssm_web/src/index.css b/vssm_web/vssm_web/src/index.css index 4f5fb79..c6d9555 100644 --- a/vssm_web/vssm_web/src/index.css +++ b/vssm_web/vssm_web/src/index.css @@ -389,4 +389,57 @@ input[type="checkbox"] { .deleteButton:hover { background-color: #cc0000; +} + +/* CONSOLE */ +.consoleWrapper { + display: flex; + flex-direction: column; + gap: 8px; +} + +.consoleOutput { + background-color: #1a1a1a; + border-radius: 6px; + padding: 12px; + height: 400px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.consoleLine { + font-family: var(--mono); + font-size: 0.85em; + color: #d4d4d4; + text-align: left; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-all; +} + +.consoleInput input { + flex: 1; +} + +.autoScrollLabel { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85em; + color: var(--text); +} + +.consoleLine code { + background-color: #2d2d2d; + color: #91a357; + padding: 1px 5px; + border-radius: 3px; + font-family: var(--mono); + font-size: 0.95em; +} + +.consoleLine i { + color: #a0a0c0; + font-style: italic; } \ No newline at end of file -- 2.52.0