Rework to a svelte project
This commit is contained in:
83
src/App.svelte
Normal file
83
src/App.svelte
Normal 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
1
src/app.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
45
src/lib/api.js
Normal file
45
src/lib/api.js
Normal 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();
|
||||
}
|
||||
145
src/lib/components/InstanceDetail.svelte
Normal file
145
src/lib/components/InstanceDetail.svelte
Normal 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">
|
||||
← 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">></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>
|
||||
58
src/lib/components/InstanceList.svelte
Normal file
58
src/lib/components/InstanceList.svelte
Normal 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
9
src/main.js
Normal 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
|
||||
Reference in New Issue
Block a user