Refresh host UI and show app version

- Add version display to the host shell
- Improve log rendering and refresh timing
- Tidy layout, styling, and window config
This commit is contained in:
Jacob Schmidt 2026-06-06 19:35:45 -05:00
parent 9610e3512d
commit c3531a5839
6 changed files with 1203 additions and 746 deletions

1
.gitignore vendored
View File

@ -27,6 +27,7 @@ docus/.nuxt/
docus/.output/
docus/.data/
docus/.nitro/
bin/launcher/
# OS
.DS_Store

View File

@ -1,26 +1,27 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "FORGE Host",
"version": "0.1.0",
"identifier": "com.idsolutions.forge.host",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "FORGE Host",
"width": 1280,
"height": 720,
"minWidth": 980,
"minHeight": 640
}
],
"security": {
"csp": null
}
},
"$schema": "https://schema.tauri.app/config/2",
"productName": "FORGE Host",
"version": "0.1.0",
"identifier": "com.idsolutions.forge.host",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "FORGE Host",
"width": 1280,
"height": 720,
"minWidth": 980,
"minHeight": 640,
"maximizable": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",

View File

@ -6,12 +6,14 @@ const serviceNames = {
const subtitles = {
overview: "Start, stop, and check local Forge hosting services.",
settings: "Edit process command, arguments, working directory, and health port.",
settings:
"Edit process command, arguments, working directory, and health port.",
logs: "Recent stdout, stderr, and host supervisor events.",
};
let snapshot = null;
let refreshTimer = null;
let lastRenderedLogText = "";
let activeSettingsService = "surrealdb";
let configDirty = false;
let activeEditorPath = "";
@ -28,7 +30,10 @@ document.querySelectorAll(".nav-button").forEach((button) => {
button.addEventListener("click", () => {
const view = button.dataset.view;
if (button.dataset.service) {
if (configDirty && button.dataset.service !== activeSettingsService) {
if (
configDirty &&
button.dataset.service !== activeSettingsService
) {
persistActiveSettingsForm();
}
activeSettingsService = button.dataset.service;
@ -38,9 +43,13 @@ document.querySelectorAll(".nav-button").forEach((button) => {
}
activeSettingsService = "surrealdb";
}
document.querySelectorAll(".nav-button").forEach((item) => item.classList.remove("active"));
document
.querySelectorAll(".nav-button")
.forEach((item) => item.classList.remove("active"));
button.classList.add("active");
Object.entries(views).forEach(([key, element]) => element.classList.toggle("active", key === view));
Object.entries(views).forEach(([key, element]) =>
element.classList.toggle("active", key === view),
);
setHeader(view);
renderSettings(true);
syncBulkActionsVisibility();
@ -52,9 +61,29 @@ document.getElementById("refreshButton").addEventListener("click", refresh);
document.getElementById("saveButton").addEventListener("click", saveConfig);
document.getElementById("startAllButton").addEventListener("click", startAll);
document.getElementById("stopAllButton").addEventListener("click", stopAll);
document.getElementById("editorCloseButton").addEventListener("click", closeConfigEditor);
document.getElementById("editorReloadButton").addEventListener("click", () => runAction(() => openConfigEditor(activeEditorPath)));
document.getElementById("editorSaveButton").addEventListener("click", () => runAction(saveConfigEditor));
document
.getElementById("editorCloseButton")
.addEventListener("click", closeConfigEditor);
document
.getElementById("editorReloadButton")
.addEventListener("click", () =>
runAction(() => openConfigEditor(activeEditorPath)),
);
document
.getElementById("editorSaveButton")
.addEventListener("click", () => runAction(saveConfigEditor));
async function loadAppVersion() {
const getVersion = window.__TAURI__?.app?.getVersion;
if (!getVersion) return;
try {
document.getElementById("appVersion").textContent =
`v${await getVersion()}`;
} catch (error) {
console.warn("Unable to load app version:", error);
}
}
async function refresh() {
if (isSettingsViewActive() && configDirty) {
@ -89,7 +118,9 @@ async function startAll() {
const serviceNames = Object.keys(snapshot.config).filter(
(name) =>
snapshot.config[name].enabled && (name === "surrealdb" || snapshot.config[name].command.trim() !== "")
snapshot.config[name].enabled &&
(name === "surrealdb" ||
snapshot.config[name].command.trim() !== ""),
);
for (const name of serviceNames) {
@ -106,7 +137,9 @@ async function startAll() {
async function stopAll() {
if (!snapshot) return;
const serviceNames = Object.keys(snapshot.config).filter((name) => snapshot.config[name].enabled);
const serviceNames = Object.keys(snapshot.config).filter(
(name) => snapshot.config[name].enabled,
);
for (const name of serviceNames) {
const status = snapshot.statuses.find((s) => s.name === name);
if (status && status.running) {
@ -141,7 +174,9 @@ async function saveConfig() {
function tauriInvoke(command, payload) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
return Promise.reject("Tauri invoke API is not available in this window.");
return Promise.reject(
"Tauri invoke API is not available in this window.",
);
}
return invoke(command, payload);
}
@ -157,32 +192,43 @@ function render() {
}
function setHeader(view) {
const title = view === "settings" ? serviceNames[activeSettingsService] : activeNavLabel();
const title =
view === "settings"
? serviceNames[activeSettingsService]
: activeNavLabel();
document.getElementById("viewTitle").textContent = title;
document.getElementById("viewSubtitle").textContent = subtitles[view];
}
function activeNavLabel() {
return document.querySelector(".nav-button.active span:last-child")?.textContent.trim() || "Overview";
return (
document
.querySelector(".nav-button.active span:last-child")
?.textContent.trim() || "Overview"
);
}
function syncSidebarStatus() {
document.querySelectorAll(".nav-button[data-service], .nav-button[data-view='settings']:not([data-service])").forEach((button) => {
const service = button.dataset.service || "surrealdb";
const config = snapshot.config[service];
const status = snapshot.statuses.find((s) => s.name === service);
const enabled = config?.enabled;
const running = status?.running;
const healthy = status?.healthy;
document
.querySelectorAll(
".nav-button[data-service], .nav-button[data-view='settings']:not([data-service])",
)
.forEach((button) => {
const service = button.dataset.service || "surrealdb";
const config = snapshot.config[service];
const status = snapshot.statuses.find((s) => s.name === service);
const enabled = config?.enabled;
const running = status?.running;
const healthy = status?.healthy;
const isDisabled = !enabled;
const isOnline = enabled && running && healthy;
const isStopped = enabled && !isOnline;
const isDisabled = !enabled;
const isOnline = enabled && running && healthy;
const isStopped = enabled && !isOnline;
button.classList.toggle("service-enabled", Boolean(isOnline));
button.classList.toggle("service-disabled", Boolean(isDisabled));
button.classList.toggle("service-stopped", Boolean(isStopped));
});
button.classList.toggle("service-enabled", Boolean(isOnline));
button.classList.toggle("service-disabled", Boolean(isDisabled));
button.classList.toggle("service-stopped", Boolean(isStopped));
});
}
function renderServices() {
@ -192,31 +238,44 @@ function renderServices() {
snapshot.statuses.forEach((status) => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector("h2").textContent = serviceNames[status.name] || status.name;
node.querySelector("h2").textContent =
serviceNames[status.name] || status.name;
const pingPill = node.querySelector(".ping-pill");
const pingDisplay = resolvePingDisplay(status);
pingPill.textContent = pingDisplay.label;
pingPill.classList.remove("good", "warn", "bad", "unavailable");
pingPill.classList.add(pingDisplay.className);
node.querySelector(".command-line").textContent = status.command || "No command configured";
node.querySelector(".command-line").textContent =
status.command || "No command configured";
const pill = node.querySelector(".status-pill");
pill.textContent = status.running ? "Running" : "Stopped";
pill.classList.add(status.running ? "running" : "stopped");
node.querySelector(".pid").textContent = status.pid ? String(status.pid) : "-";
node.querySelector(".pid").textContent = status.pid
? String(status.pid)
: "-";
node.querySelector(".health").textContent = status.health;
node.querySelector(".enabled").textContent = status.enabled ? "Yes" : "No";
node.querySelector(".enabled").textContent = status.enabled
? "Yes"
: "No";
const start = node.querySelector(".start");
const stop = node.querySelector(".stop");
if (!start || !stop) {
throw new Error("Service card template is missing start or stop controls.");
throw new Error(
"Service card template is missing start or stop controls.",
);
}
start.disabled = status.running || !status.enabled || !status.configured;
start.disabled =
status.running || !status.enabled || !status.configured;
stop.disabled = !status.running;
start.addEventListener("click", () => runAction(() => startService(status.name)));
stop.addEventListener("click", () => runAction(() => stopService(status.name)));
start.addEventListener("click", () =>
runAction(() => startService(status.name)),
);
stop.addEventListener("click", () =>
runAction(() => stopService(status.name)),
);
grid.appendChild(node);
});
@ -256,7 +315,9 @@ function renderSettings(force = false) {
form.className = "settings-form";
form.innerHTML = renderSettingsPanel(name, config);
form.querySelectorAll("input[data-service], input[data-arg-key], select[data-arg-key]").forEach((input) => {
form.querySelectorAll(
"input[data-service], input[data-arg-key], select[data-arg-key]",
).forEach((input) => {
input.addEventListener("input", () => {
configDirty = true;
});
@ -267,20 +328,30 @@ function renderSettings(force = false) {
form.querySelectorAll("[data-picker]").forEach((button) => {
button.addEventListener("click", () => {
runAction(() => pickPath(button.dataset.picker, button.dataset.target, button.dataset.serviceTarget));
runAction(() =>
pickPath(
button.dataset.picker,
button.dataset.target,
button.dataset.serviceTarget,
),
);
});
});
form.querySelectorAll("[data-create-server-config]").forEach((button) => {
button.addEventListener("click", () => {
const template = document.querySelector("[data-config-template]")?.value || "server";
const template =
document.querySelector("[data-config-template]")?.value ||
"server";
runAction(() => createArmaServerConfig(template));
});
});
form.querySelectorAll("[data-edit-server-config]").forEach((button) => {
button.addEventListener("click", () => {
const path = document.querySelector('[data-arg-key="config"]')?.value.trim();
const path = document
.querySelector('[data-arg-key="config"]')
?.value.trim();
if (!path) {
alert("Select or create a server config first.");
return;
@ -310,7 +381,11 @@ function renderSettings(force = false) {
if (name === "surrealdb" && !surrealInstallInfo) {
loadSurrealInstallInfo();
} else if (name === "surrealdb" && surrealInstallInfo && !surrealInstallInfo.latest) {
} else if (
name === "surrealdb" &&
surrealInstallInfo &&
!surrealInstallInfo.latest
) {
loadLatestSurrealVersion();
}
}
@ -319,6 +394,10 @@ function isSettingsViewActive() {
return views.settings.classList.contains("active");
}
function isLogsViewActive() {
return views.logs.classList.contains("active");
}
function syncRefreshTimer() {
if (refreshTimer) {
clearInterval(refreshTimer);
@ -326,22 +405,32 @@ function syncRefreshTimer() {
}
if (!isSettingsViewActive()) {
refreshTimer = setInterval(refresh, 2500);
refreshTimer = setInterval(refresh, isLogsViewActive() ? 30000 : 2500);
}
}
function syncBulkActionsVisibility() {
const bulkActions = document.getElementById("bulkActions");
bulkActions.style.display = views.overview.classList.contains("active") ? "flex" : "none";
bulkActions.style.display = views.overview.classList.contains("active")
? "flex"
: "none";
const stopAllButton = document.getElementById("stopAllButton");
const hasRunningService = Boolean(snapshot?.statuses.some((status) => status.running));
const hasRunningService = Boolean(
snapshot?.statuses.some((status) => status.running),
);
stopAllButton.disabled = !hasRunningService;
}
function renderLogs() {
const output = document.getElementById("logOutput");
output.textContent = snapshot.logs.length ? snapshot.logs.join("\n") : "No host logs yet.";
const nextLogText = snapshot.logs.length
? snapshot.logs.join("\n")
: "No host logs yet.";
if (nextLogText === lastRenderedLogText) return;
lastRenderedLogText = nextLogText;
output.textContent = nextLogText;
output.scrollTop = output.scrollHeight;
}
@ -350,6 +439,7 @@ function renderError(error) {
document.getElementById("configPath").textContent = "Unable to load";
document.getElementById("serviceGrid").replaceChildren();
document.getElementById("configForm").replaceChildren();
lastRenderedLogText = message;
document.getElementById("logOutput").textContent = message;
}
@ -418,7 +508,9 @@ function renderSurrealInstallControls() {
const installed = surrealInstallInfo?.installed;
const version = surrealInstallInfo?.version || "Not installed";
const latest = surrealInstallInfo?.latest || "Checking...";
const path = surrealInstallInfo?.path || "SurrealDB will be installed to the user-local default path.";
const path =
surrealInstallInfo?.path ||
"SurrealDB will be installed to the user-local default path.";
return `
<div class="field full surreal-install">
@ -455,17 +547,23 @@ function renderSurrealInstallControls() {
}
function selectedSurrealInstallVersion() {
const mode = document.querySelector("[data-surreal-version-mode]")?.value || "3";
const mode =
document.querySelector("[data-surreal-version-mode]")?.value || "3";
if (mode !== "custom") {
return mode;
}
return document.querySelector("[data-surreal-custom-version]")?.value.trim() || "3";
return (
document.querySelector("[data-surreal-custom-version]")?.value.trim() ||
"3"
);
}
function syncSurrealCustomVersion() {
const custom = document.querySelector(".install-custom");
if (!custom) return;
const isCustom = document.querySelector("[data-surreal-version-mode]")?.value === "custom";
const isCustom =
document.querySelector("[data-surreal-version-mode]")?.value ===
"custom";
custom.hidden = !isCustom;
if (isCustom) {
custom.querySelector("input")?.focus();
@ -475,7 +573,12 @@ function syncSurrealCustomVersion() {
async function loadSurrealInstallInfo() {
try {
surrealInstallInfo = await tauriInvoke("get_surrealdb_install_info");
if (activeSettingsService === "surrealdb" && isSettingsViewActive() && !configDirty && !isSurrealInstallFocused()) {
if (
activeSettingsService === "surrealdb" &&
isSettingsViewActive() &&
!configDirty &&
!isSurrealInstallFocused()
) {
renderSettings(true);
}
await loadLatestSurrealVersion();
@ -493,12 +596,25 @@ async function loadLatestSurrealVersion() {
try {
const latest = await tauriInvoke("get_latest_surrealdb_version");
surrealInstallInfo = { ...(surrealInstallInfo || {}), latest };
if (activeSettingsService === "surrealdb" && isSettingsViewActive() && !configDirty && !isSurrealInstallFocused()) {
if (
activeSettingsService === "surrealdb" &&
isSettingsViewActive() &&
!configDirty &&
!isSurrealInstallFocused()
) {
renderSettings(true);
}
} catch (error) {
surrealInstallInfo = { ...(surrealInstallInfo || {}), latest: "Unavailable" };
if (activeSettingsService === "surrealdb" && isSettingsViewActive() && !configDirty && !isSurrealInstallFocused()) {
surrealInstallInfo = {
...(surrealInstallInfo || {}),
latest: "Unavailable",
};
if (
activeSettingsService === "surrealdb" &&
isSettingsViewActive() &&
!configDirty &&
!isSurrealInstallFocused()
) {
renderSettings(true);
}
}
@ -685,8 +801,10 @@ function toggleArgument(label, key, value) {
}
function readArgumentFields(service, container) {
const value = (key) => container.querySelector(`[data-arg-key="${key}"]`)?.value.trim() || "";
const checked = (key) => Boolean(container.querySelector(`[data-arg-key="${key}"]`)?.checked);
const value = (key) =>
container.querySelector(`[data-arg-key="${key}"]`)?.value.trim() || "";
const checked = (key) =>
Boolean(container.querySelector(`[data-arg-key="${key}"]`)?.checked);
if (service === "surrealdb") {
const args = [];
if (value("subcommand")) args.push(value("subcommand"));
@ -707,7 +825,8 @@ function readArgumentFields(service, container) {
if (value("mods")) args.push(`-mod=${value("mods")}`);
if (value("serverMods")) args.push(`-serverMod=${value("serverMods")}`);
checked("autoInit") && args.push("-autoInit");
checked("reportNonNetworkObject") && args.push("-reportNonNetworkObject");
checked("reportNonNetworkObject") &&
args.push("-reportNonNetworkObject");
args.push(checked("battleye") ? "-battleye" : "-noBattlEye");
args.push(...splitMiscArgs(value("misc")));
return args;
@ -796,7 +915,10 @@ function parseArmaArgs(args) {
}
function splitMiscArgs(value) {
return value.split(/\s+/).map((arg) => arg.trim()).filter(Boolean);
return value
.split(/\s+/)
.map((arg) => arg.trim())
.filter(Boolean);
}
async function pickPath(kind, targetSelector, service) {
@ -818,7 +940,9 @@ async function pickPath(kind, targetSelector, service) {
input.value = finalPath;
if (kind === "file" && targetSelector.includes('data-field="command"')) {
const workingDir = document.querySelector(`[data-service="${service}"][data-field="working_dir"]`);
const workingDir = document.querySelector(
`[data-service="${service}"][data-field="working_dir"]`,
);
if (workingDir) {
workingDir.value = finalPath.replace(/[\\/][^\\/]+$/, "");
}
@ -845,10 +969,14 @@ function pickerFilters(kind) {
async function createArmaServerConfig(template) {
const save = window.__TAURI__?.dialog?.save;
if (!save) {
throw new Error("Tauri save dialog API is not available in this window.");
throw new Error(
"Tauri save dialog API is not available in this window.",
);
}
const workingDir = document.querySelector('[data-service="arma"][data-field="working_dir"]')?.value.trim();
const workingDir = document
.querySelector('[data-service="arma"][data-field="working_dir"]')
?.value.trim();
const defaultPath = workingDir ? `${workingDir}\\server.cfg` : "server.cfg";
const selected = await save({
defaultPath,
@ -856,7 +984,10 @@ async function createArmaServerConfig(template) {
});
if (!selected) return;
await tauriInvoke("create_arma_server_config", { path: selected, template });
await tauriInvoke("create_arma_server_config", {
path: selected,
template,
});
const input = document.querySelector('[data-arg-key="config"]');
if (input) {
input.value = selected;
@ -871,7 +1002,9 @@ function resolveArmaConfigPath(path) {
return path;
}
const workingDir = document.querySelector('[data-service="arma"][data-field="working_dir"]')?.value.trim();
const workingDir = document
.querySelector('[data-service="arma"][data-field="working_dir"]')
?.value.trim();
return workingDir ? `${workingDir}\\${path}` : path;
}
@ -906,11 +1039,15 @@ function normalizePickedExecutable(service, path) {
}
if (fileName === "arma3_x64.exe" || fileName === "arma3.exe") {
alert("That is the Arma 3 client executable. Forge Host will use arma3server_x64.exe from the same directory instead.");
alert(
"That is the Arma 3 client executable. Forge Host will use arma3server_x64.exe from the same directory instead.",
);
return path.replace(/[\\/][^\\/]+$/, "\\arma3server_x64.exe");
}
alert("Select arma3server_x64.exe for dedicated server hosting. The app will block client executables at launch.");
alert(
"Select arma3server_x64.exe for dedicated server hosting. The app will block client executables at launch.",
);
return path;
}
@ -944,3 +1081,4 @@ function selectedAttr(selected) {
refresh();
syncRefreshTimer();
loadAppVersion();

View File

@ -1,145 +1,225 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FORGE Host</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FORGE Host</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="shell">
<aside class="rail">
<nav class="nav">
<button
class="nav-button active"
type="button"
data-view="overview"
title="Overview"
>
<span class="svg-icon icon-dashboard"></span>
<span>Overview</span>
</button>
<button
class="nav-button"
type="button"
data-view="settings"
title="Settings"
>
<span class="svg-icon icon-database"></span>
<span>SurrealDB</span>
</button>
<button
class="nav-button"
type="button"
data-view="settings"
data-service="icom"
title="ICOM"
>
<span class="svg-icon icon-icom"></span>
<span>ICOM</span>
</button>
<button
class="nav-button"
type="button"
data-view="settings"
data-service="arma"
title="Arma 3 Server"
>
<span class="svg-icon icon-server"></span>
<span>Arma Server</span>
</button>
<button
class="nav-button"
type="button"
data-view="logs"
title="Logs"
>
<span class="svg-icon icon-logs"></span>
<span>Logs</span>
</button>
</nav>
</aside>
<body>
<main class="shell">
<aside class="rail">
<nav class="nav">
<button class="nav-button active" type="button" data-view="overview" title="Overview">
<span class="svg-icon icon-dashboard"></span>
<span>Overview</span>
</button>
<button class="nav-button" type="button" data-view="settings" title="Settings">
<span class="svg-icon icon-database"></span>
<span>SurrealDB</span>
</button>
<button class="nav-button" type="button" data-view="settings" data-service="icom" title="ICOM">
<span class="svg-icon icon-icom"></span>
<span>ICOM</span>
</button>
<button class="nav-button" type="button" data-view="settings" data-service="arma" title="Arma 3 Server">
<span class="svg-icon icon-server"></span>
<span>Arma Server</span>
</button>
<button class="nav-button" type="button" data-view="logs" title="Logs">
<span class="svg-icon icon-logs"></span>
<span>Logs</span>
</button>
</nav>
<div class="config-path">
<span>Config</span>
<strong id="configPath">Loading</strong>
</div>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<h1 id="viewTitle">Overview</h1>
<p id="viewSubtitle">Local service control for Forge server hosting.</p>
</div>
<div class="top-actions">
<div id="bulkActions" class="bulk-actions" style="display: none;">
<button id="startAllButton" class="start icon-button" type="button" aria-label="Start All"
title="Start all services">
<span class="svg-icon icon-play"></span>
<section class="workspace">
<header class="topbar">
<div class="view-heading">
<h1 id="viewTitle">Overview</h1>
<p id="viewSubtitle">
Local service control for Forge server hosting.
</p>
</div>
<div class="top-actions">
<div
id="bulkActions"
class="bulk-actions"
style="display: none"
>
<button
id="startAllButton"
class="start icon-button"
type="button"
aria-label="Start All"
title="Start all services"
>
<span class="svg-icon icon-play"></span>
</button>
<button
id="stopAllButton"
class="stop icon-button"
type="button"
aria-label="Stop All"
title="Stop all services"
>
<span class="svg-icon icon-stop"></span>
</button>
</div>
<button
id="refreshButton"
class="primary icon-button"
type="button"
aria-label="Refresh"
title="Refresh"
>
<span class="svg-icon icon-refresh"></span>
</button>
<button id="stopAllButton" class="stop icon-button" type="button" aria-label="Stop All"
title="Stop all services">
<span class="svg-icon icon-stop"></span>
<button
id="saveButton"
class="icon-button"
type="button"
aria-label="Save"
title="Save"
>
<span class="svg-icon icon-save"></span>
</button>
</div>
<button id="refreshButton" class="primary icon-button" type="button" aria-label="Refresh" title="Refresh">
<div class="config-path">
<span>Config</span>
<strong id="configPath">Loading</strong>
</div>
</header>
<section id="overviewView" class="view active">
<div id="serviceGrid" class="service-grid"></div>
</section>
<section id="settingsView" class="view">
<form id="configForm" class="settings-grid"></form>
</section>
<section id="logsView" class="view">
<div id="logOutput" class="log-output"></div>
</section>
</section>
<div class="version-display" title="FORGE Host version">
<span id="appVersion" class="version-text">v0.1.0</span>
</div>
</main>
<div id="editorOverlay" class="editor-overlay" hidden>
<section class="editor-modal">
<header class="editor-header">
<div>
<h2>Server Config</h2>
<p id="editorPath"></p>
</div>
<button
id="editorCloseButton"
class="icon-button"
type="button"
title="Close"
aria-label="Close"
>
<span class="svg-icon icon-close"></span>
</button>
</header>
<textarea id="configEditor" spellcheck="false"></textarea>
<footer class="editor-actions">
<button
id="editorReloadButton"
class="primary"
type="button"
aria-label="Refresh"
>
<span class="svg-icon icon-refresh"></span>
</button>
<button id="saveButton" class="icon-button" type="button" aria-label="Save" title="Save">
<button
id="editorSaveButton"
class="icon-button"
type="button"
aria-label="Save"
>
<span class="svg-icon icon-save"></span>
</button>
</div>
</header>
<section id="overviewView" class="view active">
<div id="serviceGrid" class="service-grid"></div>
</footer>
</section>
</div>
<section id="settingsView" class="view">
<form id="configForm" class="settings-grid"></form>
</section>
<section id="logsView" class="view">
<div id="logOutput" class="log-output"></div>
</section>
</section>
</main>
<div id="editorOverlay" class="editor-overlay" hidden>
<section class="editor-modal">
<header class="editor-header">
<div>
<h2>Server Config</h2>
<p id="editorPath"></p>
</div>
<button id="editorCloseButton" class="icon-button" type="button" title="Close" aria-label="Close">
<span class="svg-icon icon-close"></span>
</button>
</header>
<textarea id="configEditor" spellcheck="false"></textarea>
<footer class="editor-actions">
<button id="editorReloadButton" class="primary" type="button" aria-label="Refresh">
<span class="svg-icon icon-refresh"></span>
</button>
<button id="editorSaveButton" class="icon-button" type="button" aria-label="Save">
<span class="svg-icon icon-save"></span>
</button>
</footer>
</section>
</div>
<template id="serviceCardTemplate">
<article class="service-card">
<header>
<div>
<div class="service-title-row">
<h2></h2>
<span class="ping-pill"></span>
<template id="serviceCardTemplate">
<article class="service-card">
<header>
<div>
<div class="service-title-row">
<h2></h2>
<span class="ping-pill"></span>
</div>
<p class="command-line"></p>
</div>
<p class="command-line"></p>
<span class="status-pill"></span>
</header>
<dl class="metrics">
<div>
<dt>Process</dt>
<dd class="pid"></dd>
</div>
<div>
<dt>Health</dt>
<dd class="health"></dd>
</div>
<div>
<dt>Enabled</dt>
<dd class="enabled"></dd>
</div>
</dl>
<div class="card-actions">
<button
class="start icon-button"
type="button"
aria-label="Start"
>
<span class="svg-icon icon-play"></span>
</button>
<button
class="stop icon-button"
type="button"
aria-label="Stop"
>
<span class="svg-icon icon-stop"></span>
</button>
</div>
<span class="status-pill"></span>
</header>
<dl class="metrics">
<div>
<dt>Process</dt>
<dd class="pid"></dd>
</div>
<div>
<dt>Health</dt>
<dd class="health"></dd>
</div>
<div>
<dt>Enabled</dt>
<dd class="enabled"></dd>
</div>
</dl>
<div class="card-actions">
<button class="start icon-button" type="button" aria-label="Start">
<span class="svg-icon icon-play"></span>
</button>
<button class="stop icon-button" type="button" aria-label="Stop">
<span class="svg-icon icon-stop"></span>
</button>
</div>
</article>
</template>
<script src="./app.js"></script>
</body>
</article>
</template>
<script src="./app.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

File diff suppressed because it is too large Load Diff