Compare commits

..

5 Commits

33 changed files with 5825 additions and 3276 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,34 @@
{} {
"name": "vssm_web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}

View File

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -1,19 +1,22 @@
import { useState } from 'react' import { useState } from 'react'
import './App.css' import './App.css'
import InstancesTable from './components/InstanceTable.tsx' import InstancesTable from './components/InstanceTable.tsx'
import Settings from './components/Settings.tsx'
function App() { function App() {
const [count, setCount] = useState(0) const [settingsOpen, setSettingsOpen] = useState(false)
return ( return (
<> <>
<section id='main'> <section id='main'>
<header> <header>
<h1>VSSM - Dashboard</h1> <h1>VSSM - Dashboard</h1>
<button onClick={() => setSettingsOpen(true)}></button>
</header> </header>
<div className='contentBox'> <div className='contentBox'>
<InstancesTable /> <InstancesTable />
</div> </div>
{settingsOpen && <Settings onClose={() => setSettingsOpen(false)} />}
</section> </section>
</> </>
) )

90
vssm_web/src/api.ts Normal file
View File

@@ -0,0 +1,90 @@
import { getSetting, SettingKey } from "./settings";
type Instance = {
name: string;
version: string;
port: number;
status: string;
};
class api {
} export default api;
async function apiFetch(path: string, options: RequestInit = {}, errorContext: string): Promise<Response> {
const token = getSetting(SettingKey.Token);
const headers = {
...options.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const res = await fetch(`${getSetting(SettingKey.Address)}${path}`, { ...options, headers });
if (!res.ok) {
const message = await res.text();
throw new Error(`${errorContext}: ${message.trim()}`);
}
return res;
}
// --- GET CALLS ---
export async function fetchInstanceList(): Promise<Instance[]> {
const res = await apiFetch("/instances/list", {}, "Could not fetch instance list");
return res.json();
}
export async function fetchInstanceLogs(name: string): Promise<string[]> {
const res = await apiFetch(`/instances/logs?name=${name}`, {}, "Could not fetch instance logs");
return res.json();
}
export async function fetchInstanceConfig(name: string): Promise<string> {
const res = await apiFetch(`/instances/getConfig?name=${name}`, {}, "Could not fetch instance config");
return res.text();
}
// --- POST CALLS ---
export async function sendStartInstanceRequest(name: string) {
const res = await apiFetch(`/instances/start?name=${name}`, { method: "POST" }, "Start Instance Request failed");
return res.text();
}
export async function sendStopInstanceRequest(name: string) {
const res = await apiFetch(`/instances/stop?name=${name}`, { method: "POST" }, "Stop Instance Request failed");
return res.text();
}
export async function sendCreateInstanceRequest(name: string, version: string, port: number) {
const res = await apiFetch(`/instances/create?name=${name}&version=${version}&port=${port}`, { method: "POST" }, "Create instance request failed");
return res.text();
}
export async function sendDeleteInstanceRequest(name: string, purge: boolean) {
const res = await apiFetch(`/instances/delete?name=${name}&purge=${purge}`, { method: "POST" }, "Delete instance request failed");
return res.text();
}
export async function sendModifyInstanceRequest(name: string, newName: string, newPort: number) {
const res = await apiFetch(`/instances/modify?name=${name}&newName=${newName}&newPort=${newPort}`, { method: "POST" }, "Modify instance request failed");
return res.text();
}
export async function sendCommandRequest(name: string, payload: string) {
const res = await apiFetch(`/instances/command?name=${name}`, {
method: "POST",
body: JSON.stringify({ command: payload }),
headers: { "Content-Type": "application/json" },
}, "Send Instance Command failed");
return res.text();
}
export async function sendSetInstanceConfigRequest(name: string, config: string) {
const res = await apiFetch(`/instances/setConfig?name=${name}`, {
method: "POST",
body: config,
headers: { "Content-Type": "application/json" },
}, "Set instance config request failed");
return res.text();
}

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,50 @@
import { useState, useEffect } from "react";
import type { Instance } from "../types";
import { fetchInstanceConfig, sendSetInstanceConfigRequest } from "../api";
type Props = {
instance: Instance;
};
export default function InstanceConfig({ instance }: Props) {
const [config, setConfig] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchInstanceConfig(instance.name)
.then(setConfig)
.catch(e => setError(e.message ?? String(e)))
.finally(() => setLoading(false));
}, [instance.name]);
async function handleSave() {
setSaving(true);
setError(null);
try {
await sendSetInstanceConfigRequest(instance.name, config);
} catch (e: any) {
setError(e.message ?? String(e));
} finally {
setSaving(false);
}
}
if (loading) return <p>Loading config...</p>;
return (
<div className="configEditor">
{error && <div className="error">Error: {error}</div>}
<textarea
value={config}
onChange={e => setConfig(e.target.value)}
rows={25}
spellCheck={false}
/>
<button disabled={saving} onClick={handleSave}>
{saving ? "Saving..." : "Save"}
</button>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import type { Instance } from "../types"; import type { Instance } from "../types";
import { sendDeleteInstanceRequest, sendModifyInstanceRequest } from "../api"; import { sendDeleteInstanceRequest, sendModifyInstanceRequest } from "../api";
import InstanceConsole from "./InstanceConsole"; import InstanceConsole from "./InstanceConsole";
import InstanceConfig from "./InstanceConfig";
type Props = { type Props = {
instance: Instance; instance: Instance;
@@ -58,8 +59,8 @@ export default function InstanceDetail({ instance, onClose, onDeleted, onSaved }
<div className="tabBar"> <div className="tabBar">
<button onClick={() => setActiveTab("general")}>General</button> <button onClick={() => setActiveTab("general")}>General</button>
<button onClick={() => setActiveTab("config")}>Configuration</button>
<button onClick={() => setActiveTab("console")}>Console</button> <button onClick={() => setActiveTab("console")}>Console</button>
<button onClick={() => setActiveTab("config")}>Configuration</button>
<button onClick={() => setActiveTab("danger")}>Danger Zone</button> <button onClick={() => setActiveTab("danger")}>Danger Zone</button>
</div> </div>
@@ -83,10 +84,10 @@ export default function InstanceDetail({ instance, onClose, onDeleted, onSaved }
</div> </div>
} }
{activeTab === "config" && <p>Config (coming soon)</p>}
{activeTab === "console" && <InstanceConsole instance={instance} />} {activeTab === "console" && <InstanceConsole instance={instance} />}
{activeTab === "config" && <InstanceConfig instance={instance}/>}
{activeTab === "danger" && {activeTab === "danger" &&
<div className="dangerZone"> <div className="dangerZone">
<h3>Danger Zone</h3> <h3>Danger Zone</h3>

View File

@@ -0,0 +1,45 @@
import { useState } from "react";
import { getSetting, setSetting, SettingKey } from "../settings";
type Props = {
onClose: () => void;
};
export default function Settings({ onClose }: Props) {
const [address, setAddress] = useState(getSetting(SettingKey.Address));
const [token, setToken] = useState(getSetting(SettingKey.Token));
const [saved, setSaved] = useState(false);
function handleSave() {
setSetting(SettingKey.Address, address);
setSetting(SettingKey.Token, token);
setSaved(true);
}
return (
<div className="modalBackdrop" onClick={onClose}>
<div className="modalCard" onClick={e => e.stopPropagation()}>
<h3>Settings</h3>
<label>
API Address
<input type="text" value={address} onChange={e => { setAddress(e.target.value); setSaved(false); }} />
</label>
<label>
Auth Token
<input type="text" value={token} onChange={e => { setToken(e.target.value); setSaved(false); }} />
</label>
<p className="warningText">Stored locally in this browser only.</p>
{saved && <p>Saved.</p>}
<div className="flexboxHorizontal">
<button onClick={handleSave}>Save</button>
<button className="normalButton" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}

View File

@@ -154,6 +154,9 @@ header {
height: auto; height: auto;
padding: 5px 20px; padding: 5px 20px;
margin-bottom: 25px; margin-bottom: 25px;
display: flex;
align-items: center;
justify-content: space-between;
} }
header h1 { header h1 {
font-size: 4em; font-size: 4em;
@@ -184,6 +187,15 @@ header h1 {
min-width: 300px; min-width: 300px;
} }
.modalCard label {
display: flex;
flex-direction: column;
gap: 6px;
font-weight: 600;
font-size: 0.9em;
text-align: left;
}
/* Normal components styling */ /* Normal components styling */
button { button {
transition: transform 0.15s ease-in-out 0s; transition: transform 0.15s ease-in-out 0s;
@@ -442,4 +454,26 @@ input[type="checkbox"] {
.consoleLine i { .consoleLine i {
color: #a0a0c0; color: #a0a0c0;
font-style: italic; font-style: italic;
}
/* Config editor styles */
.configEditor {
display: flex;
flex-direction: column;
gap: 8px;
}
.configEditor textarea {
font-family: var(--mono);
font-size: 0.85em;
background-color: #1a1a1a;
color: #d4d4d4;
border-radius: 6px;
padding: 12px;
border: transparent 0px solid;
resize: vertical;
}
.configEditor button {
max-width: fit-content;
} }

27
vssm_web/src/settings.ts Normal file
View File

@@ -0,0 +1,27 @@
export const SettingKey = {
Address: "vssm_api_address",
Token: "vssm_auth_token",
} as const;
export type SettingKey = typeof SettingKey[keyof typeof SettingKey];
const SettingDefaults: Record<SettingKey, string> = {
[SettingKey.Address]: "",
[SettingKey.Token]: "",
};
export function getSetting(key: SettingKey): string {
const value = localStorage.getItem(key);
if (!value) {
return SettingDefaults[key];
}
return value;
}
export function setSetting(key: SettingKey, value: string) {
localStorage.setItem(key, value);
}
export function clearSetting(key: SettingKey) {
localStorage.removeItem(key);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
{
"name": "vssm_web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}

View File

@@ -1,89 +0,0 @@
const ADDRESS = "http://127.0.0.1:65000";
type Instance = {
name: string;
version: string;
port: number;
status: string;
};
class api {
} export default api;
export async function fetchInstanceList(): Promise<Instance[]> {
const res = await fetch(`${ADDRESS}/instances/list`);
if (!res.ok) {
const message = await res.text();
throw new Error(`Could not fetch instance list: ${message.trim()}`);
}
return res.json();
}
export async function fetchInstanceLogs(name: string): Promise<string[]> {
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) {
const message = await res.text();
throw new Error(`Start Instance Request failed: ${message.trim()}`);
}
return res.json;
}
export async function sendStopInstanceRequest(name: string) {
const res = await fetch(`${ADDRESS}/instances/stop?name=${name}`, { method: "POST" });
if (!res.ok) {
const message = await res.text();
throw new Error(`Stop Instance Request failed: ${message.trim()}`);
}
return res.json;
}
export async function sendCreateInstanceRequest(name: string, version: string, port: number) {
const res = await fetch(`${ADDRESS}/instances/create?name=${name}&version=${version}&port=${port}`, { method: "POST" });
if (!res.ok) {
const message = await res.text();
throw new Error(`Create instance request failed: ${message.trim()}`);
}
return res.json;
}
export async function sendDeleteInstanceRequest(name: string, purge: boolean) {
const res = await fetch(`${ADDRESS}/instances/delete?name=${name}&purge=${purge}`, { method: "POST" });
if (!res.ok) {
const message = await res.text();
throw new Error(`Delete instance request failed: ${message.trim()}`);
}
return res.json;
}
export async function sendModifyInstanceRequest(name: string, newName: string, newPort: number) {
const res = await fetch(`${ADDRESS}/instances/modify?name=${name}&newName=${newName}&newPort=${newPort}`, { method: "POST" });
if (!res.ok) {
const message = await res.text();
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;
}