Rework to a svelte project

This commit is contained in:
2026-06-05 23:04:28 -05:00
parent 1d105a3bfa
commit 8d211da476
20 changed files with 1535 additions and 123 deletions

83
src/App.svelte Normal file
View File

@@ -0,0 +1,83 @@
<script>
import { onMount } from 'svelte';
import { fetchInstances, toggleInstanceStatus } from './lib/api';
import InstanceList from './lib/components/InstanceList.svelte';
import InstanceDetail from './lib/components/InstanceDetail.svelte';
let instances = [];
let connectionError = null;
let processingTargets = {};
// Selection router state tracker
let activeFocusedInstance = null;
async function syncTelemetry() {
try {
instances = await fetchInstances();
connectionError = null;
// Keep our detailed view object synced with fresh status polling adjustments
if (activeFocusedInstance) {
const freshSnapshot = instances.find(i => i.name === activeFocusedInstance.name);
if (freshSnapshot) activeFocusedInstance = freshSnapshot;
}
} catch (err) {
console.error(err);
connectionError = "Unable to connect to VSSM Daemon.";
}
}
async function handlePowerToggle(name, currentStatus) {
processingTargets[name] = true;
try {
await toggleInstanceStatus(name, currentStatus);
await syncTelemetry();
} catch (err) {
alert(`Operation Failed: ${err.message}`);
} finally {
processingTargets[name] = false;
processingTargets = processingTargets;
}
}
onMount(() => {
syncTelemetry();
const interval = setInterval(syncTelemetry, 4000);
return () => clearInterval(interval);
});
</script>
<main class="min-h-screen bg-neutral-950 text-neutral-100 font-mono p-6 select-none">
<header class="border-b border-neutral-800 pb-4 mb-6 flex justify-between items-center">
<div>
<h1 class="text-lg font-bold tracking-tight text-amber-500">-- VSSM DASHBOARD --</h1>
<p class="text-xs text-neutral-400 mt-1">VintageStory Server Manager</p>
</div>
{#if !activeFocusedInstance}
<button
on:click={syncTelemetry}
class="border border-neutral-700 hover:border-neutral-500 hover:text-amber-400 px-3 py-1.5 text-xs uppercase tracking-wider transition-colors cursor-pointer">
Force Sync
</button>
{/if}
</header>
{#if connectionError}
<div class="bg-red-950/40 border border-red-900/60 text-red-400 p-3 text-xs mb-6 rounded flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span>
{connectionError}
</div>
{/if}
{#if activeFocusedInstance}
<InstanceDetail
instance={activeFocusedInstance}
onBack={() => activeFocusedInstance = null} />
{:else}
<InstanceList
{instances}
{processingTargets}
onPowerToggle={handlePowerToggle}
onSelectInstance={(target) => activeFocusedInstance = target} />
{/if}
</main>

1
src/app.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

45
src/lib/api.js Normal file
View File

@@ -0,0 +1,45 @@
const DAEMON_URL = 'http://127.0.0.1:12345';
export async function fetchInstances() {
const res = await fetch(`${DAEMON_URL}/instances/list`);
if (!res.ok) {
throw new Error(`Daemon returned status code: ${res.status}`);
}
return await res.json();
}
export async function toggleInstanceStatus(name, currentStatus) {
const action = currentStatus === 'RUNNING' ? 'stop' : 'start';
const res = await fetch(`${DAEMON_URL}/instances/${action}?name=${encodeURIComponent(name)}`, {
method: 'POST'
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(errorText || `Action '${action}' failed`);
}
return true;
}
export async function sendServerCommand(name, commandString) {
const res = await fetch(`${DAEMON_URL}/instances/command?name=${encodeURIComponent(name)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: commandString })
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(errorText || "Failed to route command to target instance");
}
return await res.text();
}
export async function fetchServerLogs(name) {
const res = await fetch(`${DAEMON_URL}/instances/logs?name=${encodeURIComponent(name)}`);
if (!res.ok) {
throw new Error("Failed to sync engine logs");
}
return await res.json();
}

View File

@@ -0,0 +1,145 @@
<script>
import { onMount } from 'svelte';
import { sendServerCommand, fetchServerLogs } from '../api';
export let instance;
export let onBack;
let textCommandBuffer = '';
let terminalLogs = [];
let isSending = false;
let terminalContainer;
function logsAreEqual(arr1, arr2) {
if (arr1.length !== arr2.length) return false;
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) return false;
}
return true;
}
async function syncLogs() {
try {
const logs = await fetchServerLogs(instance.name);
if (!logsAreEqual(logs, terminalLogs)) {
terminalLogs = logs;
autoScrollToBottom();
}
} catch (err) {
console.error("Failed to get logs:", err);
}
}
function autoScrollToBottom() {
if (terminalContainer) {
setTimeout(() => {
terminalContainer.scrollTop = terminalContainer.scrollHeight;
}, 0);
}
}
async function executeConsoleSubmit() {
if (!textCommandBuffer.trim() || isSending) return;
const commandToDispatch = textCommandBuffer.trim();
textCommandBuffer = '';
isSending = true;
try {
await sendServerCommand(instance.name, commandToDispatch);
await syncLogs();
} catch (err) {
terminalLogs = [...terminalLogs, `[ERROR]: ${err.message}`];
autoScrollToBottom();
} finally {
isSending = false;
}
}
onMount(() => {
syncLogs();
// Check for new logs every second
const logInterval = setInterval(syncLogs, 1000);
return () => clearInterval(logInterval);
});
</script>
<div class="space-y-6">
<div class="flex items-center gap-4">
<button
on:click={onBack}
class="border border-neutral-700 hover:border-neutral-500 text-neutral-300 px-3 py-1 text-xs uppercase transition-colors cursor-pointer">
&larr; Back to Server List
</button>
<div class="h-4 w-px bg-neutral-800"></div>
<div class="text-sm">
Server: <span class="text-amber-500 font-bold">{instance.name}</span>
<span class="text-xs text-neutral-500">({instance.version})</span>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 flex flex-col h-[450px] border border-neutral-800 bg-neutral-900 rounded overflow-hidden">
<div class="bg-neutral-950 border-b border-neutral-800 px-4 py-2 text-xs flex justify-between items-center select-none">
<span class="text-neutral-400 font-bold">Console Output</span>
<span class="text-[10px] {instance.status === 'RUNNING' ? 'text-emerald-400' : 'text-neutral-500'}">
{instance.status}
</span>
</div>
<div
bind:this={terminalContainer}
class="flex-1 p-4 overflow-y-auto text-xs space-y-1 text-neutral-300 font-mono select-text selection:bg-neutral-800">
{#if terminalLogs.length === 0}
<div class="text-neutral-600 italic">[No console logs available.]</div>
{:else}
{#each terminalLogs as line}
<div class="whitespace-pre-wrap break-all leading-relaxed">{line}</div>
{/each}
{/if}
</div>
<form
on:submit|preventDefault={executeConsoleSubmit}
class="border-t border-neutral-800 bg-neutral-950 flex items-center">
<span class="pl-4 text-neutral-600 text-xs select-none">&gt;</span>
<input
type="text"
bind:value={textCommandBuffer}
disabled={instance.status !== 'RUNNING' || isSending}
placeholder={instance.status === 'RUNNING' ? "Type a server command..." : "Server is offline."}
class="w-full bg-transparent px-2 py-3 text-xs text-neutral-200 placeholder-neutral-600 focus:outline-none disabled:cursor-not-allowed" />
</form>
</div>
<div class="border border-neutral-800 bg-neutral-900/10 rounded p-4 space-y-4">
<h3 class="text-xs font-bold uppercase tracking-wider text-neutral-400 border-b border-neutral-800 pb-2">
Server Operations
</h3>
<div class="opacity-50 space-y-3 pointer-events-none">
<button class="w-full text-left border border-neutral-800 bg-neutral-900/50 text-xs p-3 hover:border-neutral-700 transition-all rounded">
<div class="font-bold text-neutral-200">Configure</div>
<div class="text-[10px] text-neutral-400 mt-0.5">Edit instance configuration.</div>
</button>
<button class="w-full text-left border border-neutral-800 bg-neutral-900/50 text-xs p-3 hover:border-neutral-700 transition-all rounded">
<div class="font-bold text-neutral-200">Trigger Backup</div>
<div class="text-[10px] text-neutral-400 mt-0.5">Create a backup of the instance.</div>
</button>
<button class="w-full text-left border border-red-950 bg-red-950/10 text-xs p-3 rounded">
<div class="font-bold text-red-400">Delete Instance</div>
<div class="text-[10px] text-red-500/70 mt-0.5">Remove server instance and optionally delete its files.</div>
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,58 @@
<script>
// Props received from parent context
export let instances = [];
export let processingTargets = {};
export let onPowerToggle;
export let onSelectInstance;
</script>
<div class="border border-neutral-800 bg-neutral-900/20 rounded overflow-hidden">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-neutral-800 bg-neutral-900/80 text-xs text-neutral-400 uppercase">
<th class="p-4 font-medium">Instance ID</th>
<th class="p-4 font-medium">Engine Version</th>
<th class="p-4 font-medium">Network Port</th>
<th class="p-4 font-medium">Runtime Status</th>
<th class="p-4 font-medium text-right">Quick Controls</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800/40 text-sm">
{#if instances.length === 0}
<tr>
<td colspan="5" class="p-8 text-center text-neutral-500 text-xs">
No managed target nodes registered with daemon configuration profile.
</td>
</tr>
{:else}
{#each instances as inst}
<tr
on:click={() => onSelectInstance(inst)}
class="hover:bg-neutral-900/40 transition-colors cursor-pointer">
<td class="p-4 font-bold tracking-wide text-amber-500 hover:underline">{inst.name}</td>
<td class="p-4 text-neutral-400">{inst.version}</td>
<td class="p-4 text-neutral-400 font-mono text-xs">{inst.port}</td>
<td class="p-4">
<span class="inline-flex items-center gap-1.5 text-xs font-semibold px-2 py-0.5 rounded-sm
{inst.status === 'RUNNING' ? 'bg-emerald-950/40 text-emerald-400 border border-emerald-900/30' : 'bg-neutral-800 text-neutral-400'}">
<span class="w-1.5 h-1.5 rounded-full {inst.status === 'RUNNING' ? 'bg-emerald-400 animate-pulse' : 'bg-neutral-500'}"></span>
{inst.status}
</span>
</td>
<td class="p-4 text-right" on:click|stopPropagation>
<button
disabled={processingTargets[inst.name]}
on:click={() => onPowerToggle(inst.name, inst.status)}
class="w-24 text-center border text-xs uppercase font-bold py-1 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed
{inst.status === 'RUNNING'
? 'border-red-900/60 bg-red-950/20 text-red-400 hover:bg-red-900/40'
: 'border-emerald-900/60 bg-emerald-950/20 text-emerald-400 hover:bg-emerald-900/40'}">
{processingTargets[inst.name] ? 'PENDING...' : inst.status === 'RUNNING' ? 'STOP' : 'START'}
</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>

9
src/main.js Normal file
View File

@@ -0,0 +1,9 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app'),
})
export default app