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/.output/
docus/.data/ docus/.data/
docus/.nitro/ docus/.nitro/
bin/launcher/
# OS # OS
.DS_Store .DS_Store

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

File diff suppressed because it is too large Load Diff