docs(reference): import Dune: Awakening server-manager references
Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.
- icehunter/ dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
closest analog to our agent's Dune docker control plane (compose
lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/ Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
Hyper-V self-host path + game-config schema
See docs/reference-repos/README.md for the full index + how we use each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15
docs/reference-repos/adainrivers/app/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dune Dedicated Server Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Funnel+Display:wght@400;500;600;700&family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3897
docs/reference-repos/adainrivers/app/package-lock.json
generated
Normal file
32
docs/reference-repos/adainrivers/app/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "dune-dedicated-server-manager-app",
|
||||
"private": true,
|
||||
"version": "0.3.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 1420",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 1420",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/themes": "^3.2.1",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"markdown-to-jsx": "^9.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
26
docs/reference-repos/adainrivers/app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "dune-dedicated-server-manager-app"
|
||||
version = "0.2.0"
|
||||
description = "Desktop shell for Dune Dedicated Server Manager"
|
||||
authors = ["Dune Dedicated Server Manager"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "dune_dedicated_server_manager_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
dune-manager-core = { path = "../../crates/dune-manager-core" }
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json"] }
|
||||
6
docs/reference-repos/adainrivers/app/src-tauri/binaries/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Populated by CI from the `linux-service-binary` job artifact, or locally
|
||||
# via `cargo zigbuild -p dune-server-service --release --target
|
||||
# x86_64-unknown-linux-musl` + manual copy. Not tracked.
|
||||
dune-server-service
|
||||
dune-server-service.service
|
||||
dune-server-service.openrc
|
||||
@@ -0,0 +1,23 @@
|
||||
# Bundled service binaries
|
||||
|
||||
This directory holds the Linux `dune-server-service` binary (musl-static), its
|
||||
systemd unit, and its OpenRC init script. They are populated by the
|
||||
`linux-service-binary` job in `.github/workflows/release.yml` and bundled into
|
||||
the desktop installer as Tauri resources.
|
||||
|
||||
For local debug builds the directory can be empty — the `install_management_service`
|
||||
Tauri command surfaces a friendly error when the resource is missing.
|
||||
|
||||
For a local end-to-end test, build the service yourself:
|
||||
|
||||
```powershell
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
cargo install --locked cargo-zigbuild
|
||||
cargo zigbuild -p dune-server-service --release --target x86_64-unknown-linux-musl
|
||||
Copy-Item target\x86_64-unknown-linux-musl\release\dune-server-service `
|
||||
app\src-tauri\binaries\dune-server-service
|
||||
Copy-Item crates\dune-server-service\systemd\dune-server-service.service `
|
||||
app\src-tauri\binaries\dune-server-service.service
|
||||
Copy-Item crates\dune-server-service\openrc\dune-server-service `
|
||||
app\src-tauri\binaries\dune-server-service.openrc
|
||||
```
|
||||
67
docs/reference-repos/adainrivers/app/src-tauri/build.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
fn main() {
|
||||
expose_dune_server_service_version();
|
||||
rerun_if_bundled_binaries_change();
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
/// Tauri's resource-copy step only fires when Cargo decides build.rs needs to
|
||||
/// re-run, which by default doesn't watch arbitrary files. Without these
|
||||
/// `rerun-if-changed` lines, refreshing the bundled `dune-server-service`
|
||||
/// binary or its systemd/openrc units in `binaries/` after a previous build
|
||||
/// produces a stale `target/release/binaries/` copy — the running exe then
|
||||
/// pushes the OLD binary on Install/Update, with no visible signal.
|
||||
fn rerun_if_bundled_binaries_change() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("binaries");
|
||||
// Watch the directory itself so file additions/deletions also trigger a rerun.
|
||||
println!("cargo:rerun-if-changed={}", dir.display());
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// Skip README, .gitignore, and similar bookkeeping files.
|
||||
if matches!(
|
||||
path.file_name().and_then(|n| n.to_str()),
|
||||
Some("README.md") | Some(".gitignore")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expose_dune_server_service_version() {
|
||||
let cargo_toml = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates/dune-server-service/Cargo.toml");
|
||||
println!("cargo:rerun-if-changed={}", cargo_toml.display());
|
||||
let contents = std::fs::read_to_string(&cargo_toml)
|
||||
.unwrap_or_else(|err| panic!("reading {}: {err}", cargo_toml.display()));
|
||||
let version = parse_package_version(&contents).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"could not find [package].version in {}",
|
||||
cargo_toml.display()
|
||||
)
|
||||
});
|
||||
println!("cargo:rustc-env=DUNE_SERVER_SERVICE_VERSION={version}");
|
||||
}
|
||||
|
||||
fn parse_package_version(toml: &str) -> Option<String> {
|
||||
let mut in_package = false;
|
||||
for line in toml.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('[') {
|
||||
in_package = trimmed == "[package]";
|
||||
continue;
|
||||
}
|
||||
if !in_package {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("version") {
|
||||
let rest = rest.trim_start();
|
||||
let rest = rest.strip_prefix('=')?.trim_start();
|
||||
let rest = rest.trim_start_matches('"');
|
||||
let end = rest.find('"')?;
|
||||
return Some(rest[..end].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default desktop app permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "dialog:allow-open", "process:default", "shell:allow-open", "updater:default"]
|
||||
}
|
||||
BIN
docs/reference-repos/adainrivers/app/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/reference-repos/adainrivers/app/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
docs/reference-repos/adainrivers/app/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
docs/reference-repos/adainrivers/app/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/reference-repos/adainrivers/app/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 1005 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,254 @@
|
||||
use dune_manager_core::orchestration::{
|
||||
is_started_state, BattlegroupManagementOrchestrator, BattlegroupRef, BattlegroupState,
|
||||
RusshRunner, StructuredKubectl, VendorBattlegroupWrapper,
|
||||
};
|
||||
|
||||
use crate::commands::shared::{command_error_message, runner_for_remote_kind};
|
||||
use crate::commands::status_data::read_remote_server_status;
|
||||
use crate::dto::{RemoteBattlegroupStatus, RemoteServerActionRequest, RemoteServerStatus};
|
||||
use crate::logging::TauriOperationSink;
|
||||
|
||||
type Manager = BattlegroupManagementOrchestrator<
|
||||
StructuredKubectl<RusshRunner>,
|
||||
VendorBattlegroupWrapper<RusshRunner>,
|
||||
>;
|
||||
|
||||
fn manager_from_runner(runner: &RusshRunner) -> Manager {
|
||||
let kubernetes = StructuredKubectl::new(runner.clone());
|
||||
// Pass the actual SSH login user so the wrapper knows when to insert
|
||||
// `sudo -n -u dune -H bash -lc ...`. Defaulting to "dune" here was a
|
||||
// silent root-style fallback: when the operator registered the server
|
||||
// under e.g. `ubuntu`, the wrapper skipped impersonation and the script
|
||||
// tried to read/write /home/dune as ubuntu, which fails noisily.
|
||||
let ssh_user = runner.target().user.clone();
|
||||
let wrapper = VendorBattlegroupWrapper::with_ssh_user(runner.clone(), ssh_user);
|
||||
BattlegroupManagementOrchestrator::new(kubernetes, wrapper)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_remote_battlegroup(
|
||||
app: tauri::AppHandle,
|
||||
request: RemoteServerActionRequest,
|
||||
) -> Result<RemoteServerStatus, String> {
|
||||
run_remote_battlegroup_action(app, request, false).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_remote_battlegroup(
|
||||
app: tauri::AppHandle,
|
||||
request: RemoteServerActionRequest,
|
||||
) -> Result<RemoteServerStatus, String> {
|
||||
run_remote_battlegroup_action(app, request, true).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_remote_battlegroup(
|
||||
app: tauri::AppHandle,
|
||||
request: RemoteServerActionRequest,
|
||||
) -> Result<RemoteServerStatus, String> {
|
||||
let worker_app = app.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut sink = TauriOperationSink::new(worker_app);
|
||||
sink.info("bg.restart", "Restarting remote battlegroup.");
|
||||
let runner = runner_for_remote_kind(
|
||||
request.server_type.as_deref(),
|
||||
request.host,
|
||||
request.user,
|
||||
request.key_path,
|
||||
Some(request.port),
|
||||
)?;
|
||||
let battlegroup = BattlegroupRef {
|
||||
namespace: request.namespace,
|
||||
name: request.battlegroup_name,
|
||||
};
|
||||
let manager = manager_from_runner(&runner);
|
||||
manager
|
||||
.restart_and_wait_director(&battlegroup, 240, &mut sink)
|
||||
.map_err(command_error_message)?;
|
||||
sink.info("bg.restart", "Refreshing battlegroup state.");
|
||||
read_remote_server_status(&runner, &battlegroup.namespace, &battlegroup.name)
|
||||
.map_err(command_error_message)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Remote battlegroup restart worker failed: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_remote_battlegroup(
|
||||
app: tauri::AppHandle,
|
||||
request: RemoteServerActionRequest,
|
||||
) -> Result<RemoteServerStatus, String> {
|
||||
let worker_app = app.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut sink = TauriOperationSink::new(worker_app);
|
||||
sink.info("bg.update", "Running vendor wrapper update.");
|
||||
let runner = runner_for_remote_kind(
|
||||
request.server_type.as_deref(),
|
||||
request.host,
|
||||
request.user,
|
||||
request.key_path,
|
||||
Some(request.port),
|
||||
)?;
|
||||
run_battlegroup_update_with_runner(
|
||||
&runner,
|
||||
&mut sink,
|
||||
request.namespace,
|
||||
request.battlegroup_name,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Remote battlegroup update worker failed: {err}"))?
|
||||
}
|
||||
|
||||
pub async fn run_remote_battlegroup_action(
|
||||
app: tauri::AppHandle,
|
||||
request: RemoteServerActionRequest,
|
||||
stop: bool,
|
||||
) -> Result<RemoteServerStatus, String> {
|
||||
let worker_app = app.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut sink = TauriOperationSink::new(worker_app);
|
||||
sink.info("bg.check", "Checking remote battlegroup state.");
|
||||
let runner = runner_for_remote_kind(
|
||||
request.server_type.as_deref(),
|
||||
request.host,
|
||||
request.user,
|
||||
request.key_path,
|
||||
Some(request.port),
|
||||
)?;
|
||||
run_battlegroup_action_with_runner(
|
||||
&runner,
|
||||
&mut sink,
|
||||
request.namespace,
|
||||
request.battlegroup_name,
|
||||
stop,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Remote battlegroup action worker failed: {err}"))?
|
||||
}
|
||||
|
||||
fn run_battlegroup_action_with_runner(
|
||||
runner: &RusshRunner,
|
||||
sink: &mut TauriOperationSink,
|
||||
namespace: String,
|
||||
battlegroup_name: String,
|
||||
stop: bool,
|
||||
) -> Result<RemoteServerStatus, String> {
|
||||
let battlegroup = BattlegroupRef {
|
||||
namespace,
|
||||
name: battlegroup_name,
|
||||
};
|
||||
let manager = manager_from_runner(runner);
|
||||
// Pre-flight no-op guard. Read the BattleGroup state from the stable
|
||||
// kubectl JSON schema (same source as the dashboard) rather than the
|
||||
// vendor wrapper's `status` text: that text layout drifts across Funcom
|
||||
// releases and was being misparsed into bogus phases (e.g. status="World",
|
||||
// director="2/2"), which made `is_started_state` wrongly report the BG as
|
||||
// not running and refuse a perfectly valid Stop (#19).
|
||||
let before = read_remote_server_status(runner, &battlegroup.namespace, &battlegroup.name)
|
||||
.map_err(command_error_message)?;
|
||||
let before_bg = &before.battlegroup;
|
||||
let before_started = is_started_state(&battlegroup_state_from_status(before_bg));
|
||||
if stop && !before_started {
|
||||
return Err(format!(
|
||||
"Battlegroup is not running (status={}, stop={}, database={}, gateway={}, director={}).",
|
||||
before_bg.phase,
|
||||
before_bg.stop,
|
||||
before_bg.database_phase,
|
||||
before_bg.server_group_phase,
|
||||
before_bg.director_phase
|
||||
));
|
||||
}
|
||||
if !stop && before_started {
|
||||
return Err("Battlegroup is already started.".to_string());
|
||||
}
|
||||
if stop {
|
||||
manager
|
||||
.stop(&battlegroup, sink)
|
||||
.map_err(command_error_message)?;
|
||||
} else {
|
||||
manager
|
||||
.start_and_wait_director(&battlegroup, 180, sink)
|
||||
.map_err(command_error_message)?;
|
||||
}
|
||||
sink.info("bg.check", "Refreshing battlegroup state.");
|
||||
read_remote_server_status(runner, &battlegroup.namespace, &battlegroup.name)
|
||||
.map_err(command_error_message)
|
||||
}
|
||||
|
||||
/// Adapts the structured `RemoteBattlegroupStatus` (read from the BattleGroup
|
||||
/// CR JSON) into the core `BattlegroupState` so the shared `is_started_state`
|
||||
/// phase vocabulary stays the single source of truth. `server_stats` is not
|
||||
/// consulted by `is_started_state`, so it is left empty.
|
||||
fn battlegroup_state_from_status(status: &RemoteBattlegroupStatus) -> BattlegroupState {
|
||||
BattlegroupState {
|
||||
stop: status.stop,
|
||||
phase: status.phase.clone(),
|
||||
database_phase: status.database_phase.clone(),
|
||||
server_group_phase: status.server_group_phase.clone(),
|
||||
director_phase: status.director_phase.clone(),
|
||||
uptime: status.uptime.clone(),
|
||||
server_stats: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_battlegroup_update_with_runner(
|
||||
runner: &RusshRunner,
|
||||
sink: &mut TauriOperationSink,
|
||||
namespace: String,
|
||||
battlegroup_name: String,
|
||||
) -> Result<RemoteServerStatus, String> {
|
||||
let battlegroup = BattlegroupRef {
|
||||
namespace,
|
||||
name: battlegroup_name,
|
||||
};
|
||||
let manager = manager_from_runner(runner);
|
||||
sink.warn(
|
||||
"bg.update",
|
||||
"Running vendor `battlegroup update` (steamcmd + operators + maps + images).",
|
||||
);
|
||||
let stdout = manager
|
||||
.update(&battlegroup, sink)
|
||||
.map_err(command_error_message)?;
|
||||
if !stdout.trim().is_empty() {
|
||||
sink.info("bg.update", stdout.trim().to_string());
|
||||
}
|
||||
sink.info("bg.update", "Refreshing battlegroup state.");
|
||||
read_remote_server_status(runner, &battlegroup.namespace, &battlegroup.name)
|
||||
.map_err(command_error_message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn status(phase: &str, sgp: &str, director: &str, stop: bool) -> RemoteBattlegroupStatus {
|
||||
RemoteBattlegroupStatus {
|
||||
stop,
|
||||
phase: phase.to_string(),
|
||||
database_phase: "Ready".to_string(),
|
||||
server_group_phase: sgp.to_string(),
|
||||
director_phase: director.to_string(),
|
||||
uptime: "8h45m".to_string(),
|
||||
server_stats: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconciling_bg_counts_as_started_so_stop_is_allowed() {
|
||||
// #19: the structured kubectl read reports phase=Reconciling,
|
||||
// serverGroupPhase=Running, directorPhase=Healthy while the BG is up.
|
||||
// The stop guard must treat this as started (previously the wrapper
|
||||
// text-parse produced status="World"/director="2/2" and refused).
|
||||
let s = status("Reconciling", "Running", "Healthy", false);
|
||||
assert!(is_started_state(&battlegroup_state_from_status(&s)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_bg_is_not_started() {
|
||||
assert!(!is_started_state(&battlegroup_state_from_status(&status(
|
||||
"Stopped", "Stopped", "", true
|
||||
))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use dune_manager_core::models::CommandResult;
|
||||
use dune_manager_core::orchestration::{RemoteCommandRunner, RusshRunner};
|
||||
use dune_manager_core::security::redact_text;
|
||||
|
||||
use crate::commands::shared::{command_error_message, runner_for_remote_kind, sh_single_quoted};
|
||||
use crate::dto::{
|
||||
RemoteComponentLogRequest, RemoteComponentLogResult, RemoteComponentRestartRequest,
|
||||
RemoteComponentRestartResult,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_component_log_tail(
|
||||
request: RemoteComponentLogRequest,
|
||||
) -> Result<RemoteComponentLogResult, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let runner = runner_for_remote_kind(
|
||||
request.server_type.as_deref(),
|
||||
request.host,
|
||||
request.user,
|
||||
request.key_path,
|
||||
Some(request.port),
|
||||
)?;
|
||||
read_remote_component_log_tail(
|
||||
&runner,
|
||||
&request.namespace,
|
||||
&request.component,
|
||||
request.tail,
|
||||
)
|
||||
.map_err(command_error_message)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Remote component log worker failed: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_remote_component(
|
||||
request: RemoteComponentRestartRequest,
|
||||
) -> Result<RemoteComponentRestartResult, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let runner = runner_for_remote_kind(
|
||||
request.server_type.as_deref(),
|
||||
request.host,
|
||||
request.user,
|
||||
request.key_path,
|
||||
Some(request.port),
|
||||
)?;
|
||||
restart_remote_component_inner(&runner, &request.namespace, &request.component)
|
||||
.map_err(command_error_message)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Remote component restart worker failed: {err}"))?
|
||||
}
|
||||
|
||||
fn read_remote_component_log_tail(
|
||||
runner: &RusshRunner,
|
||||
namespace: &str,
|
||||
component: &str,
|
||||
tail: u32,
|
||||
) -> CommandResult<RemoteComponentLogResult> {
|
||||
let component = component.trim();
|
||||
let (mode, pattern) = component_pod_selection(component)?;
|
||||
let tail = tail.clamp(20, 500);
|
||||
let script = format!(
|
||||
r#"
|
||||
ns={ns}
|
||||
mode={mode}
|
||||
pattern={pattern}
|
||||
tail_lines={tail}
|
||||
component={component}
|
||||
|
||||
if [ "$mode" = "role" ]; then
|
||||
pods=$(sudo kubectl get pods -n "$ns" -l "role=$pattern" --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null || true)
|
||||
elif [ "$mode" = "roles" ]; then
|
||||
pods=$(sudo kubectl get pods -n "$ns" --no-headers -o custom-columns=NAME:.metadata.name,ROLE:.metadata.labels.role 2>/dev/null | grep -E "$pattern" | awk '{{print $1}}' || true)
|
||||
else
|
||||
pods=$(sudo kubectl get pods -n "$ns" --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep -- "$pattern" || true)
|
||||
fi
|
||||
|
||||
if [ -z "$pods" ]; then
|
||||
echo "No pods found for $component."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for pod in $pods; do
|
||||
echo "== $pod =="
|
||||
sudo kubectl logs -n "$ns" "$pod" --all-containers --tail="$tail_lines" 2>&1 || true
|
||||
done
|
||||
"#,
|
||||
ns = sh_single_quoted(namespace),
|
||||
mode = sh_single_quoted(mode),
|
||||
pattern = sh_single_quoted(pattern),
|
||||
tail = tail,
|
||||
component = sh_single_quoted(component),
|
||||
);
|
||||
let output = runner.run_script(&script)?;
|
||||
Ok(RemoteComponentLogResult {
|
||||
component: component.to_string(),
|
||||
output: redact_text(&output),
|
||||
})
|
||||
}
|
||||
|
||||
fn restart_remote_component_inner(
|
||||
runner: &RusshRunner,
|
||||
namespace: &str,
|
||||
component: &str,
|
||||
) -> CommandResult<RemoteComponentRestartResult> {
|
||||
let component = component.trim();
|
||||
let (mode, pattern) = component_pod_selection(component)?;
|
||||
let script = format!(
|
||||
r#"
|
||||
ns={ns}
|
||||
mode={mode}
|
||||
pattern={pattern}
|
||||
component={component}
|
||||
|
||||
if [ "$mode" = "role" ]; then
|
||||
pods=$(sudo kubectl get pods -n "$ns" -l "role=$pattern" --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null || true)
|
||||
elif [ "$mode" = "roles" ]; then
|
||||
pods=$(sudo kubectl get pods -n "$ns" --no-headers -o custom-columns=NAME:.metadata.name,ROLE:.metadata.labels.role 2>/dev/null | grep -E "$pattern" | awk '{{print $1}}' || true)
|
||||
else
|
||||
pods=$(sudo kubectl get pods -n "$ns" --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep -- "$pattern" || true)
|
||||
fi
|
||||
|
||||
if [ -z "$pods" ]; then
|
||||
echo "No pods found for $component."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for pod in $pods; do
|
||||
echo "Restarting $pod"
|
||||
sudo kubectl delete pod -n "$ns" "$pod" --wait=false
|
||||
done
|
||||
"#,
|
||||
ns = sh_single_quoted(namespace),
|
||||
mode = sh_single_quoted(mode),
|
||||
pattern = sh_single_quoted(pattern),
|
||||
component = sh_single_quoted(component),
|
||||
);
|
||||
let output = runner.run_script(&script)?;
|
||||
Ok(RemoteComponentRestartResult {
|
||||
component: component.to_string(),
|
||||
output: redact_text(&output),
|
||||
})
|
||||
}
|
||||
|
||||
fn component_pod_selection(component: &str) -> CommandResult<(&'static str, &'static str)> {
|
||||
match component {
|
||||
"database" => Ok(("role", "igw-database")),
|
||||
"database-utilities" => Ok((
|
||||
"roles",
|
||||
"igw-database-utility|igw-database-monitor|igw-database-pghero",
|
||||
)),
|
||||
"message-queue" => Ok(("role", "igw-message-queue")),
|
||||
"director" => Ok(("role", "igw-battlegroup-director")),
|
||||
"gateway" | "gateway-resource" => Ok(("role", "igw-server-gateway")),
|
||||
"text-router" => Ok(("role", "igw-text-router")),
|
||||
"file-browser" => Ok(("role", "igw-filebrowser")),
|
||||
"server-group" => Ok(("role", "igw-server")),
|
||||
"map-survival-1" => Ok(("name", "-sg-survival-1-")),
|
||||
"map-overmap" => Ok(("name", "-sg-overmap-")),
|
||||
"map-deepdesert" => Ok(("name", "-sg-deepdesert-")),
|
||||
"map-social-arrakeen" => Ok(("name", "-sg-sh-arrakeen-")),
|
||||
"map-social-harkovillage" => Ok(("name", "-sg-sh-harkovillage-")),
|
||||
_ => Err(dune_manager_core::errors::failure(format!(
|
||||
"Unknown component key: {component}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use dune_manager_core::orchestration::RemoteCommandRunner;
|
||||
|
||||
use crate::commands::shared::{command_error_message, runner_for_remote_kind};
|
||||
use crate::commands::status_data::remote_records_from_battlegroups;
|
||||
use crate::dto::{RemoteConnectionRequest, RemoteServerRecord};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn detect_remote_ubuntu_servers(
|
||||
request: RemoteConnectionRequest,
|
||||
) -> Result<Vec<RemoteServerRecord>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let request = RemoteConnectionRequest {
|
||||
server_type: Some("ubuntu".to_string()),
|
||||
..request
|
||||
};
|
||||
let user = request.user.clone().unwrap_or_default();
|
||||
let runner = runner_for_remote_kind(
|
||||
request.server_type.as_deref(),
|
||||
request.host.clone(),
|
||||
user,
|
||||
request.key_path.clone(),
|
||||
Some(request.port),
|
||||
)?;
|
||||
let value = runner
|
||||
.run_json(
|
||||
"sudo kubectl get battlegroups -A -o json",
|
||||
"remote ubuntu battlegroups",
|
||||
)
|
||||
.map_err(command_error_message)?;
|
||||
Ok(remote_records_from_battlegroups(&request, &value))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Remote server detection worker failed: {err}"))?
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Frontend-facing helpers for the persisted operation log file.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::log_file::LogFile;
|
||||
|
||||
/// Appends a single row to the persisted operation log.
|
||||
///
|
||||
/// Frontend-originated log rows (those produced directly by React without a
|
||||
/// matching Rust event) call this so the on-disk log mirrors the in-memory
|
||||
/// view exactly.
|
||||
#[tauri::command]
|
||||
pub fn record_operation_log(
|
||||
log_file: State<'_, Arc<LogFile>>,
|
||||
level: String,
|
||||
scope: String,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
let allowed_levels = ["debug", "info", "warn", "error"];
|
||||
let normalized = if allowed_levels.contains(&level.as_str()) {
|
||||
level.as_str()
|
||||
} else {
|
||||
"info"
|
||||
};
|
||||
log_file
|
||||
.append(normalized, &scope, &message)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Returns the absolute path of the directory containing operation.log.
|
||||
#[tauri::command]
|
||||
pub fn get_logs_folder(log_file: State<'_, Arc<LogFile>>) -> String {
|
||||
log_file.dir().to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::state::TunnelRegistry;
|
||||
|
||||
pub fn ensure_client(app: &tauri::AppHandle) -> Client {
|
||||
if let Some(client) = app.try_state::<Client>() {
|
||||
return client.inner().clone();
|
||||
}
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.expect("reqwest client builds");
|
||||
app.manage(client.clone());
|
||||
client
|
||||
}
|
||||
|
||||
fn tunnel_local_port(registry: &TunnelRegistry, tunnel_id: &str) -> Result<u16, String> {
|
||||
let tunnels = registry
|
||||
.tunnels
|
||||
.lock()
|
||||
.map_err(|_| "tunnel registry unavailable".to_string())?;
|
||||
let tunnel = tunnels
|
||||
.get(tunnel_id.trim())
|
||||
.ok_or_else(|| format!("no active tunnel id={tunnel_id}"))?;
|
||||
Ok(tunnel.status.local_port)
|
||||
}
|
||||
|
||||
async fn get_json(client: &Client, port: u16, path: &str) -> Result<Value, String> {
|
||||
let url = format!("http://127.0.0.1:{port}{path}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("GET {path}: {err}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("GET {path} -> {status}: {body_text}"));
|
||||
}
|
||||
resp.json::<Value>()
|
||||
.await
|
||||
.map_err(|err| format!("decoding {path}: {err}"))
|
||||
}
|
||||
|
||||
async fn post_json(client: &Client, port: u16, path: &str, body: &Value) -> Result<Value, String> {
|
||||
let url = format!("http://127.0.0.1:{port}{path}");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("POST {path}: {err}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("POST {path} -> {status}: {body_text}"));
|
||||
}
|
||||
resp.json::<Value>()
|
||||
.await
|
||||
.map_err(|err| format!("decoding {path}: {err}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_health(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(&client, port, "/api/health").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_list_runs(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
limit: Option<u32>,
|
||||
task: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
let mut path = String::from("/api/runs");
|
||||
let mut sep = '?';
|
||||
if let Some(l) = limit {
|
||||
path.push(sep);
|
||||
path.push_str(&format!("limit={l}"));
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(t) = task {
|
||||
path.push(sep);
|
||||
path.push_str(&format!("task={t}"));
|
||||
}
|
||||
get_json(&client, port, &path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_list_logs(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
limit: Option<u32>,
|
||||
run_id: Option<i64>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
let mut path = String::from("/api/logs");
|
||||
let mut sep = '?';
|
||||
if let Some(l) = limit {
|
||||
path.push(sep);
|
||||
path.push_str(&format!("limit={l}"));
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(r) = run_id {
|
||||
path.push(sep);
|
||||
path.push_str(&format!("runId={r}"));
|
||||
}
|
||||
get_json(&client, port, &path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_trigger_run(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
task: String,
|
||||
options: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert("task".to_string(), Value::String(task));
|
||||
if let Some(opts) = options {
|
||||
body.insert("options".to_string(), opts);
|
||||
}
|
||||
post_json(&client, port, "/api/runs/trigger", &Value::Object(body)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_list_commands(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(&client, port, "/api/admin/commands").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_search_items(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
q: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(
|
||||
&client,
|
||||
port,
|
||||
&search_path("/api/admin/items", q.as_deref(), limit),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_search_vehicles(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
q: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(
|
||||
&client,
|
||||
port,
|
||||
&search_path("/api/admin/vehicles", q.as_deref(), limit),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_search_skill_modules(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
q: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(
|
||||
&client,
|
||||
port,
|
||||
&search_path("/api/admin/skill-modules", q.as_deref(), limit),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_search_journey_nodes(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
q: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(
|
||||
&client,
|
||||
port,
|
||||
&search_path("/api/admin/journey-nodes", q.as_deref(), limit),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_search_xp_event_tags(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
q: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(
|
||||
&client,
|
||||
port,
|
||||
&search_path("/api/admin/xp-event-tags", q.as_deref(), limit),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_search_players(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
q: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(
|
||||
&client,
|
||||
port,
|
||||
&search_path("/api/admin/players", q.as_deref(), limit),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_cluster(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(&client, port, "/api/admin/cluster").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_player_location(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
fls_id: String,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
let path = format!("/api/admin/player-location?flsId={}", urlencoding(&fls_id));
|
||||
get_json(&client, port, &path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_get_config(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(&client, port, "/api/config").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_set_config(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
config: Value,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
post_json(&client, port, "/api/config", &config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_list_timezones(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(&client, port, "/api/timezones").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_cron_preview(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
expr: String,
|
||||
count: Option<u32>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
let mut path = format!("/api/cron/preview?expr={}", urlencoding(&expr));
|
||||
if let Some(c) = count {
|
||||
path.push_str(&format!("&count={c}"));
|
||||
}
|
||||
get_json(&client, port, &path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_dump_prune_preview(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
get_json(&client, port, "/api/maintenance/dump-prune").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_dump_prune_execute(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
items: Value,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
let body = serde_json::json!({ "items": items });
|
||||
post_json(&client, port, "/api/maintenance/dump-prune", &body).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_history(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
let path = match limit {
|
||||
Some(l) => format!("/api/admin/history?limit={l}"),
|
||||
None => String::from("/api/admin/history"),
|
||||
};
|
||||
get_json(&client, port, &path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_welcome_grants(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
let path = match limit {
|
||||
Some(l) => format!("/api/admin/welcome-grants?limit={l}"),
|
||||
None => String::from("/api/admin/welcome-grants"),
|
||||
};
|
||||
get_json(&client, port, &path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_welcome_grant_retry(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
player_id: String,
|
||||
package_version: String,
|
||||
account_id: i64,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
post_json(
|
||||
&client,
|
||||
port,
|
||||
"/api/admin/welcome-grants/retry",
|
||||
&serde_json::json!({
|
||||
"playerId": player_id,
|
||||
"packageVersion": package_version,
|
||||
"accountId": account_id,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_publish(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
command: String,
|
||||
fields: Value,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
post_json(
|
||||
&client,
|
||||
port,
|
||||
"/api/admin/publish",
|
||||
&serde_json::json!({ "command": command, "fields": fields }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ms_welcome_whisper(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
tunnel_id: String,
|
||||
recipient_player_id: String,
|
||||
source_player_id: String,
|
||||
message: String,
|
||||
) -> Result<Value, String> {
|
||||
let port = tunnel_local_port(®istry, &tunnel_id)?;
|
||||
let client = ensure_client(&app);
|
||||
post_json(
|
||||
&client,
|
||||
port,
|
||||
"/api/admin/welcome-whisper",
|
||||
&serde_json::json!({
|
||||
"recipientPlayerId": recipient_player_id,
|
||||
"sourcePlayerId": source_player_id,
|
||||
"message": message,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn search_path(base: &str, q: Option<&str>, limit: Option<u32>) -> String {
|
||||
let mut out = base.to_string();
|
||||
let mut sep = '?';
|
||||
if let Some(qq) = q {
|
||||
out.push(sep);
|
||||
out.push_str(&format!("q={}", urlencoding(qq)));
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(l) = limit {
|
||||
out.push(sep);
|
||||
out.push_str(&format!("limit={l}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn urlencoding(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for c in input.chars() {
|
||||
match c {
|
||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
|
||||
_ => {
|
||||
let mut buf = [0u8; 4];
|
||||
for byte in c.encode_utf8(&mut buf).bytes() {
|
||||
out.push_str(&format!("%{:02X}", byte));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use base64::Engine as _;
|
||||
use dune_manager_core::orchestration::{RemoteCommandRunner, RusshRunner, RusshTarget};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::commands::shared::{command_error_message, sh_single_quoted};
|
||||
|
||||
const REMOTE_BINARY_PATH: &str = "/opt/dune-server-service/dune-server-service";
|
||||
const REMOTE_SYSTEMD_UNIT_PATH: &str = "/etc/systemd/system/dune-server-service.service";
|
||||
const REMOTE_OPENRC_PATH: &str = "/etc/init.d/dune-server-service";
|
||||
|
||||
const BUNDLED_VERSION: &str = env!("DUNE_SERVER_SERVICE_VERSION");
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManagementInstallRequest {
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub key_path: Option<String>,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
pub port: u16,
|
||||
/// Optional command-auth token. If None, install only refreshes the binary.
|
||||
pub command_auth_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManagementConnRequest {
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub key_path: Option<String>,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManagementInstallResult {
|
||||
pub installed: bool,
|
||||
pub started: bool,
|
||||
pub init_system: String,
|
||||
pub installed_version: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManagementServiceStatus {
|
||||
pub installed: bool,
|
||||
pub active: bool,
|
||||
pub init_system: String,
|
||||
pub installed_version: Option<String>,
|
||||
pub bundled_version: String,
|
||||
pub journal_tail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallProgressEvent {
|
||||
pub step: String,
|
||||
pub status: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
fn default_ssh_port() -> u16 {
|
||||
22
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ServiceAccount {
|
||||
user: String,
|
||||
group: String,
|
||||
home: String,
|
||||
}
|
||||
|
||||
fn target_from_conn(req: &ManagementConnRequest) -> Result<RusshTarget, String> {
|
||||
let mut target = RusshTarget::new(
|
||||
PathBuf::from(
|
||||
req.key_path
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string(),
|
||||
),
|
||||
req.user.trim().to_string(),
|
||||
req.host.trim().to_string(),
|
||||
);
|
||||
if req.port != 0 {
|
||||
target.port = req.port;
|
||||
}
|
||||
target.validate().map_err(|err| err.message)?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn target_from_install(req: &ManagementInstallRequest) -> Result<RusshTarget, String> {
|
||||
let conn = ManagementConnRequest {
|
||||
host: req.host.clone(),
|
||||
user: req.user.clone(),
|
||||
key_path: req.key_path.clone(),
|
||||
port: req.port,
|
||||
};
|
||||
target_from_conn(&conn)
|
||||
}
|
||||
|
||||
fn resolve_resource(app: &tauri::AppHandle, path: &str) -> Result<PathBuf, String> {
|
||||
let resource = app
|
||||
.path()
|
||||
.resolve(path, tauri::path::BaseDirectory::Resource)
|
||||
.map_err(|err| format!("resolving bundled {path}: {err}"))?;
|
||||
if !resource.exists() {
|
||||
return Err(format!("bundled {path} missing at {}", resource.display()));
|
||||
}
|
||||
Ok(resource)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_management_service(
|
||||
app: tauri::AppHandle,
|
||||
request: ManagementInstallRequest,
|
||||
) -> Result<ManagementInstallResult, String> {
|
||||
let binary_path = resolve_resource(&app, "binaries/dune-server-service")?;
|
||||
let unit_path = resolve_resource(&app, "binaries/dune-server-service.service")?;
|
||||
let openrc_path = resolve_resource(&app, "binaries/dune-server-service.openrc")?;
|
||||
let target = target_from_install(&request)?;
|
||||
let token = request.command_auth_token.clone();
|
||||
let app_handle = app.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
install_inner(
|
||||
&app_handle,
|
||||
&target,
|
||||
&binary_path,
|
||||
&unit_path,
|
||||
&openrc_path,
|
||||
token.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("install worker failed: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn management_service_bundled_version() -> String {
|
||||
BUNDLED_VERSION.trim().to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_management_service(request: ManagementConnRequest) -> Result<(), String> {
|
||||
let target = target_from_conn(&request)?;
|
||||
tauri::async_runtime::spawn_blocking(move || uninstall_inner(&target))
|
||||
.await
|
||||
.map_err(|err| format!("uninstall worker failed: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_management_service(request: ManagementConnRequest) -> Result<(), String> {
|
||||
let target = target_from_conn(&request)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let script = "set -eu\n\
|
||||
export PATH=/sbin:/usr/sbin:/usr/local/sbin:$PATH\n\
|
||||
if command -v systemctl >/dev/null 2>&1; then\n \
|
||||
sudo systemctl restart dune-server-service.service\n\
|
||||
elif command -v rc-service >/dev/null 2>&1; then\n \
|
||||
sudo rc-service dune-server-service restart\n\
|
||||
else\n \
|
||||
echo \"no supported init system\" >&2\n \
|
||||
exit 1\n\
|
||||
fi\n\
|
||||
exit 0\n";
|
||||
let runner = RusshRunner::new(target.clone());
|
||||
runner
|
||||
.run_script(script)
|
||||
.map_err(command_error_message)
|
||||
.map(|_| ())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("restart worker failed: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn management_service_status(
|
||||
request: ManagementConnRequest,
|
||||
) -> Result<ManagementServiceStatus, String> {
|
||||
let target = target_from_conn(&request)?;
|
||||
tauri::async_runtime::spawn_blocking(move || status_inner(&target))
|
||||
.await
|
||||
.map_err(|err| format!("status worker failed: {err}"))?
|
||||
}
|
||||
|
||||
fn install_inner(
|
||||
app: &tauri::AppHandle,
|
||||
target: &RusshTarget,
|
||||
binary_path: &std::path::Path,
|
||||
unit_path: &std::path::Path,
|
||||
openrc_path: &std::path::Path,
|
||||
token: Option<&str>,
|
||||
) -> Result<ManagementInstallResult, String> {
|
||||
let runner = RusshRunner::new(target.clone());
|
||||
let account = discover_service_account(&runner, &target.user)?;
|
||||
|
||||
emit_progress(app, "stop-old", "running", None);
|
||||
let stop_script = "set +e\n\
|
||||
export PATH=/sbin:/usr/sbin:/usr/local/sbin:$PATH\n\
|
||||
sudo systemctl disable --now server-management-service.service >/dev/null 2>&1 || true\n\
|
||||
sudo systemctl stop dune-server-service.service >/dev/null 2>&1 || true\n\
|
||||
sudo rc-service dune-server-service stop >/dev/null 2>&1 || true\n\
|
||||
exit 0\n";
|
||||
runner
|
||||
.run_script(stop_script)
|
||||
.map_err(|err| step_err(app, "stop-old", err))?;
|
||||
emit_progress(app, "stop-old", "ok", None);
|
||||
|
||||
emit_progress(app, "prepare-host", "running", None);
|
||||
// Pre-create every directory the systemd unit lists under
|
||||
// `ReadWritePaths=`. systemd sets up a mount namespace BEFORE the binary
|
||||
// runs, and a missing path there is fatal (exit 226/NAMESPACE — see the
|
||||
// "/root/.steam: No such file or directory" failure mode). The service's
|
||||
// sqlite + OpenRC supervisor also need the state dir and log file owned
|
||||
// by the service user up front; missing them produces the silent
|
||||
// not-starting symptom (goofycoolguy / MadBuffoon / issues #5, #6).
|
||||
let prepare_script = format!(
|
||||
"set -eu\n\
|
||||
export PATH=/sbin:/usr/sbin:/usr/local/sbin:$PATH\n\
|
||||
sudo install -d -m 0755 -o {user} -g {group} {home}/.dune\n\
|
||||
sudo install -d -m 0700 -o {user} -g {group} {state_dir}\n\
|
||||
sudo install -d -m 0755 -o {user} -g {group} {home}/.local\n\
|
||||
sudo install -d -m 0755 -o {user} -g {group} {home}/.local/bin\n\
|
||||
sudo install -d -m 0755 -o {user} -g {group} {home}/.steam\n\
|
||||
sudo install -d -m 0755 -o {user} -g {group} {home}/Steam\n\
|
||||
sudo touch /var/log/dune-server-service.log\n\
|
||||
sudo chown {user}:{group} /var/log/dune-server-service.log\n\
|
||||
sudo chmod 0644 /var/log/dune-server-service.log\n",
|
||||
user = sh_single_quoted(&account.user),
|
||||
group = sh_single_quoted(&account.group),
|
||||
home = sh_single_quoted(&account.home),
|
||||
state_dir = sh_single_quoted(&format!("{}/.dune/state", account.home)),
|
||||
);
|
||||
runner
|
||||
.run_script(&prepare_script)
|
||||
.map_err(|err| step_err(app, "prepare-host", err))?;
|
||||
emit_progress(app, "prepare-host", "ok", None);
|
||||
|
||||
let binary_bytes = std::fs::read(binary_path)
|
||||
.map_err(|err| format!("reading resource {}: {err}", binary_path.display()))?;
|
||||
let binary_size = std::fs::metadata(binary_path)
|
||||
.ok()
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
let size_msg = if binary_size > 0 {
|
||||
format!("{:.1} MB", binary_size as f64 / 1024.0 / 1024.0)
|
||||
} else {
|
||||
"unknown size".to_string()
|
||||
};
|
||||
emit_progress(
|
||||
app,
|
||||
"upload-binary",
|
||||
"running",
|
||||
Some(format!(
|
||||
"streaming {size_msg} from {} to {REMOTE_BINARY_PATH}",
|
||||
binary_path.display()
|
||||
)),
|
||||
);
|
||||
let upload_script = format!(
|
||||
"set -eu\n\
|
||||
export PATH=/sbin:/usr/sbin:/usr/local/sbin:$PATH\n\
|
||||
sudo install -d -m 0755 /opt/dune-server-service\n\
|
||||
tmp=$(mktemp /tmp/dune-server-service.XXXXXX)\n\
|
||||
trap 'rm -f \"$tmp\"' EXIT\n\
|
||||
cat > \"$tmp\"\n\
|
||||
actual=$(wc -c < \"$tmp\" | tr -d '[:space:]')\n\
|
||||
if [ \"$actual\" != {expected_bytes} ]; then\n \
|
||||
echo \"upload byte-count mismatch: expected {expected_bytes}, got $actual\" >&2\n \
|
||||
exit 42\n\
|
||||
fi\n\
|
||||
sudo install -m 0755 -o root -g root \"$tmp\" {dest}\n\
|
||||
installed=$(sudo stat -c '%s bytes mode=%a owner=%U:%G' {dest})\n\
|
||||
echo \"remote install: $installed\"\n",
|
||||
expected_bytes = binary_bytes.len(),
|
||||
dest = sh_single_quoted(REMOTE_BINARY_PATH),
|
||||
);
|
||||
let upload_stdout = runner
|
||||
.run_with_stdin(
|
||||
&format!("sh -c {}", sh_single_quoted(&upload_script)),
|
||||
&binary_bytes,
|
||||
)
|
||||
.map_err(|err| step_err(app, "upload-binary", err))?;
|
||||
let upload_msg = if upload_stdout.trim().is_empty() {
|
||||
size_msg
|
||||
} else {
|
||||
format!("{size_msg}; {}", upload_stdout.trim())
|
||||
};
|
||||
emit_progress(app, "upload-binary", "ok", Some(upload_msg));
|
||||
|
||||
if let Some(t) = token {
|
||||
emit_progress(app, "write-token", "running", None);
|
||||
let token_b64 = base64::engine::general_purpose::STANDARD.encode(t.as_bytes());
|
||||
let token_path = format!("{}/.dune/state/command-auth-token", account.home);
|
||||
// Stage to a real temp file before `sudo install` instead of piping
|
||||
// through `sudo install /dev/stdin ...`. On Ubuntu hosts with sudo
|
||||
// `Defaults use_pty` (default on 24.04+), root-to-root sudo allocates
|
||||
// a pty and the piped bytes never reach the child's fd 0, which
|
||||
// surfaces as `install: No such file or directory` even though both
|
||||
// /dev/stdin and the destination dir exist. The temp-file pattern
|
||||
// sidesteps the pty entirely.
|
||||
let token_script = format!(
|
||||
"set -eu\n\
|
||||
export PATH=/sbin:/usr/sbin:/usr/local/sbin:$PATH\n\
|
||||
sudo install -d -m 0700 -o {user} -g {group} {state_dir}\n\
|
||||
tmp=$(mktemp /tmp/dune-token.XXXXXX)\n\
|
||||
trap 'rm -f \"$tmp\"' EXIT\n\
|
||||
echo {b64} | base64 -d > \"$tmp\"\n\
|
||||
sudo install -m 0600 -o {user} -g {group} \"$tmp\" {dest}\n",
|
||||
user = sh_single_quoted(&account.user),
|
||||
group = sh_single_quoted(&account.group),
|
||||
state_dir = sh_single_quoted(&format!("{}/.dune/state", account.home)),
|
||||
b64 = sh_single_quoted(&token_b64),
|
||||
dest = sh_single_quoted(&token_path),
|
||||
);
|
||||
runner
|
||||
.run_script(&token_script)
|
||||
.map_err(|err| step_err(app, "write-token", err))?;
|
||||
emit_progress(app, "write-token", "ok", None);
|
||||
} else {
|
||||
emit_progress(
|
||||
app,
|
||||
"write-token",
|
||||
"ok",
|
||||
Some("skipped (no token)".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
emit_progress(app, "install-init", "running", None);
|
||||
let unit_b64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(render_systemd_unit(unit_path, &account)?.as_bytes());
|
||||
let openrc_b64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(render_openrc_unit(openrc_path, &account)?.as_bytes());
|
||||
// Stage unit content + drop-in to real temp files before `sudo install`.
|
||||
// The previous `echo b64 | base64 -d | sudo install /dev/stdin ...` shape
|
||||
// breaks on hosts where sudoers has `Defaults use_pty` enabled (default
|
||||
// on Ubuntu 24.04+): root-to-root sudo allocates a pty and the piped
|
||||
// bytes never reach the child's fd 0, surfacing as
|
||||
// `install: No such file or directory`. mktemp + sudo install <tmp>
|
||||
// sidesteps the pty entirely.
|
||||
let init_script = format!(
|
||||
"set -eu\n\
|
||||
export PATH=/sbin:/usr/sbin:/usr/local/sbin:$PATH\n\
|
||||
tmp_unit=$(mktemp /tmp/dune-unit.XXXXXX)\n\
|
||||
tmp_dropin=$(mktemp /tmp/dune-dropin.XXXXXX)\n\
|
||||
tmp_openrc=$(mktemp /tmp/dune-openrc.XXXXXX)\n\
|
||||
trap 'rm -f \"$tmp_unit\" \"$tmp_dropin\" \"$tmp_openrc\"' EXIT\n\
|
||||
if command -v systemctl >/dev/null 2>&1; then\n \
|
||||
echo SYSTEMD\n \
|
||||
echo {unit_b64} | base64 -d > \"$tmp_unit\"\n \
|
||||
sudo install -m 0644 -o root -g root \"$tmp_unit\" {unit_dest}\n \
|
||||
sudo install -d -m 0755 /etc/systemd/system/dune-server-service.service.d\n \
|
||||
printf '%s\\n' '[Service]' 'NoNewPrivileges=false' 'MemoryDenyWriteExecute=false' > \"$tmp_dropin\"\n \
|
||||
sudo install -m 0644 -o root -g root \"$tmp_dropin\" /etc/systemd/system/dune-server-service.service.d/zz-dune-steamcmd-compat.conf\n \
|
||||
sudo systemctl daemon-reload\n \
|
||||
sudo systemctl reset-failed dune-server-service.service >/dev/null 2>&1 || true\n\
|
||||
elif command -v rc-service >/dev/null 2>&1; then\n \
|
||||
echo OPENRC\n \
|
||||
echo {openrc_b64} | base64 -d > \"$tmp_openrc\"\n \
|
||||
sudo install -m 0755 -o root -g root \"$tmp_openrc\" {openrc_dest}\n \
|
||||
sudo rc-update add dune-server-service default >/dev/null 2>&1 || true\n\
|
||||
else\n \
|
||||
echo \"no supported init system found (need systemd or openrc)\" >&2\n \
|
||||
exit 1\n\
|
||||
fi\n",
|
||||
unit_b64 = sh_single_quoted(&unit_b64),
|
||||
unit_dest = sh_single_quoted(REMOTE_SYSTEMD_UNIT_PATH),
|
||||
openrc_b64 = sh_single_quoted(&openrc_b64),
|
||||
openrc_dest = sh_single_quoted(REMOTE_OPENRC_PATH),
|
||||
);
|
||||
let init_stdout = runner
|
||||
.run_script(&init_script)
|
||||
.map_err(|err| step_err(app, "install-init", err))?;
|
||||
let mut init_system = String::from("unknown");
|
||||
for line in init_stdout.lines() {
|
||||
match line.trim() {
|
||||
"SYSTEMD" => init_system = "systemd".to_string(),
|
||||
"OPENRC" => init_system = "openrc".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
emit_progress(app, "install-init", "ok", Some(init_system.clone()));
|
||||
|
||||
emit_progress(app, "start-service", "running", None);
|
||||
let start_script = "set -eu\n\
|
||||
export PATH=/sbin:/usr/sbin:/usr/local/sbin:$PATH\n\
|
||||
if command -v systemctl >/dev/null 2>&1; then\n \
|
||||
sudo systemctl enable --now dune-server-service.service\n\
|
||||
elif command -v rc-service >/dev/null 2>&1; then\n \
|
||||
sudo rc-service dune-server-service restart >/dev/null 2>&1 || sudo rc-service dune-server-service start\n\
|
||||
fi\n";
|
||||
runner
|
||||
.run_script(start_script)
|
||||
.map_err(|err| step_err(app, "start-service", err))?;
|
||||
emit_progress(app, "start-service", "ok", None);
|
||||
|
||||
emit_progress(app, "verify", "running", None);
|
||||
// `STATE=...` line carries the canonical systemctl/openrc state. When the
|
||||
// unit is anything other than active we also tail the journal so the UI
|
||||
// surfaces *why* — empty parentheses helped nobody.
|
||||
let verify_script = "set +e\n\
|
||||
export PATH=/sbin:/usr/sbin:/usr/local/sbin:$PATH\n\
|
||||
if command -v systemctl >/dev/null 2>&1; then\n \
|
||||
sleep 1\n \
|
||||
state=$(sudo systemctl is-active dune-server-service.service 2>/dev/null | tr -d '\\r\\n')\n \
|
||||
[ -z \"$state\" ] && state=unknown\n \
|
||||
echo \"STATE=$state\"\n \
|
||||
if [ \"$state\" != active ]; then\n \
|
||||
echo '--- journalctl ---'\n \
|
||||
sudo journalctl -u dune-server-service.service -n 20 --no-pager 2>&1 | tail -n 20\n \
|
||||
fi\n\
|
||||
elif command -v rc-service >/dev/null 2>&1; then\n \
|
||||
sleep 1\n \
|
||||
if sudo rc-service dune-server-service status >/dev/null 2>&1; then echo STATE=active; else echo STATE=inactive; fi\n \
|
||||
if [ -f /var/log/dune-server-service.log ]; then\n \
|
||||
echo '--- supervisor log ---'\n \
|
||||
sudo tail -n 20 /var/log/dune-server-service.log 2>&1\n \
|
||||
fi\n\
|
||||
else\n \
|
||||
echo STATE=unknown\n\
|
||||
fi\n\
|
||||
/opt/dune-server-service/dune-server-service --version 2>/dev/null || true\n\
|
||||
exit 0\n";
|
||||
let verify_stdout = runner
|
||||
.run_script(verify_script)
|
||||
.map_err(|err| step_err(app, "verify", err))?;
|
||||
let mut active_state = String::new();
|
||||
let mut installed_version: Option<String> = None;
|
||||
let mut diagnostic_lines: Vec<String> = Vec::new();
|
||||
let mut collecting_diag = false;
|
||||
for line in verify_stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(state) = trimmed.strip_prefix("STATE=") {
|
||||
active_state = state.to_string();
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("--- ") && trimmed.ends_with(" ---") {
|
||||
collecting_diag = true;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("dune-server-service ") {
|
||||
installed_version = trimmed
|
||||
.strip_prefix("dune-server-service ")
|
||||
.map(|s| s.trim().to_string());
|
||||
continue;
|
||||
}
|
||||
if collecting_diag && !trimmed.is_empty() {
|
||||
diagnostic_lines.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
let started = active_state == "active";
|
||||
let verify_msg = match (started, &installed_version) {
|
||||
(true, Some(v)) => Some(format!("active, version {v}")),
|
||||
(true, None) => Some("active".to_string()),
|
||||
(false, _) => {
|
||||
let header = if active_state.is_empty() {
|
||||
"not active".to_string()
|
||||
} else {
|
||||
format!("not active ({active_state})")
|
||||
};
|
||||
if diagnostic_lines.is_empty() {
|
||||
Some(header)
|
||||
} else {
|
||||
// Keep the tail short so the toast/log stays readable; full
|
||||
// detail is still on the host via `journalctl -u ...`.
|
||||
let tail: Vec<String> = diagnostic_lines
|
||||
.iter()
|
||||
.rev()
|
||||
.take(6)
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect();
|
||||
Some(format!("{header}\n{}", tail.join("\n")))
|
||||
}
|
||||
}
|
||||
};
|
||||
emit_progress(
|
||||
app,
|
||||
"verify",
|
||||
if started { "ok" } else { "error" },
|
||||
verify_msg.clone(),
|
||||
);
|
||||
|
||||
Ok(ManagementInstallResult {
|
||||
installed: true,
|
||||
started,
|
||||
init_system: init_system.clone(),
|
||||
installed_version,
|
||||
message: format!("installed via {init_system}; active={active_state}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn discover_service_account(
|
||||
runner: &RusshRunner,
|
||||
_registered_user: &str,
|
||||
) -> Result<ServiceAccount, String> {
|
||||
// The Dune service ALWAYS runs as the vendor's `dune` user with home
|
||||
// `/home/dune`, no matter which account the operator SSH'd in as. SSH
|
||||
// login may be root / ubuntu / a custom sudoer; install steps escalate
|
||||
// via `sudo install -o dune -g dune` and the systemd/openrc unit pins
|
||||
// User=dune. We still call getent on the host to fail loudly if `dune`
|
||||
// isn't provisioned yet (e.g. vendor setup wasn't run).
|
||||
let script = "set -eu\n\
|
||||
user=dune\n\
|
||||
home=$(getent passwd \"$user\" | awk -F: '{print $6}')\n\
|
||||
group=$(id -gn \"$user\" 2>/dev/null || echo dune)\n\
|
||||
if [ -z \"$home\" ]; then\n \
|
||||
echo \"dune user not found on host — run the vendor setup first\" >&2\n \
|
||||
exit 1\n\
|
||||
fi\n\
|
||||
printf 'USER=%s\\nGROUP=%s\\nHOME=%s\\n' \"$user\" \"$group\" \"$home\"\n";
|
||||
let script = script.to_string();
|
||||
let stdout = runner.run_script(&script).map_err(command_error_message)?;
|
||||
let mut account = ServiceAccount {
|
||||
user: String::new(),
|
||||
group: String::new(),
|
||||
home: String::new(),
|
||||
};
|
||||
for line in stdout.lines() {
|
||||
if let Some(value) = line.strip_prefix("USER=") {
|
||||
account.user = value.trim().to_string();
|
||||
} else if let Some(value) = line.strip_prefix("GROUP=") {
|
||||
account.group = value.trim().to_string();
|
||||
} else if let Some(value) = line.strip_prefix("HOME=") {
|
||||
account.home = value.trim().trim_end_matches('/').to_string();
|
||||
}
|
||||
}
|
||||
if account.user.is_empty() || account.group.is_empty() || account.home.is_empty() {
|
||||
return Err(format!(
|
||||
"could not resolve service account from remote output: {stdout}"
|
||||
));
|
||||
}
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
fn render_systemd_unit(path: &std::path::Path, account: &ServiceAccount) -> Result<String, String> {
|
||||
let unit = std::fs::read_to_string(path)
|
||||
.map_err(|err| format!("reading resource {}: {err}", path.display()))?;
|
||||
let home = account.home.as_str();
|
||||
Ok(unit
|
||||
.replace("User=dune", &format!("User={}", account.user))
|
||||
.replace("Group=dune", &format!("Group={}", account.group))
|
||||
.replace("/home/dune/.local/bin", &format!("{home}/.local/bin"))
|
||||
.replace("/home/dune/.dune", &format!("{home}/.dune"))
|
||||
.replace("/home/dune/.steam", &format!("{home}/.steam"))
|
||||
.replace("/home/dune/Steam", &format!("{home}/Steam"))
|
||||
.replace(
|
||||
"Environment=\"DUNE_SERVICE_HOME=/home/dune\"",
|
||||
&format!("Environment=\"DUNE_SERVICE_HOME={home}\""),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_openrc_unit(path: &std::path::Path, account: &ServiceAccount) -> Result<String, String> {
|
||||
let unit = std::fs::read_to_string(path)
|
||||
.map_err(|err| format!("reading resource {}: {err}", path.display()))?;
|
||||
let home = account.home.as_str();
|
||||
Ok(unit
|
||||
.replace(
|
||||
"command_user=\"dune:dune\"",
|
||||
&format!("command_user=\"{}:{}\"", account.user, account.group),
|
||||
)
|
||||
.replace(
|
||||
"--owner dune:dune",
|
||||
&format!("--owner {}:{}", account.user, account.group),
|
||||
)
|
||||
.replace("/home/dune/.dune", &format!("{home}/.dune"))
|
||||
.replace(
|
||||
"DUNE_SERVICE_HOME=\"${DUNE_SERVICE_HOME:-/home/dune}\"",
|
||||
&format!("DUNE_SERVICE_HOME=\"${{DUNE_SERVICE_HOME:-{home}}}\""),
|
||||
))
|
||||
}
|
||||
|
||||
fn emit_progress(app: &tauri::AppHandle, step: &str, status: &str, message: Option<String>) {
|
||||
let payload = InstallProgressEvent {
|
||||
step: step.to_string(),
|
||||
status: status.to_string(),
|
||||
message,
|
||||
};
|
||||
let _ = app.emit("management-install-progress", payload);
|
||||
}
|
||||
|
||||
fn step_err(
|
||||
app: &tauri::AppHandle,
|
||||
step: &str,
|
||||
err: dune_manager_core::models::CommandFailure,
|
||||
) -> String {
|
||||
let msg = command_error_message(err);
|
||||
emit_progress(app, step, "error", Some(msg.clone()));
|
||||
msg
|
||||
}
|
||||
|
||||
fn uninstall_inner(target: &RusshTarget) -> Result<(), String> {
|
||||
let script = "set -eu\n\
|
||||
export PATH=/sbin:/usr/sbin:/usr/local/sbin:$PATH\n\
|
||||
if command -v systemctl >/dev/null 2>&1; then\n \
|
||||
sudo systemctl disable --now dune-server-service.service >/dev/null 2>&1 || true\n \
|
||||
sudo rm -f /etc/systemd/system/dune-server-service.service\n \
|
||||
sudo systemctl daemon-reload\n\
|
||||
fi\n\
|
||||
if command -v rc-service >/dev/null 2>&1; then\n \
|
||||
sudo rc-service dune-server-service stop >/dev/null 2>&1 || true\n \
|
||||
sudo rc-update del dune-server-service default >/dev/null 2>&1 || true\n \
|
||||
sudo rm -f /etc/init.d/dune-server-service\n\
|
||||
fi\n\
|
||||
sudo rm -rf /opt/dune-server-service\n\
|
||||
exit 0\n";
|
||||
let runner = RusshRunner::new(target.clone());
|
||||
runner
|
||||
.run_script(script)
|
||||
.map_err(command_error_message)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn status_inner(target: &RusshTarget) -> Result<ManagementServiceStatus, String> {
|
||||
let script = "set +e\n\
|
||||
export PATH=/sbin:/usr/sbin:/usr/local/sbin:$PATH\n\
|
||||
if [ -x /opt/dune-server-service/dune-server-service ]; then\n \
|
||||
echo INSTALLED=yes\n \
|
||||
/opt/dune-server-service/dune-server-service --version 2>/dev/null | head -n 1\n\
|
||||
else\n \
|
||||
echo INSTALLED=no\n\
|
||||
fi\n\
|
||||
if command -v systemctl >/dev/null 2>&1; then\n \
|
||||
echo INIT=systemd\n \
|
||||
sudo systemctl is-active dune-server-service.service\n\
|
||||
elif command -v rc-service >/dev/null 2>&1; then\n \
|
||||
echo INIT=openrc\n \
|
||||
sudo rc-service dune-server-service status >/dev/null 2>&1 && echo active || echo inactive\n\
|
||||
else\n \
|
||||
echo INIT=none\n\
|
||||
fi\n\
|
||||
exit 0\n";
|
||||
let runner = RusshRunner::new(target.clone());
|
||||
let stdout = runner.run_script(script).map_err(command_error_message)?;
|
||||
let mut installed = false;
|
||||
let mut active = false;
|
||||
let mut init_system = String::from("unknown");
|
||||
let mut installed_version: Option<String> = None;
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
match trimmed {
|
||||
"INSTALLED=yes" => installed = true,
|
||||
"INSTALLED=no" => installed = false,
|
||||
"INIT=systemd" => init_system = "systemd".to_string(),
|
||||
"INIT=openrc" => init_system = "openrc".to_string(),
|
||||
"INIT=none" => init_system = "none".to_string(),
|
||||
"active" => active = true,
|
||||
"inactive" => active = false,
|
||||
other if other.starts_with("dune-server-service ") => {
|
||||
installed_version = other
|
||||
.strip_prefix("dune-server-service ")
|
||||
.map(|s| s.trim().to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(ManagementServiceStatus {
|
||||
installed,
|
||||
active,
|
||||
init_system,
|
||||
installed_version,
|
||||
bundled_version: BUNDLED_VERSION.trim().to_string(),
|
||||
journal_tail: String::new(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
mod battlegroup;
|
||||
mod component;
|
||||
mod discovery;
|
||||
mod logs;
|
||||
mod management_api;
|
||||
mod management_service;
|
||||
mod preflight;
|
||||
pub(crate) mod shared;
|
||||
mod status;
|
||||
mod status_data;
|
||||
mod status_helpers;
|
||||
mod status_naming;
|
||||
mod tunnel;
|
||||
mod tunnel_helpers;
|
||||
|
||||
pub use battlegroup::{
|
||||
restart_remote_battlegroup, start_remote_battlegroup, stop_remote_battlegroup,
|
||||
update_remote_battlegroup,
|
||||
};
|
||||
pub use component::{remote_component_log_tail, restart_remote_component};
|
||||
pub use discovery::detect_remote_ubuntu_servers;
|
||||
pub use logs::{get_logs_folder, record_operation_log};
|
||||
pub use management_api::{
|
||||
ms_cluster, ms_cron_preview, ms_dump_prune_execute, ms_dump_prune_preview, ms_get_config,
|
||||
ms_health, ms_history, ms_list_commands, ms_list_logs, ms_list_runs, ms_list_timezones,
|
||||
ms_player_location, ms_publish, ms_search_items, ms_search_journey_nodes, ms_search_players,
|
||||
ms_search_skill_modules, ms_search_vehicles, ms_search_xp_event_tags, ms_set_config,
|
||||
ms_trigger_run, ms_welcome_grant_retry, ms_welcome_grants, ms_welcome_whisper,
|
||||
};
|
||||
pub use management_service::{
|
||||
install_management_service, management_service_bundled_version, management_service_status,
|
||||
restart_management_service, uninstall_management_service,
|
||||
};
|
||||
pub use preflight::check_remote_sudo;
|
||||
pub use status::{remote_server_components, remote_server_status};
|
||||
pub use tunnel::{
|
||||
server_tunnel_status, start_custom_tunnel, start_server_tunnel, stop_all_tunnels,
|
||||
stop_server_tunnel,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Pre-attach connectivity + sudo checks executed against a candidate host.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dune_manager_core::orchestration::{RemoteCommandRunner, RusshRunner, RusshTarget};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreflightCheck {
|
||||
/// SSH connection + key authentication succeeded.
|
||||
pub ssh_ok: bool,
|
||||
/// The SSH user can `sudo -n -u dune` without a password.
|
||||
pub sudo_to_dune_ok: bool,
|
||||
/// The `dune` user itself has passwordless sudo for arbitrary commands.
|
||||
pub dune_nopasswd_ok: bool,
|
||||
/// Whether the SSH login user IS `dune` (no impersonation needed).
|
||||
pub is_dune_login: bool,
|
||||
/// Raw stdout/stderr collected from the probe script — surfaced in the
|
||||
/// UI when something fails so the operator can see exactly what
|
||||
/// happened on the host.
|
||||
pub raw_output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreflightRequest {
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub key_path: String,
|
||||
#[serde(default)]
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Probes connectivity, SSH auth, and the various sudo capabilities we
|
||||
/// rely on. The result is used to gate the attach flow with a clear error
|
||||
/// banner when something is missing.
|
||||
#[tauri::command]
|
||||
pub async fn check_remote_sudo(request: PreflightRequest) -> Result<PreflightCheck, String> {
|
||||
let host = request.host.trim().to_string();
|
||||
let user = request.user.trim().to_string();
|
||||
let key_path = request.key_path.trim().to_string();
|
||||
let port = request.port;
|
||||
if host.is_empty() || user.is_empty() || key_path.is_empty() {
|
||||
return Err("Host, user, and SSH key path are required.".to_string());
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || run_preflight(host, user, key_path, port))
|
||||
.await
|
||||
.map_err(|err| format!("Preflight worker failed: {err}"))?
|
||||
}
|
||||
|
||||
fn run_preflight(
|
||||
host: String,
|
||||
user: String,
|
||||
key_path: String,
|
||||
port: Option<u16>,
|
||||
) -> Result<PreflightCheck, String> {
|
||||
let mut target = RusshTarget::new(PathBuf::from(&key_path), user.clone(), host.clone());
|
||||
if let Some(p) = port {
|
||||
target.port = p;
|
||||
}
|
||||
target.validate().map_err(|err| err.message)?;
|
||||
let runner = RusshRunner::new(target);
|
||||
let probe = r#"set +e
|
||||
echo SSH_OK
|
||||
if sudo -n -u dune true >/dev/null 2>&1; then echo SUDO_TO_DUNE_OK; else echo SUDO_TO_DUNE_FAILED; fi
|
||||
if sudo -n -u dune sudo -n true >/dev/null 2>&1; then echo DUNE_NOPASSWD_OK; else echo DUNE_NOPASSWD_FAILED; fi
|
||||
echo PREFLIGHT_DONE
|
||||
"#;
|
||||
let stdout = runner.run_script(probe).map_err(|err| {
|
||||
// Connection / auth failures land here. Surface them to the UI so
|
||||
// the operator can fix host/key before retrying.
|
||||
if !err.stderr.trim().is_empty() {
|
||||
format!("{}: {}", err.message, err.stderr.trim())
|
||||
} else {
|
||||
err.message
|
||||
}
|
||||
})?;
|
||||
let ssh_ok = stdout.contains("SSH_OK");
|
||||
let is_dune_login = user == "dune";
|
||||
// When the SSH login is already dune, we do not need a sudo-to-dune
|
||||
// hop; treat it as ok regardless of the probe outcome.
|
||||
let sudo_to_dune_ok = is_dune_login || stdout.contains("SUDO_TO_DUNE_OK");
|
||||
let dune_nopasswd_ok = if is_dune_login {
|
||||
// `sudo -n -u dune sudo -n true` may be rejected when the outer
|
||||
// sudo refuses self-targeting. Fall back to a direct `sudo -n true`
|
||||
// check when the operator is already logged in as dune. Re-run a
|
||||
// quick second probe.
|
||||
let direct = r#"if sudo -n true >/dev/null 2>&1; then echo DUNE_NOPASSWD_OK; else echo DUNE_NOPASSWD_FAILED; fi"#;
|
||||
runner
|
||||
.run_script(direct)
|
||||
.map(|out| out.contains("DUNE_NOPASSWD_OK"))
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
stdout.contains("DUNE_NOPASSWD_OK")
|
||||
};
|
||||
Ok(PreflightCheck {
|
||||
ssh_ok,
|
||||
sudo_to_dune_ok,
|
||||
dune_nopasswd_ok,
|
||||
is_dune_login,
|
||||
raw_output: stdout,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dune_manager_core::models::CommandFailure;
|
||||
use dune_manager_core::orchestration::{RusshRunner, RusshTarget};
|
||||
|
||||
pub fn remote_runner(
|
||||
host: String,
|
||||
user: String,
|
||||
key_path: String,
|
||||
port: Option<u16>,
|
||||
) -> Result<RusshRunner, String> {
|
||||
let mut target = RusshTarget::new(PathBuf::from(key_path), user, host);
|
||||
if let Some(p) = port {
|
||||
target.port = p;
|
||||
}
|
||||
target.validate().map_err(|err| err.message)?;
|
||||
Ok(RusshRunner::new(target))
|
||||
}
|
||||
|
||||
pub fn runner_for_remote_kind(
|
||||
_server_type: Option<&str>,
|
||||
host: String,
|
||||
user: String,
|
||||
key_path: Option<String>,
|
||||
port: Option<u16>,
|
||||
) -> Result<RusshRunner, String> {
|
||||
let key_path = key_path
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "SSH private key is required for remote Ubuntu servers.".to_string())?;
|
||||
remote_runner(host, user, key_path, port)
|
||||
}
|
||||
|
||||
pub fn command_error_message(err: CommandFailure) -> String {
|
||||
let mut parts = vec![err.message];
|
||||
if !err.stderr.trim().is_empty() {
|
||||
parts.push(err.stderr);
|
||||
}
|
||||
if !err.stdout.trim().is_empty() {
|
||||
parts.push(err.stdout);
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
pub fn sh_single_quoted(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use crate::commands::shared::{command_error_message, runner_for_remote_kind};
|
||||
use crate::commands::status_data::{read_remote_server_components, read_remote_server_status};
|
||||
use crate::dto::{RemoteServerActionRequest, RemoteServerComponent, RemoteServerStatus};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_server_status(
|
||||
request: RemoteServerActionRequest,
|
||||
) -> Result<RemoteServerStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let runner = runner_for_remote_kind(
|
||||
request.server_type.as_deref(),
|
||||
request.host,
|
||||
request.user,
|
||||
request.key_path,
|
||||
Some(request.port),
|
||||
)?;
|
||||
read_remote_server_status(&runner, &request.namespace, &request.battlegroup_name)
|
||||
.map_err(command_error_message)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Remote status worker failed: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_server_components(
|
||||
request: RemoteServerActionRequest,
|
||||
) -> Result<Vec<RemoteServerComponent>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let runner = runner_for_remote_kind(
|
||||
request.server_type.as_deref(),
|
||||
request.host,
|
||||
request.user,
|
||||
request.key_path,
|
||||
Some(request.port),
|
||||
)?;
|
||||
read_remote_server_components(&runner, &request.namespace).map_err(command_error_message)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Remote component diagnostics worker failed: {err}"))?
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
use dune_manager_core::errors::failure;
|
||||
use dune_manager_core::models::CommandResult;
|
||||
use dune_manager_core::orchestration::{RemoteCommandRunner, RusshRunner};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::commands::shared::sh_single_quoted;
|
||||
use crate::commands::status_helpers::{pod_component, server_resource_components};
|
||||
use crate::commands::status_naming::friendly_map_name;
|
||||
use crate::dto::{
|
||||
RemoteBattlegroupServerStat, RemoteBattlegroupStatus, RemoteServerComponent,
|
||||
RemoteServerPackageStatus, RemoteServerStatus,
|
||||
};
|
||||
|
||||
pub fn read_remote_server_status(
|
||||
runner: &RusshRunner,
|
||||
namespace: &str,
|
||||
battlegroup_name: &str,
|
||||
) -> CommandResult<RemoteServerStatus> {
|
||||
// The vendor wrapper's `status` text output is the source of truth in
|
||||
// older operator versions, but the format keeps shifting across Funcom
|
||||
// releases (newer wrappers show the partial world name in "Status",
|
||||
// "N/M" ratios under "Director", and semantic words like "Healthy"
|
||||
// under "Uptime" — none of which match the older
|
||||
// `Running/Running/Running/Running/1h2m` shape we used to parse).
|
||||
// Read the BattleGroup CR's `status` object directly so we stay
|
||||
// pinned to the stable Kubernetes schema instead of the rotating
|
||||
// text rendering.
|
||||
let bg = runner.run_json(
|
||||
&format!(
|
||||
"sudo kubectl get battlegroup -n {} {} -o json",
|
||||
sh_single_quoted(namespace),
|
||||
sh_single_quoted(battlegroup_name),
|
||||
),
|
||||
"remote battlegroup",
|
||||
)?;
|
||||
// Per-partition live data (player count, gamePhase, ready) lives on a
|
||||
// separate ServerStats CRD published by the Funcom operator — the same
|
||||
// source `F:\Dune\Server\gt-server-status\gt_server_status.py` consumes.
|
||||
// Failing to fetch this is non-fatal; the table just shows blank
|
||||
// players where it can't be merged.
|
||||
let stats = runner
|
||||
.run_json(
|
||||
&format!(
|
||||
"sudo kubectl get serverstats -n {} -o json",
|
||||
sh_single_quoted(namespace),
|
||||
),
|
||||
"remote serverstats",
|
||||
)
|
||||
.unwrap_or_else(|_| Value::Null);
|
||||
let battlegroup = battlegroup_status_from_json_with_stats(&bg, &stats).ok_or_else(|| {
|
||||
failure(format!(
|
||||
"BattleGroup `{battlegroup_name}` returned no status object yet (likely still initialising)"
|
||||
))
|
||||
})?;
|
||||
let package = read_guest_package_status(runner, namespace, battlegroup_name)?;
|
||||
Ok(RemoteServerStatus {
|
||||
battlegroup,
|
||||
package,
|
||||
})
|
||||
}
|
||||
|
||||
/// Maps a raw `kubectl get battlegroup ... -o json` payload into the UI's
|
||||
/// `RemoteBattlegroupStatus` and merges per-partition
|
||||
/// live data (players, gamePhase, ready) from a `kubectl get serverstats`
|
||||
/// JSON payload. Pass `Value::Null` when no stats are available.
|
||||
pub(crate) fn battlegroup_status_from_json_with_stats(
|
||||
bg: &Value,
|
||||
serverstats: &Value,
|
||||
) -> Option<RemoteBattlegroupStatus> {
|
||||
bg.get("metadata")?.get("name")?.as_str()?;
|
||||
let spec = bg.get("spec").cloned().unwrap_or(Value::Null);
|
||||
let status = bg.get("status").cloned().unwrap_or(Value::Null);
|
||||
|
||||
let stop = spec
|
||||
.get("stop")
|
||||
.and_then(Value::as_bool)
|
||||
.or_else(|| status.get("stop").and_then(Value::as_bool))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Funcom's CR carries `status.startTimestamp` at the BG level (when the
|
||||
// BG first scheduled) but not per-server. We render it on every row as a
|
||||
// best-effort age — accurate when partitions all came up together, off
|
||||
// by however long a partition has restarted independently.
|
||||
let bg_age = status
|
||||
.get("startTimestamp")
|
||||
.and_then(Value::as_str)
|
||||
.map(format_age_since_iso)
|
||||
.unwrap_or_default();
|
||||
|
||||
let stats_by_partition = index_serverstats_by_partition(serverstats);
|
||||
|
||||
let server_stats = status
|
||||
.get("servers")
|
||||
.and_then(Value::as_array)
|
||||
.map(|servers| {
|
||||
servers
|
||||
.iter()
|
||||
.map(|s| server_stat_from_json(s, &bg_age, &stats_by_partition))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Database/director phases are nested in the live CR, not top-level
|
||||
// fields. Fall back to top-level keys for older operator builds.
|
||||
let database_phase = status
|
||||
.get("database")
|
||||
.and_then(|d| d.get("phase"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| string_field(&status, "databasePhase"));
|
||||
let director_phase = status
|
||||
.get("utilities")
|
||||
.and_then(|u| u.get("director"))
|
||||
.and_then(|d| d.get("phase"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| string_field(&status, "directorPhase"));
|
||||
// Uptime: the CR doesn't expose a pre-formatted string anymore, so we
|
||||
// compute it from `status.startTimestamp` (the same field we use for
|
||||
// per-row age). Older operators that set a literal `uptime` string win.
|
||||
let uptime_literal = string_field(&status, "uptime");
|
||||
let uptime = if uptime_literal.is_empty() {
|
||||
bg_age.clone()
|
||||
} else {
|
||||
uptime_literal
|
||||
};
|
||||
|
||||
Some(RemoteBattlegroupStatus {
|
||||
stop,
|
||||
phase: string_field(&status, "phase"),
|
||||
database_phase,
|
||||
server_group_phase: string_field(&status, "serverGroupPhase"),
|
||||
director_phase,
|
||||
uptime,
|
||||
server_stats,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct PartitionStats {
|
||||
players: Option<i64>,
|
||||
}
|
||||
|
||||
/// Build a `partition_index -> PartitionStats` map from a `kubectl get
|
||||
/// serverstats -n <ns> -o json` payload. The Funcom operator emits one
|
||||
/// ServerStats CR per partition with `spec.area.partition` as the id and
|
||||
/// `status.runtime.players` as the live count. Same source the
|
||||
/// `gt_server_status.py` cron script consumes.
|
||||
fn index_serverstats_by_partition(stats: &Value) -> std::collections::HashMap<i64, PartitionStats> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
let Some(items) = stats.get("items").and_then(Value::as_array) else {
|
||||
return out;
|
||||
};
|
||||
for item in items {
|
||||
let partition = item
|
||||
.get("spec")
|
||||
.and_then(|s| s.get("area"))
|
||||
.and_then(|a| a.get("partition"))
|
||||
.and_then(Value::as_i64);
|
||||
let Some(partition) = partition else { continue };
|
||||
let players = item
|
||||
.get("status")
|
||||
.and_then(|s| s.get("runtime"))
|
||||
.and_then(|r| r.get("players"))
|
||||
.and_then(Value::as_i64);
|
||||
out.insert(partition, PartitionStats { players });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn string_field(value: &Value, key: &str) -> String {
|
||||
match value.get(key) {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::Bool(b)) => b.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn server_stat_from_json(
|
||||
server: &Value,
|
||||
bg_age: &str,
|
||||
stats_by_partition: &std::collections::HashMap<i64, PartitionStats>,
|
||||
) -> RemoteBattlegroupServerStat {
|
||||
// The Funcom operator names this field `partitionMap` in the BattleGroup
|
||||
// CR's `status.servers[]` — confirmed against backed-up live CR YAML.
|
||||
// Older / alternate operators have used `map` or `name`, so we keep
|
||||
// those as fallbacks. With no map at all `friendly_map_name` returns
|
||||
// "Game Server" which is what we want to avoid here.
|
||||
let raw_map = server
|
||||
.get("partitionMap")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| server.get("map").and_then(Value::as_str))
|
||||
.or_else(|| server.get("name").and_then(Value::as_str))
|
||||
.unwrap_or_default();
|
||||
let partition_index = server
|
||||
.get("partitionIndex")
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| server.get("ordinalIndex").and_then(Value::as_u64));
|
||||
let friendly = friendly_map_name(raw_map, raw_map);
|
||||
let labelled = match partition_index {
|
||||
Some(idx) => format!("{friendly} #{idx}"),
|
||||
None => friendly,
|
||||
};
|
||||
let ready_str = match server.get("ready") {
|
||||
Some(Value::Bool(b)) => b.to_string(),
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
// The BG CR's status.servers[] entries don't carry a player count or
|
||||
// age; we inherit the BG-level age and merge the per-partition player
|
||||
// count from the matching ServerStats CR (keyed by partitionIndex).
|
||||
let age = if let Some(start) = server.get("startTimestamp").and_then(Value::as_str) {
|
||||
format_age_since_iso(start)
|
||||
} else {
|
||||
bg_age.to_string()
|
||||
};
|
||||
let players = partition_index
|
||||
.and_then(|idx| stats_by_partition.get(&(idx as i64)))
|
||||
.and_then(|s| s.players)
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_default();
|
||||
RemoteBattlegroupServerStat {
|
||||
map: labelled,
|
||||
phase: string_field(server, "phase"),
|
||||
ready: ready_str,
|
||||
players,
|
||||
age,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an RFC 3339 timestamp like `"2026-05-22T01:27:53Z"` as a compact
|
||||
/// elapsed-time string (`5d 3h`, `2h 17m`, `45m`, `12s`). Returns empty
|
||||
/// string when parsing fails — the UI just shows an empty cell.
|
||||
fn format_age_since_iso(iso_ts: &str) -> String {
|
||||
let parsed = chrono::DateTime::parse_from_rfc3339(iso_ts.trim());
|
||||
let Ok(start) = parsed else {
|
||||
return String::new();
|
||||
};
|
||||
let now = chrono::Utc::now();
|
||||
let diff = now.signed_duration_since(start.with_timezone(&chrono::Utc));
|
||||
let secs = diff.num_seconds().max(0);
|
||||
if secs < 60 {
|
||||
return format!("{secs}s");
|
||||
}
|
||||
let minutes = secs / 60;
|
||||
if minutes < 60 {
|
||||
return format!("{minutes}m");
|
||||
}
|
||||
let hours = minutes / 60;
|
||||
let mins_rem = minutes % 60;
|
||||
if hours < 24 {
|
||||
return format!("{hours}h {mins_rem}m");
|
||||
}
|
||||
let days = hours / 24;
|
||||
let hours_rem = hours % 24;
|
||||
format!("{days}d {hours_rem}h")
|
||||
}
|
||||
|
||||
fn read_guest_package_status(
|
||||
runner: &RusshRunner,
|
||||
namespace: &str,
|
||||
battlegroup_name: &str,
|
||||
) -> CommandResult<RemoteServerPackageStatus> {
|
||||
let script = r#"
|
||||
set -u
|
||||
download=/home/dune/.dune/download
|
||||
manifest="$download/steamapps/appmanifest_4754530.acf"
|
||||
ns=__NAMESPACE__
|
||||
bg=__BATTLEGROUP__
|
||||
read_vdf_value() {
|
||||
key="$1"
|
||||
file="$2"
|
||||
[ -f "$file" ] || return 0
|
||||
awk -F '"' -v wanted="$key" '$2 == wanted { print $4; exit }' "$file" 2>/dev/null || true
|
||||
}
|
||||
read_file() {
|
||||
file="$1"
|
||||
[ -f "$file" ] || return 0
|
||||
head -n 1 "$file" 2>/dev/null | tr -d '\r\n'
|
||||
}
|
||||
printf 'installedBuildId=%s\n' "$(read_vdf_value buildid "$manifest")"
|
||||
printf 'battlegroupVersion=%s\n' "$(read_file "$download/images/battlegroup/version.txt")"
|
||||
printf 'operatorVersion=%s\n' "$(read_file "$download/images/operators/version.txt")"
|
||||
live_image=$(sudo kubectl get battlegroup "$bg" -n "$ns" -o jsonpath='{..image}' 2>/dev/null | tr ' ' '\n' | awk -F: '/self-hosting\/(igw-server|seabass-server):/ { print $NF; exit }' || true)
|
||||
printf 'liveBattlegroupVersion=%s\n' "$live_image"
|
||||
"#
|
||||
.replace("__NAMESPACE__", &sh_single_quoted(namespace))
|
||||
.replace("__BATTLEGROUP__", &sh_single_quoted(battlegroup_name));
|
||||
let output = runner.run_script(&script)?;
|
||||
let value = |key: &str| {
|
||||
output.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once('=')?;
|
||||
(name == key && !value.trim().is_empty()).then(|| value.trim().to_string())
|
||||
})
|
||||
};
|
||||
Ok(RemoteServerPackageStatus {
|
||||
installed_build_id: value("installedBuildId"),
|
||||
battlegroup_version: value("battlegroupVersion"),
|
||||
live_battlegroup_version: value("liveBattlegroupVersion"),
|
||||
operator_version: value("operatorVersion"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_remote_server_components(
|
||||
runner: &RusshRunner,
|
||||
namespace: &str,
|
||||
) -> CommandResult<Vec<RemoteServerComponent>> {
|
||||
let pods = runner.run_json(
|
||||
&format!(
|
||||
"sudo kubectl get pods -n {} -o json",
|
||||
sh_single_quoted(namespace)
|
||||
),
|
||||
"remote server pods",
|
||||
)?;
|
||||
let resources = runner.run_json(
|
||||
&format!(
|
||||
"sudo kubectl get servergroups,servergateways,serversets -n {} -o json",
|
||||
sh_single_quoted(namespace)
|
||||
),
|
||||
"remote server resources",
|
||||
)?;
|
||||
|
||||
let mut components = vec![
|
||||
pod_component("Database", "database", &pods, |role, name| {
|
||||
role.contains("database") && !name.contains("-util-")
|
||||
}),
|
||||
pod_component(
|
||||
"Database utilities",
|
||||
"database-utilities",
|
||||
&pods,
|
||||
|role, _| {
|
||||
role.contains("database-utility")
|
||||
|| role.contains("database-monitor")
|
||||
|| role.contains("database-pghero")
|
||||
},
|
||||
),
|
||||
pod_component("Message Queue", "message-queue", &pods, |role, name| {
|
||||
role.contains("message-queue") || name.contains("-mq-")
|
||||
}),
|
||||
pod_component("Director", "director", &pods, |role, name| {
|
||||
role.contains("battlegroup-director") || name.contains("-bgd-")
|
||||
}),
|
||||
pod_component("Gateway", "gateway", &pods, |role, name| {
|
||||
role.contains("server-gateway") || name.contains("-sgw-")
|
||||
}),
|
||||
pod_component("Text Router", "text-router", &pods, |role, name| {
|
||||
role.contains("text-router") || name.contains("-tr-")
|
||||
}),
|
||||
pod_component("File Browser", "file-browser", &pods, |role, name| {
|
||||
role.contains("filebrowser") || name.contains("-fb-")
|
||||
}),
|
||||
];
|
||||
components.extend(server_resource_components(&resources));
|
||||
Ok(components
|
||||
.into_iter()
|
||||
.filter(|component| component.state != "Not present")
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn remote_records_from_battlegroups(
|
||||
request: &crate::dto::RemoteConnectionRequest,
|
||||
value: &Value,
|
||||
) -> Vec<crate::dto::RemoteServerRecord> {
|
||||
value
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| remote_record_from_battlegroup(request, item))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn remote_record_from_battlegroup(
|
||||
request: &crate::dto::RemoteConnectionRequest,
|
||||
item: &Value,
|
||||
) -> Option<crate::dto::RemoteServerRecord> {
|
||||
let namespace = item
|
||||
.get("metadata")?
|
||||
.get("namespace")?
|
||||
.as_str()?
|
||||
.to_string();
|
||||
let battlegroup_name = item.get("metadata")?.get("name")?.as_str()?.to_string();
|
||||
let title = item
|
||||
.get("spec")
|
||||
.and_then(|spec| spec.get("title"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&battlegroup_name)
|
||||
.to_string();
|
||||
let phase = item
|
||||
.get("status")
|
||||
.and_then(|status| status.get("phase"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let server_type = request
|
||||
.server_type
|
||||
.as_deref()
|
||||
.unwrap_or("ubuntu")
|
||||
.trim()
|
||||
.to_string();
|
||||
let user = request
|
||||
.user
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
Some(crate::dto::RemoteServerRecord {
|
||||
id: remote_record_id(&server_type, &request.host, request.key_path.as_deref()),
|
||||
name: title,
|
||||
host: request.host.clone(),
|
||||
user,
|
||||
key_path: request.key_path.clone().unwrap_or_default(),
|
||||
port: request.port,
|
||||
server_type,
|
||||
namespace,
|
||||
battlegroup_name: battlegroup_name.clone(),
|
||||
world_unique_name: battlegroup_name,
|
||||
phase,
|
||||
})
|
||||
}
|
||||
|
||||
fn remote_record_id(_server_type: &str, host: &str, key_path: Option<&str>) -> String {
|
||||
format!(
|
||||
"ubuntu:{}:{}",
|
||||
host.trim().to_lowercase(),
|
||||
key_path.unwrap_or_default().trim().to_lowercase()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn bg(spec: Value, status: Value) -> Value {
|
||||
json!({
|
||||
"metadata": {"name": "sh-test-bg", "namespace": "funcom-seabass-sh-test"},
|
||||
"spec": spec,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
fn bg_status(bg: &Value) -> Option<RemoteBattlegroupStatus> {
|
||||
battlegroup_status_from_json_with_stats(bg, &Value::Null)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_reconciling_bg_with_null_director_phase() {
|
||||
// Mirrors the user-reported payload: phase Reconciling, gateway
|
||||
// Running, director not yet populated. Prior text-parse path was
|
||||
// confusing the UI into greying the Director tunnel; under direct
|
||||
// kubectl read the director_phase is just "" which the UI treats
|
||||
// as "ready enough".
|
||||
let value = bg(
|
||||
json!({"stop": false}),
|
||||
json!({
|
||||
"phase": "Reconciling",
|
||||
"serverGroupPhase": "Running",
|
||||
"directorPhase": Value::Null,
|
||||
"stop": Value::Null,
|
||||
}),
|
||||
);
|
||||
let dto = bg_status(&value).expect("status maps");
|
||||
assert!(!dto.stop);
|
||||
assert_eq!(dto.phase, "Reconciling");
|
||||
assert_eq!(dto.server_group_phase, "Running");
|
||||
assert_eq!(dto.director_phase, "");
|
||||
assert_eq!(dto.uptime, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_status_stop_when_spec_missing() {
|
||||
let value = bg(json!({}), json!({"phase": "Stopped", "stop": true}));
|
||||
let dto = bg_status(&value).expect("status maps");
|
||||
assert!(dto.stop);
|
||||
assert_eq!(dto.phase, "Stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_stats_pulled_from_status_servers_array() {
|
||||
let value = bg(
|
||||
json!({"stop": false}),
|
||||
json!({
|
||||
"phase": "Running",
|
||||
"servers": [
|
||||
{"map": "Survival_1", "phase": "Running", "ready": true},
|
||||
{"name": "DeepDesert_1", "phase": "Stopped", "ready": false},
|
||||
]
|
||||
}),
|
||||
);
|
||||
let dto = bg_status(&value).expect("status maps");
|
||||
assert_eq!(dto.server_stats.len(), 2);
|
||||
assert_eq!(
|
||||
dto.server_stats[0].map,
|
||||
friendly_map_name("Survival_1", "Survival_1")
|
||||
);
|
||||
assert_eq!(dto.server_stats[0].phase, "Running");
|
||||
assert_eq!(dto.server_stats[0].ready, "true");
|
||||
// Players empty when no ServerStats CR is supplied — that data lives
|
||||
// on a separate CRD and is merged via `_with_stats`.
|
||||
assert_eq!(dto.server_stats[0].players, "");
|
||||
assert_eq!(
|
||||
dto.server_stats[1].map,
|
||||
friendly_map_name("DeepDesert_1", "DeepDesert_1")
|
||||
);
|
||||
assert_eq!(dto.server_stats[1].ready, "false");
|
||||
assert_eq!(dto.server_stats[1].age, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_stats_merge_player_count_from_serverstats_crd() {
|
||||
// Mirrors the data shape gt_server_status.py reads: each ServerStats
|
||||
// CR has spec.area.partition matching the BG's partitionIndex, and
|
||||
// status.runtime.players is the live count.
|
||||
let value = bg(
|
||||
json!({"stop": false}),
|
||||
json!({
|
||||
"phase": "Healthy",
|
||||
"servers": [
|
||||
{"partitionMap": "Survival_1", "partitionIndex": 1, "phase": "Running", "ready": true},
|
||||
{"partitionMap": "Survival_1", "partitionIndex": 31, "phase": "Running", "ready": true},
|
||||
{"partitionMap": "Overmap", "partitionIndex": 2, "phase": "Running", "ready": true},
|
||||
],
|
||||
}),
|
||||
);
|
||||
let stats = json!({
|
||||
"items": [
|
||||
{"spec": {"area": {"partition": 1, "map": "Survival_1"}}, "status": {"runtime": {"players": 7}}},
|
||||
{"spec": {"area": {"partition": 31, "map": "Survival_1"}}, "status": {"runtime": {"players": 0}}},
|
||||
{"spec": {"area": {"partition": 2, "map": "Overmap"}}, "status": {"runtime": {"players": 3}}},
|
||||
],
|
||||
});
|
||||
let dto = battlegroup_status_from_json_with_stats(&value, &stats).expect("status maps");
|
||||
assert_eq!(dto.server_stats[0].players, "7");
|
||||
assert_eq!(dto.server_stats[1].players, "0");
|
||||
assert_eq!(dto.server_stats[2].players, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_stats_player_count_blank_when_partition_missing_from_stats() {
|
||||
let value = bg(
|
||||
json!({"stop": false}),
|
||||
json!({
|
||||
"servers": [
|
||||
{"partitionMap": "Survival_1", "partitionIndex": 1, "phase": "Running", "ready": true},
|
||||
],
|
||||
}),
|
||||
);
|
||||
let stats = json!({"items": []});
|
||||
let dto = battlegroup_status_from_json_with_stats(&value, &stats).expect("status maps");
|
||||
assert_eq!(dto.server_stats[0].players, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_stats_use_partition_map_and_index_from_real_cr() {
|
||||
// Mirrors the actual Funcom operator status.servers[] shape captured
|
||||
// from a live BattleGroup CR backup. Pre-fix the map column showed
|
||||
// "Game Server" for every row because we were reading `map`/`name`
|
||||
// instead of `partitionMap`.
|
||||
let value = bg(
|
||||
json!({"stop": false}),
|
||||
json!({
|
||||
"phase": "Healthy",
|
||||
"servers": [
|
||||
{
|
||||
"partitionMap": "Survival_1",
|
||||
"partitionIndex": 1,
|
||||
"phase": "Running",
|
||||
"ready": true,
|
||||
},
|
||||
{
|
||||
"partitionMap": "Survival_1",
|
||||
"partitionIndex": 31,
|
||||
"phase": "Running",
|
||||
"ready": true,
|
||||
},
|
||||
{
|
||||
"partitionMap": "Overmap",
|
||||
"partitionIndex": 2,
|
||||
"phase": "Running",
|
||||
"ready": true,
|
||||
},
|
||||
]
|
||||
}),
|
||||
);
|
||||
let dto = bg_status(&value).expect("status maps");
|
||||
assert_eq!(dto.server_stats.len(), 3);
|
||||
assert_eq!(dto.server_stats[0].map, "Hagga Basin #1");
|
||||
assert_eq!(dto.server_stats[1].map, "Hagga Basin #31");
|
||||
assert_eq!(dto.server_stats[2].map, "Overmap #2");
|
||||
assert!(dto.server_stats.iter().all(|s| s.phase == "Running"));
|
||||
assert!(dto.server_stats.iter().all(|s| s.ready == "true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_not_a_battlegroup_resource() {
|
||||
let value = json!({"kind": "Pod", "spec": {}, "status": {}});
|
||||
assert!(bg_status(&value).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bg_start_timestamp_propagates_to_every_server_row_when_per_server_missing() {
|
||||
// status.startTimestamp from the live CR backup is one minute in the
|
||||
// past for this test.
|
||||
let one_min_ago = (chrono::Utc::now() - chrono::Duration::minutes(1))
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
let value = bg(
|
||||
json!({"stop": false}),
|
||||
json!({
|
||||
"phase": "Running",
|
||||
"startTimestamp": one_min_ago,
|
||||
"servers": [
|
||||
{"partitionMap": "Survival_1", "partitionIndex": 1, "phase": "Running", "ready": true},
|
||||
{"partitionMap": "Overmap", "partitionIndex": 2, "phase": "Running", "ready": true},
|
||||
],
|
||||
}),
|
||||
);
|
||||
let dto = bg_status(&value).expect("status maps");
|
||||
// All rows pick up the same BG-level age.
|
||||
assert_eq!(dto.server_stats.len(), 2);
|
||||
for row in &dto.server_stats {
|
||||
assert!(
|
||||
row.age == "1m" || row.age == "60s",
|
||||
"row age was {:?}",
|
||||
row.age
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_director_phases_pulled_from_nested_status() {
|
||||
// Live CR shape: status.database.phase + status.utilities.director.phase,
|
||||
// not top-level databasePhase/directorPhase.
|
||||
let value = bg(
|
||||
json!({"stop": false}),
|
||||
json!({
|
||||
"phase": "Healthy",
|
||||
"serverGroupPhase": "Running",
|
||||
"database": {"phase": "Ready", "address": "1.2.3.4:15432"},
|
||||
"utilities": {
|
||||
"director": {"phase": "Healthy", "address": "1.2.3.4:30393"},
|
||||
},
|
||||
}),
|
||||
);
|
||||
let dto = bg_status(&value).expect("status maps");
|
||||
assert_eq!(dto.database_phase, "Ready");
|
||||
assert_eq!(dto.director_phase, "Healthy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptime_derived_from_start_timestamp_when_no_literal() {
|
||||
let one_hr_ago =
|
||||
(chrono::Utc::now() - chrono::Duration::hours(1) - chrono::Duration::minutes(2))
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
let value = bg(
|
||||
json!({"stop": false}),
|
||||
json!({"phase": "Healthy", "startTimestamp": one_hr_ago}),
|
||||
);
|
||||
let dto = bg_status(&value).expect("status maps");
|
||||
assert_eq!(dto.uptime, "1h 2m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptime_prefers_literal_string_when_older_operator_set_it() {
|
||||
let value = bg(
|
||||
json!({"stop": false}),
|
||||
json!({
|
||||
"phase": "Healthy",
|
||||
"uptime": "1h2m",
|
||||
"startTimestamp": "2026-05-22T01:27:53Z",
|
||||
}),
|
||||
);
|
||||
let dto = bg_status(&value).expect("status maps");
|
||||
assert_eq!(dto.uptime, "1h2m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_age_since_iso_handles_common_shapes() {
|
||||
assert_eq!(format_age_since_iso(""), "");
|
||||
assert_eq!(format_age_since_iso("not a timestamp"), "");
|
||||
let recent = (chrono::Utc::now() - chrono::Duration::seconds(30))
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
assert!(format_age_since_iso(&recent).ends_with('s'));
|
||||
let hours =
|
||||
(chrono::Utc::now() - chrono::Duration::hours(3) - chrono::Duration::minutes(15))
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
assert_eq!(format_age_since_iso(&hours), "3h 15m");
|
||||
let days = (chrono::Utc::now() - chrono::Duration::days(5) - chrono::Duration::hours(7))
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
assert_eq!(format_age_since_iso(&days), "5d 7h");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::commands::status_naming::{friendly_map_name, serverset_log_key};
|
||||
use crate::dto::RemoteServerComponent;
|
||||
|
||||
pub fn pod_component(
|
||||
label: &str,
|
||||
log_key: &str,
|
||||
pods: &Value,
|
||||
matches: impl Fn(&str, &str) -> bool,
|
||||
) -> RemoteServerComponent {
|
||||
let mut total = 0usize;
|
||||
let mut ready = 0usize;
|
||||
let mut restarts = 0u64;
|
||||
let mut reasons = Vec::new();
|
||||
let mut phases = Vec::new();
|
||||
for item in pods["items"].as_array().cloned().unwrap_or_default() {
|
||||
let name = item["metadata"]["name"].as_str().unwrap_or_default();
|
||||
let role = item["metadata"]["labels"]["role"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
if !matches(role, name) {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
let phase = item["status"]["phase"].as_str().unwrap_or_default();
|
||||
if !phase.is_empty() {
|
||||
phases.push(phase.to_string());
|
||||
}
|
||||
let statuses = item["status"]["containerStatuses"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let pod_ready = !statuses.is_empty()
|
||||
&& statuses
|
||||
.iter()
|
||||
.all(|status| status["ready"].as_bool().unwrap_or(false));
|
||||
if pod_ready || phase == "Succeeded" {
|
||||
ready += 1;
|
||||
}
|
||||
for status in statuses {
|
||||
restarts += status["restartCount"].as_u64().unwrap_or_default();
|
||||
if let Some(reason) = status["state"]["waiting"]["reason"].as_str() {
|
||||
reasons.push(reason.to_string());
|
||||
}
|
||||
if let Some(reason) = status["state"]["terminated"]["reason"].as_str() {
|
||||
if reason != "Completed" {
|
||||
reasons.push(reason.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return component(
|
||||
label,
|
||||
log_key,
|
||||
"system",
|
||||
"Not present",
|
||||
"gray",
|
||||
"No matching runtime component was found.",
|
||||
vec![],
|
||||
);
|
||||
}
|
||||
let details = compact_details(vec![
|
||||
format!("{ready}/{total} pods ready"),
|
||||
if restarts > 0 {
|
||||
format!("{restarts} container restarts")
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
if reasons.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("Reason: {}", reasons.join(", "))
|
||||
},
|
||||
]);
|
||||
if ready == total && reasons.is_empty() {
|
||||
component(
|
||||
label,
|
||||
log_key,
|
||||
"system",
|
||||
"Ready",
|
||||
"green",
|
||||
"All pods are ready.",
|
||||
details,
|
||||
)
|
||||
} else if reasons.iter().any(|reason| is_bad_reason(reason))
|
||||
|| phases.iter().any(|phase| phase == "Failed")
|
||||
{
|
||||
component(
|
||||
label,
|
||||
log_key,
|
||||
"system",
|
||||
"Problem",
|
||||
"red",
|
||||
"One or more pods are failing.",
|
||||
details,
|
||||
)
|
||||
} else {
|
||||
component(
|
||||
label,
|
||||
log_key,
|
||||
"system",
|
||||
"Starting",
|
||||
"amber",
|
||||
"Waiting for pods to become ready.",
|
||||
details,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_resource_components(resources: &Value) -> Vec<RemoteServerComponent> {
|
||||
let mut items = resources["items"].as_array().cloned().unwrap_or_default();
|
||||
items.sort_by(|left, right| {
|
||||
left["metadata"]["name"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(right["metadata"]["name"].as_str().unwrap_or_default())
|
||||
});
|
||||
let mut output = Vec::new();
|
||||
for item in items {
|
||||
let kind = item["kind"].as_str().unwrap_or_default();
|
||||
let name = item["metadata"]["name"].as_str().unwrap_or_default();
|
||||
match kind {
|
||||
"ServerGroup" => output.push(server_group_component(&item)),
|
||||
"ServerGateway" => output.push(resource_phase_component("Gateway Resource", &item)),
|
||||
"ServerSet" => {
|
||||
if should_show_serverset(&item) {
|
||||
output.push(serverset_component(name, &item));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn server_group_component(item: &Value) -> RemoteServerComponent {
|
||||
let phase = item["status"]["phase"].as_str().unwrap_or("Unknown");
|
||||
phase_component(
|
||||
"Server Group",
|
||||
"server-group",
|
||||
"system",
|
||||
phase,
|
||||
format!("Server Group reports {phase}."),
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
|
||||
fn resource_phase_component(label: &str, item: &Value) -> RemoteServerComponent {
|
||||
let phase = item["status"]["phase"].as_str().unwrap_or("Unknown");
|
||||
phase_component(
|
||||
label,
|
||||
"gateway-resource",
|
||||
"system",
|
||||
phase,
|
||||
format!("{label} reports {phase}."),
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
|
||||
fn serverset_component(name: &str, item: &Value) -> RemoteServerComponent {
|
||||
let map = item["spec"]["map"].as_str().unwrap_or_default();
|
||||
let label = friendly_map_name(map, name);
|
||||
let phase = item["status"]["phase"].as_str().unwrap_or("Unknown");
|
||||
let target = item["status"]["targetReplicas"]
|
||||
.as_u64()
|
||||
.unwrap_or_default();
|
||||
let ready = item["status"]["readyReplicas"].as_u64().unwrap_or_default();
|
||||
let completed = item["status"]["completedReplicas"]
|
||||
.as_u64()
|
||||
.unwrap_or_default();
|
||||
let pods = item["status"]["pods"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let game_ready = pods
|
||||
.iter()
|
||||
.filter(|pod| pod["ready"].as_bool().unwrap_or(false))
|
||||
.count();
|
||||
let details = compact_details(vec![
|
||||
format!("{ready}/{target} Kubernetes-ready replicas"),
|
||||
format!("{completed}/{target} completed game replicas"),
|
||||
format!("{game_ready}/{target} game-ready servers"),
|
||||
]);
|
||||
let summary =
|
||||
if phase == "Initializing" && ready >= target && target > 0 && game_ready < target as usize
|
||||
{
|
||||
"Game process is running, but game readiness has not completed.".to_string()
|
||||
} else {
|
||||
format!("{label} reports {phase}.")
|
||||
};
|
||||
phase_component(
|
||||
&label,
|
||||
&serverset_log_key(name, map),
|
||||
"map",
|
||||
phase,
|
||||
summary,
|
||||
details,
|
||||
)
|
||||
}
|
||||
|
||||
fn should_show_serverset(item: &Value) -> bool {
|
||||
let phase = item["status"]["phase"].as_str().unwrap_or_default();
|
||||
let target = item["status"]["targetReplicas"]
|
||||
.as_u64()
|
||||
.unwrap_or_default();
|
||||
let map = item["spec"]["map"].as_str().unwrap_or_default();
|
||||
phase != "Stopped" || target > 0 || matches!(map, "Survival_1" | "Overmap" | "DeepDesert_1")
|
||||
}
|
||||
|
||||
fn phase_component(
|
||||
label: &str,
|
||||
log_key: &str,
|
||||
category: &str,
|
||||
phase: &str,
|
||||
summary: String,
|
||||
details: Vec<String>,
|
||||
) -> RemoteServerComponent {
|
||||
let normalized = phase.to_ascii_lowercase();
|
||||
let (state, tone) = match normalized.as_str() {
|
||||
"healthy" | "running" | "ready" | "available" => ("Ready", "green"),
|
||||
"stopped" | "suspended" => ("Stopped", "gray"),
|
||||
"initializing" | "reconciling" | "pending" | "starting" => ("Starting", "amber"),
|
||||
"failed" | "error" | "degraded" => ("Problem", "red"),
|
||||
_ => ("Unknown", "amber"),
|
||||
};
|
||||
component(label, log_key, category, state, tone, summary, details)
|
||||
}
|
||||
|
||||
fn component(
|
||||
name: &str,
|
||||
log_key: &str,
|
||||
category: &str,
|
||||
state: &str,
|
||||
tone: &str,
|
||||
summary: impl Into<String>,
|
||||
details: Vec<String>,
|
||||
) -> RemoteServerComponent {
|
||||
RemoteServerComponent {
|
||||
name: name.to_string(),
|
||||
log_key: log_key.to_string(),
|
||||
category: category.to_string(),
|
||||
state: state.to_string(),
|
||||
tone: tone.to_string(),
|
||||
summary: summary.into(),
|
||||
details,
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_details(values: Vec<String>) -> Vec<String> {
|
||||
values
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_bad_reason(reason: &str) -> bool {
|
||||
matches!(
|
||||
reason,
|
||||
"CrashLoopBackOff"
|
||||
| "ImagePullBackOff"
|
||||
| "ErrImagePull"
|
||||
| "CreateContainerConfigError"
|
||||
| "CreateContainerError"
|
||||
| "RunContainerError"
|
||||
| "OOMKilled"
|
||||
| "Error"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
pub fn friendly_map_name(map: &str, fallback_name: &str) -> String {
|
||||
let normalized = map.to_ascii_lowercase();
|
||||
if normalized == "survival_1" || fallback_name.contains("survival-1") {
|
||||
return "Hagga Basin".to_string();
|
||||
}
|
||||
if normalized == "overmap" || fallback_name.contains("overmap") {
|
||||
return "Overmap".to_string();
|
||||
}
|
||||
if normalized.contains("deepdesert") || fallback_name.contains("deepdesert") {
|
||||
return "Deep Desert".to_string();
|
||||
}
|
||||
if fallback_name.contains("sh-arrakeen") {
|
||||
return "Social Hub: Arrakeen".to_string();
|
||||
}
|
||||
if fallback_name.contains("sh-harkovillage") {
|
||||
return "Social Hub: Harko Village".to_string();
|
||||
}
|
||||
if !map.is_empty() {
|
||||
return map.replace('_', " ");
|
||||
}
|
||||
"Game Server".to_string()
|
||||
}
|
||||
|
||||
pub fn serverset_log_key(name: &str, map: &str) -> String {
|
||||
let combined = format!("{name} {map}").to_ascii_lowercase();
|
||||
if map.eq_ignore_ascii_case("Survival_1") || combined.contains("survival-1") {
|
||||
return "map-survival-1".to_string();
|
||||
}
|
||||
if map.eq_ignore_ascii_case("Overmap") || combined.contains("overmap") {
|
||||
return "map-overmap".to_string();
|
||||
}
|
||||
if combined.contains("deepdesert") || combined.contains("deep-desert") {
|
||||
return "map-deepdesert".to_string();
|
||||
}
|
||||
if combined.contains("sh-arrakeen") {
|
||||
return "map-social-arrakeen".to_string();
|
||||
}
|
||||
if combined.contains("sh-harkovillage") {
|
||||
return "map-social-harkovillage".to_string();
|
||||
}
|
||||
format!("map-{}", sanitize_component_key(map))
|
||||
}
|
||||
|
||||
fn sanitize_component_key(value: &str) -> String {
|
||||
let key = value
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
character.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
if key.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
key
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use dune_manager_core::orchestration::{LocalForwarder, RusshTarget};
|
||||
|
||||
use crate::commands::tunnel_helpers::{
|
||||
discover_database_tunnel_port, discover_director_tunnel_port, discover_pg_hero_tunnel_port,
|
||||
normalize_tunnel_service, tunnel_target, tunnel_url,
|
||||
};
|
||||
use crate::dto::{
|
||||
CustomTunnelStartRequest, ServerTunnelStartRequest, ServerTunnelStatus, ServerTunnelStopRequest,
|
||||
};
|
||||
use crate::state::{ManagedTunnel, TunnelRegistry};
|
||||
|
||||
const MANAGEMENT_API_PORT: u16 = 29187;
|
||||
const LEGACY_MANAGEMENT_API_PORT: u16 = 8787;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_server_tunnel(
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
request: ServerTunnelStartRequest,
|
||||
) -> Result<ServerTunnelStatus, String> {
|
||||
let registry = registry.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || start_server_tunnel_inner(®istry, request))
|
||||
.await
|
||||
.map_err(|err| format!("Tunnel worker failed: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_server_tunnel(
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
request: ServerTunnelStopRequest,
|
||||
) -> Result<(), String> {
|
||||
let registry = registry.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
stop_server_tunnel_inner(®istry, &request.tunnel_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Tunnel stop worker failed: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn server_tunnel_status(
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
request: ServerTunnelStopRequest,
|
||||
) -> Result<Option<ServerTunnelStatus>, String> {
|
||||
let registry = registry.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
existing_running_tunnel(®istry, request.tunnel_id.trim())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Tunnel status worker failed: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_all_tunnels(registry: tauri::State<'_, TunnelRegistry>) -> Result<(), String> {
|
||||
registry.stop_all();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_custom_tunnel(
|
||||
registry: tauri::State<'_, TunnelRegistry>,
|
||||
request: CustomTunnelStartRequest,
|
||||
) -> Result<ServerTunnelStatus, String> {
|
||||
let registry = registry.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || start_custom_tunnel_inner(®istry, request))
|
||||
.await
|
||||
.map_err(|err| format!("Tunnel worker failed: {err}"))?
|
||||
}
|
||||
|
||||
fn start_custom_tunnel_inner(
|
||||
registry: &TunnelRegistry,
|
||||
request: CustomTunnelStartRequest,
|
||||
) -> Result<ServerTunnelStatus, String> {
|
||||
let tunnel_id = request.tunnel_id.trim();
|
||||
if tunnel_id.is_empty() {
|
||||
return Err("Tunnel id is required.".to_string());
|
||||
}
|
||||
if let Some(status) = existing_running_tunnel(registry, tunnel_id)? {
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
let target = match request.server_kind.trim() {
|
||||
"ubuntu" => {
|
||||
let mut t = RusshTarget::new(
|
||||
PathBuf::from(request.key_path.as_deref().unwrap_or_default().trim()),
|
||||
request.user.trim().to_string(),
|
||||
request.host.trim().to_string(),
|
||||
);
|
||||
if request.port != 0 {
|
||||
t.port = request.port;
|
||||
}
|
||||
t.validate().map_err(|err| err.message)?;
|
||||
t
|
||||
}
|
||||
other => return Err(format!("Unsupported remote server kind: {other}")),
|
||||
};
|
||||
|
||||
let forwarder = LocalForwarder::start(
|
||||
&target,
|
||||
request.local_port,
|
||||
"127.0.0.1",
|
||||
request.remote_port,
|
||||
)
|
||||
.map_err(|err| err.message)?;
|
||||
let local_port = forwarder.local_port();
|
||||
|
||||
let url = match request.protocol.trim() {
|
||||
"https" => format!("https://127.0.0.1:{local_port}/"),
|
||||
"postgresql" => format!("postgresql://127.0.0.1:{local_port}/"),
|
||||
_ => format!("http://127.0.0.1:{local_port}/"),
|
||||
};
|
||||
|
||||
let status = ServerTunnelStatus {
|
||||
tunnel_id: tunnel_id.to_string(),
|
||||
service: "custom".to_string(),
|
||||
local_port,
|
||||
remote_port: request.remote_port,
|
||||
url,
|
||||
};
|
||||
let mut tunnels = registry
|
||||
.tunnels
|
||||
.lock()
|
||||
.map_err(|_| "Tunnel registry is unavailable.".to_string())?;
|
||||
if let Some(existing) = tunnels.remove(tunnel_id) {
|
||||
existing.forwarder.stop();
|
||||
}
|
||||
tunnels.insert(
|
||||
tunnel_id.to_string(),
|
||||
ManagedTunnel {
|
||||
forwarder,
|
||||
status: status.clone(),
|
||||
},
|
||||
);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn start_server_tunnel_inner(
|
||||
registry: &TunnelRegistry,
|
||||
request: ServerTunnelStartRequest,
|
||||
) -> Result<ServerTunnelStatus, String> {
|
||||
let tunnel_id = request.tunnel_id.trim();
|
||||
if tunnel_id.is_empty() {
|
||||
return Err("Tunnel id is required.".to_string());
|
||||
}
|
||||
if let Some(status) = existing_running_tunnel(registry, tunnel_id)? {
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
let target = tunnel_target(&request)?;
|
||||
let service = normalize_tunnel_service(&request.service)?;
|
||||
let remote_port = match service.as_str() {
|
||||
"director" => discover_director_tunnel_port(&target, &request.namespace)?,
|
||||
"fileBrowser" => 18888,
|
||||
"database" => discover_database_tunnel_port(&target, &request.namespace)?,
|
||||
"pgHero" => discover_pg_hero_tunnel_port(&target, &request.namespace)?,
|
||||
"managementApi" => MANAGEMENT_API_PORT,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if service == "managementApi" {
|
||||
return start_management_api_tunnel(registry, tunnel_id, &target, &service);
|
||||
}
|
||||
|
||||
let forwarder =
|
||||
LocalForwarder::start(&target, 0, "127.0.0.1", remote_port).map_err(|err| err.message)?;
|
||||
let local_port = forwarder.local_port();
|
||||
|
||||
let status = ServerTunnelStatus {
|
||||
tunnel_id: tunnel_id.to_string(),
|
||||
url: tunnel_url(&service, local_port),
|
||||
service,
|
||||
local_port,
|
||||
remote_port,
|
||||
};
|
||||
let mut tunnels = registry
|
||||
.tunnels
|
||||
.lock()
|
||||
.map_err(|_| "Tunnel registry is unavailable.".to_string())?;
|
||||
if let Some(existing) = tunnels.remove(tunnel_id) {
|
||||
existing.forwarder.stop();
|
||||
}
|
||||
tunnels.insert(
|
||||
tunnel_id.to_string(),
|
||||
ManagedTunnel {
|
||||
forwarder,
|
||||
status: status.clone(),
|
||||
},
|
||||
);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn start_management_api_tunnel(
|
||||
registry: &TunnelRegistry,
|
||||
tunnel_id: &str,
|
||||
target: &RusshTarget,
|
||||
service: &str,
|
||||
) -> Result<ServerTunnelStatus, String> {
|
||||
let mut last_error = String::new();
|
||||
for remote_port in [MANAGEMENT_API_PORT, LEGACY_MANAGEMENT_API_PORT] {
|
||||
let forwarder = LocalForwarder::start(target, 0, "127.0.0.1", remote_port)
|
||||
.map_err(|err| err.message)?;
|
||||
let local_port = forwarder.local_port();
|
||||
match probe_management_api(local_port) {
|
||||
Ok(()) => {
|
||||
let status = ServerTunnelStatus {
|
||||
tunnel_id: tunnel_id.to_string(),
|
||||
url: tunnel_url(service, local_port),
|
||||
service: service.to_string(),
|
||||
local_port,
|
||||
remote_port,
|
||||
};
|
||||
let mut tunnels = registry
|
||||
.tunnels
|
||||
.lock()
|
||||
.map_err(|_| "Tunnel registry is unavailable.".to_string())?;
|
||||
if let Some(existing) = tunnels.remove(tunnel_id) {
|
||||
existing.forwarder.stop();
|
||||
}
|
||||
tunnels.insert(
|
||||
tunnel_id.to_string(),
|
||||
ManagedTunnel {
|
||||
forwarder,
|
||||
status: status.clone(),
|
||||
},
|
||||
);
|
||||
return Ok(status);
|
||||
}
|
||||
Err(err) => {
|
||||
last_error = format!("127.0.0.1:{remote_port}: {err}");
|
||||
forwarder.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"management service did not answer on port {MANAGEMENT_API_PORT} or legacy port {LEGACY_MANAGEMENT_API_PORT}; last probe: {last_error}"
|
||||
))
|
||||
}
|
||||
|
||||
fn probe_management_api(local_port: u16) -> Result<(), String> {
|
||||
let addr = format!("127.0.0.1:{local_port}");
|
||||
let timeout = Duration::from_millis(1500);
|
||||
let socket_addr: std::net::SocketAddr =
|
||||
addr.parse().map_err(|err| format!("bad addr: {err}"))?;
|
||||
let mut stream = TcpStream::connect_timeout(&socket_addr, timeout)
|
||||
.map_err(|err| format!("connect failed: {err}"))?;
|
||||
stream.set_read_timeout(Some(timeout)).ok();
|
||||
stream.set_write_timeout(Some(timeout)).ok();
|
||||
stream
|
||||
.write_all(b"GET /api/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
|
||||
.map_err(|err| format!("write failed: {err}"))?;
|
||||
let mut buf = [0u8; 256];
|
||||
let n = stream
|
||||
.read(&mut buf)
|
||||
.map_err(|err| format!("read failed: {err}"))?;
|
||||
if n == 0 {
|
||||
return Err("remote closed without an HTTP response".to_string());
|
||||
}
|
||||
let head = String::from_utf8_lossy(&buf[..n]);
|
||||
if head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("unexpected health response: {}", head.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_server_tunnel_inner(registry: &TunnelRegistry, tunnel_id: &str) -> Result<(), String> {
|
||||
let mut tunnels = registry
|
||||
.tunnels
|
||||
.lock()
|
||||
.map_err(|_| "Tunnel registry is unavailable.".to_string())?;
|
||||
if let Some(tunnel) = tunnels.remove(tunnel_id.trim()) {
|
||||
tunnel.forwarder.stop();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn existing_running_tunnel(
|
||||
registry: &TunnelRegistry,
|
||||
tunnel_id: &str,
|
||||
) -> Result<Option<ServerTunnelStatus>, String> {
|
||||
let mut tunnels = registry
|
||||
.tunnels
|
||||
.lock()
|
||||
.map_err(|_| "Tunnel registry is unavailable.".to_string())?;
|
||||
let Some(tunnel) = tunnels.get(tunnel_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if tunnel.forwarder.is_finished() {
|
||||
if let Some(stale) = tunnels.remove(tunnel_id) {
|
||||
stale.forwarder.stop();
|
||||
}
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(tunnel.status.clone()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dune_manager_core::orchestration::{RemoteCommandRunner, RusshRunner, RusshTarget};
|
||||
|
||||
use crate::commands::shared::{command_error_message, sh_single_quoted};
|
||||
use crate::dto::ServerTunnelStartRequest;
|
||||
|
||||
pub fn tunnel_target(request: &ServerTunnelStartRequest) -> Result<RusshTarget, String> {
|
||||
match request.server_kind.trim() {
|
||||
"ubuntu" => {
|
||||
let mut target = RusshTarget::new(
|
||||
PathBuf::from(
|
||||
request
|
||||
.key_path
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string(),
|
||||
),
|
||||
request.user.trim().to_string(),
|
||||
request.host.trim().to_string(),
|
||||
);
|
||||
if request.port != 0 {
|
||||
target.port = request.port;
|
||||
}
|
||||
target.validate().map_err(|err| err.message)?;
|
||||
Ok(target)
|
||||
}
|
||||
other => Err(format!("Unsupported remote server kind: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_tunnel_service(service: &str) -> Result<String, String> {
|
||||
match service.trim() {
|
||||
"director" => Ok("director".to_string()),
|
||||
"fileBrowser" => Ok("fileBrowser".to_string()),
|
||||
"database" => Ok("database".to_string()),
|
||||
"pgHero" => Ok("pgHero".to_string()),
|
||||
"managementApi" => Ok("managementApi".to_string()),
|
||||
other => Err(format!("Unsupported tunnel service: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tunnel_url(service: &str, local_port: u16) -> String {
|
||||
match service {
|
||||
"database" => format!("postgresql://127.0.0.1:{local_port}/dune"),
|
||||
"managementApi" => format!("http://127.0.0.1:{local_port}/api"),
|
||||
_ => format!("http://127.0.0.1:{local_port}/"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn discover_director_tunnel_port(target: &RusshTarget, namespace: &str) -> Result<u16, String> {
|
||||
let namespace = namespace.trim();
|
||||
if namespace.is_empty() {
|
||||
return Err(
|
||||
"BattleGroup namespace is required before starting the Director tunnel.".to_string(),
|
||||
);
|
||||
}
|
||||
let runner = RusshRunner::new(target.clone());
|
||||
let value = runner
|
||||
.run_json(
|
||||
&format!(
|
||||
"sudo kubectl get svc -n {} -o json",
|
||||
sh_single_quoted(namespace)
|
||||
),
|
||||
"director service list",
|
||||
)
|
||||
.map_err(command_error_message)?;
|
||||
for service in value["items"].as_array().cloned().unwrap_or_default() {
|
||||
for port in service["spec"]["ports"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if port["port"].as_u64() == Some(11717) {
|
||||
if let Some(node_port) = port["nodePort"]
|
||||
.as_u64()
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
{
|
||||
return Ok(node_port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("Director service is not currently exposed in Kubernetes.".to_string())
|
||||
}
|
||||
|
||||
pub fn discover_database_tunnel_port(target: &RusshTarget, namespace: &str) -> Result<u16, String> {
|
||||
const DEFAULT_DATABASE_PORT: u16 = dune_manager_core::database::DEFAULT_DUNE_DATABASE_PORT;
|
||||
|
||||
let namespace = namespace.trim();
|
||||
if namespace.is_empty() {
|
||||
return Err(
|
||||
"BattleGroup namespace is required before starting the database tunnel.".to_string(),
|
||||
);
|
||||
}
|
||||
let runner = RusshRunner::new(target.clone());
|
||||
let value = runner
|
||||
.run_json(
|
||||
&format!(
|
||||
"sudo kubectl get databasedeployments -n {} -o json",
|
||||
sh_single_quoted(namespace)
|
||||
),
|
||||
"database deployment list",
|
||||
)
|
||||
.map_err(command_error_message)?;
|
||||
for deployment in value["items"].as_array().cloned().unwrap_or_default() {
|
||||
if let Some(port) = deployment["spec"]["port"]
|
||||
.as_u64()
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
{
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
Ok(DEFAULT_DATABASE_PORT)
|
||||
}
|
||||
|
||||
pub fn discover_pg_hero_tunnel_port(target: &RusshTarget, namespace: &str) -> Result<u16, String> {
|
||||
const DEFAULT_PG_HERO_PORT: u16 = 21111;
|
||||
|
||||
let namespace = namespace.trim();
|
||||
if namespace.is_empty() {
|
||||
return Err(
|
||||
"BattleGroup namespace is required before starting the PgHero tunnel.".to_string(),
|
||||
);
|
||||
}
|
||||
let runner = RusshRunner::new(target.clone());
|
||||
let value = runner
|
||||
.run_json(
|
||||
&format!(
|
||||
"sudo kubectl get pods -n {} -l role=igw-database-pghero -o json",
|
||||
sh_single_quoted(namespace)
|
||||
),
|
||||
"PgHero pod list",
|
||||
)
|
||||
.map_err(command_error_message)?;
|
||||
for pod in value["items"].as_array().cloned().unwrap_or_default() {
|
||||
for container in pod["spec"]["containers"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
{
|
||||
for env in container["env"].as_array().cloned().unwrap_or_default() {
|
||||
if env["name"].as_str() == Some("PORT") {
|
||||
if let Some(port) = env["value"]
|
||||
.as_str()
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
{
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(DEFAULT_PG_HERO_PORT)
|
||||
}
|
||||
186
docs/reference-repos/adainrivers/app/src-tauri/src/dto.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn default_ssh_port() -> u16 {
|
||||
22
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteConnectionRequest {
|
||||
pub host: String,
|
||||
pub key_path: Option<String>,
|
||||
pub server_type: Option<String>,
|
||||
pub user: Option<String>,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteServerActionRequest {
|
||||
pub server_type: Option<String>,
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub key_path: Option<String>,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
pub port: u16,
|
||||
pub namespace: String,
|
||||
pub battlegroup_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerTunnelStartRequest {
|
||||
pub tunnel_id: String,
|
||||
pub server_kind: String,
|
||||
pub service: String,
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub key_path: Option<String>,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
pub port: u16,
|
||||
pub namespace: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerTunnelStopRequest {
|
||||
pub tunnel_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomTunnelStartRequest {
|
||||
pub tunnel_id: String,
|
||||
pub server_kind: String,
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub key_path: Option<String>,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
pub port: u16,
|
||||
pub protocol: String,
|
||||
pub remote_port: u16,
|
||||
pub local_port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerTunnelStatus {
|
||||
pub tunnel_id: String,
|
||||
pub service: String,
|
||||
pub local_port: u16,
|
||||
pub remote_port: u16,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteBattlegroupStatus {
|
||||
pub stop: bool,
|
||||
pub phase: String,
|
||||
#[serde(default)]
|
||||
pub database_phase: String,
|
||||
/// Wrapper's `Gateway` column. Kept under the old name for UI compatibility.
|
||||
pub server_group_phase: String,
|
||||
pub director_phase: String,
|
||||
#[serde(default)]
|
||||
pub uptime: String,
|
||||
#[serde(default)]
|
||||
pub server_stats: Vec<RemoteBattlegroupServerStat>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteBattlegroupServerStat {
|
||||
pub map: String,
|
||||
pub phase: String,
|
||||
pub ready: String,
|
||||
pub players: String,
|
||||
pub age: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteServerStatus {
|
||||
pub battlegroup: RemoteBattlegroupStatus,
|
||||
pub package: RemoteServerPackageStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteServerPackageStatus {
|
||||
pub installed_build_id: Option<String>,
|
||||
pub battlegroup_version: Option<String>,
|
||||
pub live_battlegroup_version: Option<String>,
|
||||
pub operator_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteServerComponent {
|
||||
pub name: String,
|
||||
pub log_key: String,
|
||||
pub category: String,
|
||||
pub state: String,
|
||||
pub tone: String,
|
||||
pub summary: String,
|
||||
pub details: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteComponentLogRequest {
|
||||
pub server_type: Option<String>,
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub key_path: Option<String>,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
pub port: u16,
|
||||
pub namespace: String,
|
||||
pub component: String,
|
||||
pub tail: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteComponentLogResult {
|
||||
pub component: String,
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteComponentRestartRequest {
|
||||
pub server_type: Option<String>,
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub key_path: Option<String>,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
pub port: u16,
|
||||
pub namespace: String,
|
||||
pub component: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteComponentRestartResult {
|
||||
pub component: String,
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteServerRecord {
|
||||
#[serde(rename = "type")]
|
||||
pub server_type: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub key_path: String,
|
||||
pub port: u16,
|
||||
pub namespace: String,
|
||||
pub battlegroup_name: String,
|
||||
pub world_unique_name: String,
|
||||
pub phase: String,
|
||||
}
|
||||
112
docs/reference-repos/adainrivers/app/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
mod commands;
|
||||
mod dto;
|
||||
mod log_file;
|
||||
mod logging;
|
||||
mod state;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::log_file::LogFile;
|
||||
|
||||
use crate::commands::{
|
||||
check_remote_sudo, detect_remote_ubuntu_servers, get_logs_folder, install_management_service,
|
||||
management_service_bundled_version, management_service_status, ms_cluster, ms_cron_preview,
|
||||
ms_dump_prune_execute, ms_dump_prune_preview, ms_get_config, ms_health, ms_history,
|
||||
ms_list_commands, ms_list_logs, ms_list_runs, ms_list_timezones, ms_player_location,
|
||||
ms_publish, ms_search_items, ms_search_journey_nodes, ms_search_players,
|
||||
ms_search_skill_modules, ms_search_vehicles, ms_search_xp_event_tags, ms_set_config,
|
||||
ms_trigger_run, ms_welcome_grant_retry, ms_welcome_grants, ms_welcome_whisper,
|
||||
record_operation_log,
|
||||
remote_component_log_tail, remote_server_components, remote_server_status,
|
||||
restart_management_service, restart_remote_battlegroup, restart_remote_component,
|
||||
server_tunnel_status, start_custom_tunnel, start_remote_battlegroup, start_server_tunnel,
|
||||
stop_all_tunnels, stop_remote_battlegroup, stop_server_tunnel, uninstall_management_service,
|
||||
update_remote_battlegroup,
|
||||
};
|
||||
use crate::state::TunnelRegistry;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// WebKitGTK 4.1 (Fedora 40+, WebKit 2.44+) aborts under GNOME Wayland
|
||||
// with "Error 71 dispatching to Wayland display" when the DMABuf
|
||||
// renderer is active. Disable it unless the user opted in explicitly.
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(TunnelRegistry::default())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(|app| {
|
||||
match LogFile::new(&app.handle()) {
|
||||
Ok(file) => {
|
||||
app.manage(Arc::new(file));
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Failed to initialize operation log file: {err}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
remote_server_status,
|
||||
remote_server_components,
|
||||
start_server_tunnel,
|
||||
start_custom_tunnel,
|
||||
stop_server_tunnel,
|
||||
server_tunnel_status,
|
||||
stop_all_tunnels,
|
||||
remote_component_log_tail,
|
||||
restart_remote_component,
|
||||
start_remote_battlegroup,
|
||||
stop_remote_battlegroup,
|
||||
restart_remote_battlegroup,
|
||||
update_remote_battlegroup,
|
||||
detect_remote_ubuntu_servers,
|
||||
check_remote_sudo,
|
||||
record_operation_log,
|
||||
get_logs_folder,
|
||||
install_management_service,
|
||||
uninstall_management_service,
|
||||
management_service_status,
|
||||
management_service_bundled_version,
|
||||
restart_management_service,
|
||||
ms_get_config,
|
||||
ms_set_config,
|
||||
ms_list_timezones,
|
||||
ms_cron_preview,
|
||||
ms_dump_prune_preview,
|
||||
ms_dump_prune_execute,
|
||||
ms_player_location,
|
||||
ms_health,
|
||||
ms_list_runs,
|
||||
ms_list_logs,
|
||||
ms_trigger_run,
|
||||
ms_list_commands,
|
||||
ms_search_items,
|
||||
ms_search_vehicles,
|
||||
ms_search_players,
|
||||
ms_search_skill_modules,
|
||||
ms_search_journey_nodes,
|
||||
ms_search_xp_event_tags,
|
||||
ms_cluster,
|
||||
ms_history,
|
||||
ms_welcome_grants,
|
||||
ms_welcome_grant_retry,
|
||||
ms_welcome_whisper,
|
||||
ms_publish,
|
||||
])
|
||||
.on_window_event(|window, event| {
|
||||
if matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
|
||||
window.state::<TunnelRegistry>().stop_all();
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to run Tauri application");
|
||||
}
|
||||
131
docs/reference-repos/adainrivers/app/src-tauri/src/log_file.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! Append-only operation log file with simple size-based rotation.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024;
|
||||
const LOG_FILE_NAME: &str = "operation.log";
|
||||
const ROLLED_FILE_NAME: &str = "operation.log.1";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LogLine<'a> {
|
||||
ts: String,
|
||||
level: &'a str,
|
||||
scope: &'a str,
|
||||
message: &'a str,
|
||||
}
|
||||
|
||||
/// JSON-line append-only sink for operation logs.
|
||||
pub struct LogFile {
|
||||
dir: PathBuf,
|
||||
path: PathBuf,
|
||||
file: Mutex<File>,
|
||||
}
|
||||
|
||||
impl LogFile {
|
||||
/// Resolves the app's local log directory, creates it if missing, and
|
||||
/// opens `operation.log` for append. Errors out only if the directory
|
||||
/// cannot be created or the file cannot be opened.
|
||||
pub fn new(app: &AppHandle) -> std::io::Result<Self> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_log_dir()
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
|
||||
fs::create_dir_all(&dir)?;
|
||||
let path = dir.join(LOG_FILE_NAME);
|
||||
let file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||
Ok(Self {
|
||||
dir,
|
||||
path,
|
||||
file: Mutex::new(file),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the directory the log file lives in.
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Appends a single JSON-line entry. Errors are swallowed by callers
|
||||
/// because the live in-memory log view is the source of truth.
|
||||
pub fn append(&self, level: &str, scope: &str, message: &str) -> std::io::Result<()> {
|
||||
let line = LogLine {
|
||||
ts: iso_timestamp(),
|
||||
level,
|
||||
scope,
|
||||
message,
|
||||
};
|
||||
let mut text = serde_json::to_string(&line)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
|
||||
text.push('\n');
|
||||
let mut file = self
|
||||
.file
|
||||
.lock()
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
|
||||
file.write_all(text.as_bytes())?;
|
||||
self.maybe_rotate_locked(&mut file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn maybe_rotate_locked(&self, file: &mut File) -> std::io::Result<()> {
|
||||
let len = file.metadata()?.len();
|
||||
if len < MAX_LOG_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
// Drop the file handle before renaming on Windows.
|
||||
drop(std::mem::replace(
|
||||
file,
|
||||
OpenOptions::new().read(true).open(&self.path)?,
|
||||
));
|
||||
let rolled = self.dir.join(ROLLED_FILE_NAME);
|
||||
let _ = fs::remove_file(&rolled);
|
||||
fs::rename(&self.path, &rolled)?;
|
||||
*file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn iso_timestamp() -> String {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = now.as_secs();
|
||||
let millis = now.subsec_millis();
|
||||
// Minimal ISO-8601 UTC formatter without bringing in chrono.
|
||||
let days_from_epoch = (secs / 86_400) as i64;
|
||||
let (year, month, day) = civil_from_days(days_from_epoch);
|
||||
let seconds_in_day = secs % 86_400;
|
||||
let hour = (seconds_in_day / 3600) as u32;
|
||||
let minute = ((seconds_in_day / 60) % 60) as u32;
|
||||
let second = (seconds_in_day % 60) as u32;
|
||||
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z")
|
||||
}
|
||||
|
||||
/// Converts days-since-1970 to a (year, month, day) Gregorian triple.
|
||||
/// Based on Howard Hinnant's `civil_from_days` algorithm.
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 {
|
||||
(mp + 3) as u32
|
||||
} else {
|
||||
(mp - 9) as u32
|
||||
};
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dune_manager_core::orchestration::{OperationSink, OrchestrationEvent};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use crate::log_file::LogFile;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OperationLogPayload {
|
||||
pub level: &'static str,
|
||||
pub scope: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub server_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TauriOperationSink {
|
||||
pub app: AppHandle,
|
||||
pub server_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TauriOperationSink {
|
||||
pub fn new(app: AppHandle) -> Self {
|
||||
Self {
|
||||
app,
|
||||
server_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&self, scope: impl Into<String>, message: impl Into<String>) {
|
||||
self.emit_level("info", scope, message);
|
||||
}
|
||||
|
||||
pub fn warn(&self, scope: impl Into<String>, message: impl Into<String>) {
|
||||
self.emit_level("warn", scope, message);
|
||||
}
|
||||
|
||||
fn emit_level(
|
||||
&self,
|
||||
level: &'static str,
|
||||
scope: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
) {
|
||||
let scope_text = scope.into();
|
||||
let message_text = message.into();
|
||||
let payload = OperationLogPayload {
|
||||
level,
|
||||
scope: scope_text.clone(),
|
||||
message: message_text.clone(),
|
||||
server_id: self.server_id.clone(),
|
||||
};
|
||||
let _ = self.app.emit("operation-log", &payload);
|
||||
if let Some(log_file) = self.app.try_state::<Arc<LogFile>>() {
|
||||
let _ = log_file.append(level, &scope_text, &message_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OperationSink for TauriOperationSink {
|
||||
fn emit(&mut self, event: OrchestrationEvent) {
|
||||
self.info(event.step_id, event.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
dune_dedicated_server_manager_app_lib::run();
|
||||
}
|
||||
29
docs/reference-repos/adainrivers/app/src-tauri/src/state.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use dune_manager_core::orchestration::LocalForwarder;
|
||||
|
||||
use crate::dto::ServerTunnelStatus;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct TunnelRegistry {
|
||||
pub tunnels: Arc<Mutex<HashMap<String, ManagedTunnel>>>,
|
||||
}
|
||||
|
||||
pub struct ManagedTunnel {
|
||||
pub forwarder: LocalForwarder,
|
||||
pub status: ServerTunnelStatus,
|
||||
}
|
||||
|
||||
impl TunnelRegistry {
|
||||
pub fn stop_all(&self) {
|
||||
let Ok(mut tunnels) = self.tunnels.lock() else {
|
||||
return;
|
||||
};
|
||||
for (_, tunnel) in tunnels.drain() {
|
||||
tunnel.forwarder.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Dune Dedicated Server Manager",
|
||||
"version": "0.3.16",
|
||||
"identifier": "dev.dune.dedicated-server-manager",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Dune Dedicated Server Manager",
|
||||
"width": 1280,
|
||||
"height": 820,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"devtools": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"createUpdaterArtifacts": false,
|
||||
"resources": {
|
||||
"binaries/dune-server-service": "binaries/dune-server-service",
|
||||
"binaries/dune-server-service.service": "binaries/dune-server-service.service",
|
||||
"binaries/dune-server-service.openrc": "binaries/dune-server-service.openrc"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE2N0QwRkY3NzBBNTMxNTAKUldSUU1hVnc5dzk5cGg2b3ZVKzNpUGI0M3BNVHdzWk1mdzQwSzMrdm9HVllvQlg5bEdyM1BCU0UK",
|
||||
"endpoints": [
|
||||
"https://github.com/adainrivers/dune-dedicated-server-manager/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
193
docs/reference-repos/adainrivers/app/src/App.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Box, Flex, Theme } from "@radix-ui/themes";
|
||||
|
||||
import AppErrorBoundary from "./components/AppErrorBoundary";
|
||||
import Header from "./components/layout/Header";
|
||||
import LogWindow from "./components/logs/LogWindow";
|
||||
import RemoteAttachDialog from "./components/dialogs/RemoteAttachDialog";
|
||||
import RemoveRemoteServerDialog from "./components/dialogs/RemoveRemoteServerDialog";
|
||||
import UpdateDialog from "./components/dialogs/UpdateDialog";
|
||||
import ServerDetailPage from "./components/servers/ServerDetailPage";
|
||||
import ServersListPage from "./components/servers/ServersListPage";
|
||||
import { useAppUpdates } from "./hooks/useAppUpdates";
|
||||
import { useComponentActions } from "./hooks/useComponentActions";
|
||||
import { useOperationLogs } from "./hooks/useOperationLogs";
|
||||
import { useRemoteServerStatus } from "./hooks/useRemoteServerStatus";
|
||||
import { useRemoteServers } from "./hooks/useRemoteServers";
|
||||
import { useServerTunnels } from "./hooks/useServerTunnels";
|
||||
import { useActivePage } from "./hooks/useActivePage";
|
||||
import { log } from "./utils/logging";
|
||||
|
||||
export function App() {
|
||||
const {
|
||||
logLevelFilter,
|
||||
setLogLevelFilter,
|
||||
logPanelCollapsed,
|
||||
setLogPanelCollapsed,
|
||||
scopeToActiveServer,
|
||||
setScopeToActiveServer,
|
||||
appendLogRow,
|
||||
clearLogRows,
|
||||
renderedLogRows,
|
||||
} = useOperationLogs();
|
||||
|
||||
const remoteServersHook = useRemoteServers({ appendLogRow });
|
||||
|
||||
const tunnels = useServerTunnels({ appendLogRow });
|
||||
|
||||
const status = useRemoteServerStatus({
|
||||
appendLogRow,
|
||||
setRemoteServers: remoteServersHook.setRemoteServers,
|
||||
});
|
||||
|
||||
const componentActions = useComponentActions({
|
||||
appendLogRow,
|
||||
detectRemoteServerDetails: status.detectRemoteServerDetails,
|
||||
setRemoteServerComponents: status.setRemoteServerComponents,
|
||||
setRemoteComponentLogs: status.setRemoteComponentLogs,
|
||||
setRemoteComponentLogBusy: status.setRemoteComponentLogBusy,
|
||||
setRemoteComponentRestartBusy: status.setRemoteComponentRestartBusy,
|
||||
});
|
||||
|
||||
const updates = useAppUpdates({ appendLogRow });
|
||||
|
||||
remoteServersHook.bindRefreshRemoteServerStatus(status.refreshRemoteServerStatus);
|
||||
remoteServersHook.bindRemoteServerBusy(status.remoteServerBusy);
|
||||
remoteServersHook.bindClearStatusForServer(status.clearStatusForServer);
|
||||
remoteServersHook.bindStopTunnelsForServer(tunnels.stopTunnelsForServer);
|
||||
|
||||
const { activePage, openServer, openServersList, setSub } = useActivePage({
|
||||
remoteServers: remoteServersHook.remoteServers,
|
||||
});
|
||||
|
||||
const scopeServerId = activePage.kind === "server" ? activePage.serverId : undefined;
|
||||
const visibleLogRows =
|
||||
scopeServerId && scopeToActiveServer
|
||||
? renderedLogRows.filter((row) => !row.serverId || row.serverId === scopeServerId)
|
||||
: renderedLogRows;
|
||||
|
||||
const activeServer =
|
||||
activePage.kind === "server"
|
||||
? remoteServersHook.remoteServers.find((server) => server.id === activePage.serverId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Theme
|
||||
appearance="dark"
|
||||
accentColor="bronze"
|
||||
grayColor="sand"
|
||||
radius="medium"
|
||||
scaling="100%"
|
||||
panelBackground="solid"
|
||||
>
|
||||
<Flex direction="column" height="100vh" className="app-shell">
|
||||
<Header
|
||||
activePage={activePage}
|
||||
servers={remoteServersHook.remoteServers}
|
||||
statuses={status.remoteServerStatuses}
|
||||
statusErrors={status.remoteServerStatusErrors}
|
||||
busyMap={status.remoteServerBusy}
|
||||
onOpenServersList={openServersList}
|
||||
onOpenServer={openServer}
|
||||
onAddServer={() => remoteServersHook.setRemoteAttachOpen(true)}
|
||||
updateStatus={updates.updateStatus}
|
||||
update={updates.availableUpdate}
|
||||
updateProgress={updates.updateProgress}
|
||||
onCheckUpdate={updates.checkForAppUpdate}
|
||||
onOpenUpdate={() => updates.setUpdateDialogOpen(true)}
|
||||
/>
|
||||
<Flex className="content-shell" gap="3" p="4" pt="0" minHeight="0">
|
||||
<Box className="main-pane">
|
||||
<AppErrorBoundary onError={(message) => appendLogRow(log.error("ui", message))}>
|
||||
{activePage.kind === "servers" || !activeServer ? (
|
||||
<ServersListPage
|
||||
servers={remoteServersHook.remoteServers}
|
||||
statuses={status.remoteServerStatuses}
|
||||
statusErrors={status.remoteServerStatusErrors}
|
||||
busyMap={status.remoteServerBusy}
|
||||
onOpenServer={openServer}
|
||||
onAddServer={() => remoteServersHook.setRemoteAttachOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<ServerDetailPage
|
||||
server={activeServer}
|
||||
sub={activePage.sub}
|
||||
onSubChange={setSub}
|
||||
status={status.remoteServerStatuses[activeServer.id]}
|
||||
statusError={status.remoteServerStatusErrors[activeServer.id]}
|
||||
busyLabel={status.remoteServerBusy[activeServer.id]}
|
||||
components={status.remoteServerComponents[activeServer.id] ?? []}
|
||||
componentLogs={status.remoteComponentLogs}
|
||||
componentLogBusy={status.remoteComponentLogBusy}
|
||||
componentRestartBusy={status.remoteComponentRestartBusy}
|
||||
tunnels={tunnels.serverTunnels}
|
||||
tunnelBusy={tunnels.serverTunnelBusy}
|
||||
onRefresh={() => status.refreshRemoteServerStatus(activeServer)}
|
||||
onRemove={() => remoteServersHook.setRemoteServerToRemove(activeServer)}
|
||||
onStartBattlegroup={() => status.runRemoteBattlegroupAction(activeServer, "start")}
|
||||
onStopBattlegroup={() => status.runRemoteBattlegroupAction(activeServer, "stop")}
|
||||
onRestartBattlegroup={() =>
|
||||
status.runRemoteBattlegroupAction(activeServer, "restart")
|
||||
}
|
||||
onUpdateBattlegroup={() => status.runRemoteBattlegroupAction(activeServer, "update")}
|
||||
onStartTunnel={tunnels.startServerTunnel}
|
||||
onStartCustomTunnel={tunnels.startCustomTunnel}
|
||||
onStopTunnel={tunnels.stopServerTunnel}
|
||||
onOpenTunnel={tunnels.openServerTunnel}
|
||||
onRefreshComponentLog={(component) =>
|
||||
componentActions.refreshRemoteComponentLog(activeServer, component)
|
||||
}
|
||||
onRestartComponent={(component) =>
|
||||
componentActions.restartRemoteComponent(activeServer, component)
|
||||
}
|
||||
appendLogRow={appendLogRow}
|
||||
/>
|
||||
)}
|
||||
</AppErrorBoundary>
|
||||
</Box>
|
||||
<LogWindow
|
||||
rows={visibleLogRows}
|
||||
level={logLevelFilter}
|
||||
collapsed={logPanelCollapsed}
|
||||
scopedToServer={scopeToActiveServer}
|
||||
canScopeToServer={!!scopeServerId}
|
||||
onLevelChange={setLogLevelFilter}
|
||||
onClear={clearLogRows}
|
||||
onToggleCollapsed={() => setLogPanelCollapsed((collapsed) => !collapsed)}
|
||||
onToggleScope={setScopeToActiveServer}
|
||||
/>
|
||||
</Flex>
|
||||
<RemoteAttachDialog
|
||||
open={remoteServersHook.remoteAttachOpen}
|
||||
form={remoteServersHook.remoteAttachForm}
|
||||
running={remoteServersHook.remoteAttachRunning}
|
||||
errorMessage={remoteServersHook.remoteAttachError}
|
||||
preflight={remoteServersHook.remoteAttachPreflight}
|
||||
onOpenChange={(open) => {
|
||||
remoteServersHook.setRemoteAttachOpen(open);
|
||||
if (!open) remoteServersHook.setRemoteAttachError(null);
|
||||
}}
|
||||
onChange={remoteServersHook.setRemoteAttachForm}
|
||||
onAttach={remoteServersHook.addRemoteServer}
|
||||
/>
|
||||
<RemoveRemoteServerDialog
|
||||
server={remoteServersHook.remoteServerToRemove}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) remoteServersHook.setRemoteServerToRemove(null);
|
||||
}}
|
||||
onRemove={(server) => {
|
||||
remoteServersHook.removeRemoteServer(server);
|
||||
remoteServersHook.setRemoteServerToRemove(null);
|
||||
}}
|
||||
/>
|
||||
<UpdateDialog
|
||||
open={updates.updateDialogOpen}
|
||||
update={updates.availableUpdate}
|
||||
status={updates.updateStatus}
|
||||
progress={updates.updateProgress}
|
||||
onOpenChange={updates.setUpdateDialogOpen}
|
||||
onInstall={updates.installAppUpdate}
|
||||
/>
|
||||
</Flex>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { Card, Flex, Heading, Text } from "@radix-ui/themes";
|
||||
|
||||
export type AppErrorBoundaryProps = {
|
||||
onError: (message: string) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AppErrorBoundaryState = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export default class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
|
||||
state: AppErrorBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): AppErrorBoundaryState {
|
||||
return { error: error.message };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
this.props.onError(`${error.message}\n${info.componentStack}`);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<Card size="3" variant="surface" className="pane page-pane">
|
||||
<Flex direction="column" gap="3">
|
||||
<Heading size="4">UI Error</Heading>
|
||||
<Text size="2" color="gray">
|
||||
The view failed to render. Details were written to the log window.
|
||||
</Text>
|
||||
<Text size="2" className="mono">
|
||||
{this.state.error}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
||||
import { Button, Dialog, Flex, IconButton, Link, Text } from "@radix-ui/themes";
|
||||
|
||||
import { openExternal } from "../../services/tauri";
|
||||
|
||||
const REPO_URL = "https://github.com/adainrivers/dune-dedicated-server-manager";
|
||||
const ISSUES_URL = `${REPO_URL}/issues`;
|
||||
|
||||
/**
|
||||
* Small info button (sits next to "Check for updates") that opens an About
|
||||
* modal showing the app version and links back to the project. Self-contained:
|
||||
* owns its open state so it can be dropped into the header without prop
|
||||
* threading.
|
||||
*/
|
||||
export default function AboutDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
|
||||
// The bundled dune-server-service ships with the same version as the app,
|
||||
// so this number identifies both. Fetched from the Tauri runtime rather than
|
||||
// package.json so it reflects the actually-installed build.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getVersion()
|
||||
.then((v) => {
|
||||
if (active) setVersion(v);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setVersion(null);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openLink = (url: string) => () => {
|
||||
void openExternal(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger>
|
||||
<IconButton size="1" variant="surface" aria-label="About this app" title="About">
|
||||
<InfoCircledIcon />
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content maxWidth="460px">
|
||||
<Dialog.Title>About</Dialog.Title>
|
||||
<Dialog.Description size="2" style={{ color: "var(--color-text-muted)" }}>
|
||||
Dune Dedicated Server Manager
|
||||
</Dialog.Description>
|
||||
|
||||
<Flex direction="column" gap="3" mt="4">
|
||||
<Flex justify="between" align="center">
|
||||
<Text size="2" color="gray">
|
||||
Version
|
||||
</Text>
|
||||
<Text size="2" className="mono">
|
||||
{version ?? "—"}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="between" align="center">
|
||||
<Text size="2" color="gray">
|
||||
Repository
|
||||
</Text>
|
||||
<Link size="2" href={REPO_URL} onClick={(e) => { e.preventDefault(); openLink(REPO_URL)(); }}>
|
||||
GitHub
|
||||
</Link>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="between" align="center">
|
||||
<Text size="2" color="gray">
|
||||
Found a bug?
|
||||
</Text>
|
||||
<Link size="2" href={ISSUES_URL} onClick={(e) => { e.preventDefault(); openLink(ISSUES_URL)(); }}>
|
||||
Report an issue
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="3" justify="end" mt="5">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
Close
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Button, Callout, Dialog, Flex, Grid, TextField } from "@radix-ui/themes";
|
||||
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import { openFileDialog, type PreflightCheck } from "../../services/tauri";
|
||||
import type { RemoteAttachForm } from "../../types/ui";
|
||||
import ActionButton from "../ui/ActionButton";
|
||||
import Field from "../ui/Field";
|
||||
|
||||
export type RemoteAttachDialogProps = {
|
||||
open: boolean;
|
||||
form: RemoteAttachForm;
|
||||
running: boolean;
|
||||
errorMessage?: string | null;
|
||||
preflight?: PreflightCheck | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onChange: (form: RemoteAttachForm) => void;
|
||||
onAttach: () => void;
|
||||
};
|
||||
|
||||
export default function RemoteAttachDialog({
|
||||
open,
|
||||
form,
|
||||
running,
|
||||
errorMessage,
|
||||
preflight,
|
||||
onOpenChange,
|
||||
onChange,
|
||||
onAttach,
|
||||
}: RemoteAttachDialogProps) {
|
||||
const canAttach =
|
||||
form.host.trim().length > 0 &&
|
||||
form.user.trim().length > 0 &&
|
||||
form.keyPath.trim().length > 0 &&
|
||||
form.port > 0 &&
|
||||
!running;
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content maxWidth="540px">
|
||||
<Dialog.Title>Add Remote Server</Dialog.Title>
|
||||
<Dialog.Description size="2" style={{ color: "var(--color-text-muted)" }}>
|
||||
Connect over SSH and detect existing Dune battlegroups. Vendor wrapper commands
|
||||
always execute as <code>dune</code>; if you log in as root we drop into dune via
|
||||
sudo automatically.
|
||||
</Dialog.Description>
|
||||
<Flex direction="column" gap="3" mt="4">
|
||||
<Field label="Host or IP">
|
||||
<TextField.Root
|
||||
placeholder="203.0.113.10"
|
||||
disabled={running}
|
||||
value={form.host}
|
||||
onChange={(event) => onChange({ ...form, host: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Grid columns="3fr 1fr" gap="3">
|
||||
<Field label="SSH User">
|
||||
<TextField.Root
|
||||
placeholder="dune"
|
||||
disabled={running}
|
||||
value={form.user}
|
||||
onChange={(event) => onChange({ ...form, user: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="SSH Port">
|
||||
<TextField.Root
|
||||
placeholder="22"
|
||||
disabled={running}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={String(form.port)}
|
||||
onChange={(event) => {
|
||||
const parsed = parseInt(event.target.value, 10);
|
||||
onChange({ ...form, port: isNaN(parsed) ? 22 : parsed });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</Grid>
|
||||
<Field label="Private Key">
|
||||
<Grid columns="1fr auto" gap="2">
|
||||
<TextField.Root
|
||||
placeholder="Choose SSH private key"
|
||||
value={form.keyPath}
|
||||
disabled={running}
|
||||
onChange={(event) => onChange({ ...form, keyPath: event.target.value })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="surface"
|
||||
disabled={running}
|
||||
onClick={async () => {
|
||||
const selected = await openFileDialog("Choose SSH private key");
|
||||
if (selected) onChange({ ...form, keyPath: selected });
|
||||
}}
|
||||
>
|
||||
Choose
|
||||
</Button>
|
||||
</Grid>
|
||||
</Field>
|
||||
{errorMessage ? (
|
||||
<Callout.Root color="red" variant="surface">
|
||||
<Callout.Icon>
|
||||
<ExclamationTriangleIcon />
|
||||
</Callout.Icon>
|
||||
<Callout.Text style={{ whiteSpace: "pre-wrap" }}>{errorMessage}</Callout.Text>
|
||||
</Callout.Root>
|
||||
) : null}
|
||||
{preflight && !errorMessage ? (
|
||||
<Callout.Root color="green" variant="surface">
|
||||
<Callout.Text>
|
||||
Preflight passed: SSH ok, sudo to dune ok, dune passwordless sudo ok.
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Flex gap="3" justify="end" mt="5">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray" disabled={running}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<ActionButton
|
||||
onClick={onAttach}
|
||||
busy={running}
|
||||
disabled={!canAttach}
|
||||
tone="accent"
|
||||
pendingLabel="Checking"
|
||||
>
|
||||
Detect and Add
|
||||
</ActionButton>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AlertDialog, Box, Button, Flex } from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerRecord } from "../../types/server";
|
||||
import Metric from "../ui/Metric";
|
||||
|
||||
export type RemoveRemoteServerDialogProps = {
|
||||
server: RemoteServerRecord | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onRemove: (server: RemoteServerRecord) => void;
|
||||
};
|
||||
|
||||
export default function RemoveRemoteServerDialog({
|
||||
server,
|
||||
onOpenChange,
|
||||
onRemove,
|
||||
}: RemoveRemoteServerDialogProps) {
|
||||
return (
|
||||
<AlertDialog.Root open={!!server} onOpenChange={onOpenChange}>
|
||||
<AlertDialog.Content maxWidth="520px">
|
||||
<AlertDialog.Title>Forget Remote Server</AlertDialog.Title>
|
||||
<AlertDialog.Description size="2" color="gray">
|
||||
This only removes the saved server entry from this app. The remote host and Dune battlegroup will not be changed.
|
||||
</AlertDialog.Description>
|
||||
{server ? (
|
||||
<Box className="info-card" mt="4">
|
||||
<Metric label="Host" value={server.host} />
|
||||
<Metric label="Battlegroup" value={server.battlegroupName || "unknown"} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Flex gap="3" justify="end" mt="5">
|
||||
<AlertDialog.Cancel>
|
||||
<Button variant="soft" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action>
|
||||
<Button color="red" onClick={() => server && onRemove(server)}>
|
||||
Forget Server
|
||||
</Button>
|
||||
</AlertDialog.Action>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import Markdown from "markdown-to-jsx";
|
||||
import { AlertDialog, Box, Button, Flex, Link, Text } from "@radix-ui/themes";
|
||||
|
||||
import { openExternal } from "../../services/tauri";
|
||||
import type { Update } from "../../services/updater";
|
||||
import type { UpdateStatus } from "../../types/update";
|
||||
|
||||
const RELEASES_URL = "https://github.com/adainrivers/dune-dedicated-server-manager/releases";
|
||||
|
||||
// Links inside the release notes must open in the system browser, not navigate
|
||||
// the Tauri webview away from the app.
|
||||
function NotesLink({ href, children }: ComponentPropsWithoutRef<"a">) {
|
||||
return (
|
||||
<Link
|
||||
size="2"
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) void openExternal(href);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export type UpdateDialogProps = {
|
||||
open: boolean;
|
||||
update: Update | null;
|
||||
status: UpdateStatus;
|
||||
progress: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onInstall: () => void;
|
||||
};
|
||||
|
||||
export default function UpdateDialog({
|
||||
open,
|
||||
update,
|
||||
status,
|
||||
progress,
|
||||
onOpenChange,
|
||||
onInstall,
|
||||
}: UpdateDialogProps) {
|
||||
const busy = status === "installing" || status === "relaunching";
|
||||
return (
|
||||
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialog.Content maxWidth="520px">
|
||||
<AlertDialog.Title>Install app update?</AlertDialog.Title>
|
||||
<AlertDialog.Description size="2">
|
||||
{update
|
||||
? `Version ${update.version} is available. The app will download the signed installer, install it, and relaunch.`
|
||||
: "No update is currently selected."}
|
||||
</AlertDialog.Description>
|
||||
{update?.body ? (
|
||||
<Box mt="3">
|
||||
<Text size="2" weight="medium">
|
||||
What's new
|
||||
</Text>
|
||||
{/* Render the release notes as markdown, bounded with scroll so a
|
||||
long changelog can never push the dialog past the viewport. */}
|
||||
<Box className="release-notes-md">
|
||||
<Markdown options={{ forceBlock: true, overrides: { a: NotesLink } }}>
|
||||
{update.body}
|
||||
</Markdown>
|
||||
</Box>
|
||||
<Flex mt="2">
|
||||
<Link
|
||||
size="1"
|
||||
href={RELEASES_URL}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openExternal(RELEASES_URL);
|
||||
}}
|
||||
>
|
||||
Full release notes
|
||||
</Link>
|
||||
</Flex>
|
||||
</Box>
|
||||
) : null}
|
||||
{progress ? (
|
||||
<Text as="p" size="2" color="gray" mt="3" className="mono">
|
||||
{progress}
|
||||
</Text>
|
||||
) : null}
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<AlertDialog.Cancel disabled={busy}>
|
||||
<Button variant="soft" color="gray" disabled={busy}>
|
||||
Later
|
||||
</Button>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action disabled={!update || busy}>
|
||||
<Button disabled={!update || busy} onClick={onInstall}>
|
||||
{busy ? "Installing..." : "Install update"}
|
||||
</Button>
|
||||
</AlertDialog.Action>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Flex } from "@radix-ui/themes";
|
||||
|
||||
import type { Update } from "../../services/updater";
|
||||
import type { RemoteServerRecord, RemoteServerStatus } from "../../types/server";
|
||||
import type { ActivePage } from "../../types/ui";
|
||||
import type { UpdateStatus } from "../../types/update";
|
||||
import TopNav from "./TopNav";
|
||||
import UpdateHeaderControl from "./UpdateHeaderControl";
|
||||
|
||||
export type HeaderProps = {
|
||||
activePage: ActivePage;
|
||||
servers: RemoteServerRecord[];
|
||||
statuses: Record<string, RemoteServerStatus>;
|
||||
statusErrors: Record<string, string>;
|
||||
busyMap: Record<string, string>;
|
||||
onOpenServersList: () => void;
|
||||
onOpenServer: (serverId: string) => void;
|
||||
onAddServer: () => void;
|
||||
updateStatus: UpdateStatus;
|
||||
update: Update | null;
|
||||
updateProgress: string | null;
|
||||
onCheckUpdate: () => void;
|
||||
onOpenUpdate: () => void;
|
||||
};
|
||||
|
||||
export default function Header({
|
||||
activePage,
|
||||
servers,
|
||||
statuses,
|
||||
statusErrors,
|
||||
busyMap,
|
||||
onOpenServersList,
|
||||
onOpenServer,
|
||||
onAddServer,
|
||||
updateStatus,
|
||||
update,
|
||||
updateProgress,
|
||||
onCheckUpdate,
|
||||
onOpenUpdate,
|
||||
}: HeaderProps) {
|
||||
return (
|
||||
<Flex asChild align="center" justify="between" px="4" py="3" className="app-header">
|
||||
<header>
|
||||
<Flex align="center" gap="4">
|
||||
<Flex align="center" gap="3">
|
||||
<span className="app-glyph" aria-hidden>
|
||||
D
|
||||
</span>
|
||||
<Flex direction="column" gap="0">
|
||||
<span className="app-title">Dune Dedicated Server Manager</span>
|
||||
<span className="app-title-sub">Operator console</span>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<TopNav
|
||||
activePage={activePage}
|
||||
servers={servers}
|
||||
statuses={statuses}
|
||||
statusErrors={statusErrors}
|
||||
busyMap={busyMap}
|
||||
onOpenServersList={onOpenServersList}
|
||||
onOpenServer={onOpenServer}
|
||||
onAddServer={onAddServer}
|
||||
/>
|
||||
</Flex>
|
||||
<UpdateHeaderControl
|
||||
status={updateStatus}
|
||||
update={update}
|
||||
progress={updateProgress}
|
||||
onCheck={onCheckUpdate}
|
||||
onOpenUpdate={onOpenUpdate}
|
||||
/>
|
||||
</header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { TabNav, Tooltip } from "@radix-ui/themes";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import type { RemoteServerRecord, RemoteServerStatus } from "../../types/server";
|
||||
import type { ActivePage } from "../../types/ui";
|
||||
import { resolveServerStatus } from "../../utils/remote-server";
|
||||
|
||||
export type TopNavProps = {
|
||||
activePage: ActivePage;
|
||||
servers: RemoteServerRecord[];
|
||||
statuses: Record<string, RemoteServerStatus>;
|
||||
statusErrors: Record<string, string>;
|
||||
busyMap: Record<string, string>;
|
||||
onOpenServersList: () => void;
|
||||
onOpenServer: (serverId: string) => void;
|
||||
onAddServer: () => void;
|
||||
};
|
||||
|
||||
export default function TopNav({
|
||||
activePage,
|
||||
servers,
|
||||
statuses,
|
||||
statusErrors,
|
||||
busyMap,
|
||||
onOpenServersList,
|
||||
onOpenServer,
|
||||
onAddServer,
|
||||
}: TopNavProps) {
|
||||
const serversActive = activePage.kind === "servers";
|
||||
const activeServerId = activePage.kind === "server" ? activePage.serverId : null;
|
||||
return (
|
||||
<nav aria-label="Primary navigation" className="top-nav">
|
||||
<TabNav.Root size="2" color="bronze" className="server-tab-strip">
|
||||
<TabNav.Link
|
||||
href="#"
|
||||
active={serversActive}
|
||||
aria-current={serversActive ? "page" : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onOpenServersList();
|
||||
}}
|
||||
>
|
||||
Servers ({servers.length})
|
||||
</TabNav.Link>
|
||||
{servers.map((server) => {
|
||||
const status = statuses[server.id];
|
||||
const resolved = resolveServerStatus(
|
||||
statusErrors[server.id],
|
||||
status,
|
||||
!!busyMap[server.id],
|
||||
server,
|
||||
);
|
||||
const isActive = activeServerId === server.id;
|
||||
return (
|
||||
<TabNav.Link
|
||||
key={server.id}
|
||||
href="#"
|
||||
active={isActive}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onOpenServer(server.id);
|
||||
}}
|
||||
>
|
||||
<span className="server-tab-dot" data-tone={resolved.tone} aria-hidden />
|
||||
<span className="server-tab-label">{server.name}</span>
|
||||
</TabNav.Link>
|
||||
);
|
||||
})}
|
||||
<Tooltip content="Add remote server">
|
||||
<button
|
||||
type="button"
|
||||
className="server-tab-add"
|
||||
aria-label="Add remote server"
|
||||
onClick={onAddServer}
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</TabNav.Root>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Badge, Button, Flex } from "@radix-ui/themes";
|
||||
|
||||
import type { Update } from "../../services/updater";
|
||||
import type { UpdateStatus } from "../../types/update";
|
||||
import { updateLabel, updateTone } from "../../utils/formatting";
|
||||
import AboutDialog from "../dialogs/AboutDialog";
|
||||
|
||||
export type UpdateHeaderControlProps = {
|
||||
status: UpdateStatus;
|
||||
update: Update | null;
|
||||
progress: string | null;
|
||||
onCheck: () => void;
|
||||
onOpenUpdate: () => void;
|
||||
};
|
||||
|
||||
export default function UpdateHeaderControl({
|
||||
status,
|
||||
update,
|
||||
progress,
|
||||
onCheck,
|
||||
onOpenUpdate,
|
||||
}: UpdateHeaderControlProps) {
|
||||
const busy = status === "checking" || status === "installing" || status === "relaunching";
|
||||
const hasUpdate = Boolean(update);
|
||||
return (
|
||||
<Flex align="center" gap="2" className="header-update">
|
||||
<Badge color={updateTone(status)} variant="soft">
|
||||
{updateLabel(status, update, progress)}
|
||||
</Badge>
|
||||
<Button size="1" variant={hasUpdate ? "solid" : "surface"} disabled={busy} onClick={hasUpdate ? onOpenUpdate : onCheck}>
|
||||
{busy ? "Working..." : hasUpdate ? "Install" : "Check for updates"}
|
||||
</Button>
|
||||
<AboutDialog />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Box, Flex, Grid, Select, Text, Tooltip } from "@radix-ui/themes";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
FilePlusIcon,
|
||||
TrashIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
|
||||
import { getLogsFolder, openLogsFolder } from "../../services/tauri";
|
||||
import type { LogLevelFilter, LogRow } from "../../types/log";
|
||||
|
||||
export type LogWindowProps = {
|
||||
rows: LogRow[];
|
||||
level: LogLevelFilter;
|
||||
collapsed: boolean;
|
||||
scopedToServer: boolean;
|
||||
canScopeToServer: boolean;
|
||||
onLevelChange: (level: LogLevelFilter) => void;
|
||||
onClear: () => void;
|
||||
onToggleCollapsed: () => void;
|
||||
onToggleScope: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export default function LogWindow({
|
||||
rows,
|
||||
level,
|
||||
collapsed,
|
||||
scopedToServer,
|
||||
canScopeToServer,
|
||||
onLevelChange,
|
||||
onClear,
|
||||
onToggleCollapsed,
|
||||
onToggleScope,
|
||||
}: LogWindowProps) {
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
const [logsFolder, setLogsFolder] = useState<string>("");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const body = bodyRef.current;
|
||||
if (!body) return;
|
||||
if (stickToBottomRef.current) {
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
}, [rows]);
|
||||
|
||||
useEffect(() => {
|
||||
void getLogsFolder()
|
||||
.then(setLogsFolder)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const latestLevel = rows.length > 0 ? rows[rows.length - 1].level : "info";
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="log-sidebar" data-collapsed="true">
|
||||
<Tooltip content="Expand logs">
|
||||
<button
|
||||
type="button"
|
||||
className="log-sidebar-toggle"
|
||||
aria-label="Expand logs"
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="log-sidebar-rail">
|
||||
<span className="log-sidebar-rail-label">LOGS</span>
|
||||
<span className="log-sidebar-rail-count">{rows.length}</span>
|
||||
<span className={`log-sidebar-rail-dot log-${latestLevel}`} aria-hidden />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="log-sidebar" data-collapsed="false">
|
||||
<Flex direction="column" height="100%" minHeight="0" gap="2" p="3">
|
||||
<Flex align="center" justify="between" gap="2" wrap="wrap">
|
||||
<Tooltip content={logsFolder ? `Persisted at ${logsFolder}` : "Operation log"}>
|
||||
<Box>
|
||||
<Text as="div" size="2" weight="medium">
|
||||
Logs
|
||||
</Text>
|
||||
<Text as="div" size="1" style={{ color: "var(--color-text-muted)" }}>
|
||||
{rows.length} entries
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip content="Collapse logs">
|
||||
<button
|
||||
type="button"
|
||||
className="log-sidebar-toggle"
|
||||
aria-label="Collapse logs"
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" gap="2" wrap="wrap">
|
||||
<Select.Root size="1" value={level} onValueChange={(value) => onLevelChange(value as LogLevelFilter)}>
|
||||
<Select.Trigger variant="surface" aria-label="Minimum log level" />
|
||||
<Select.Content>
|
||||
<Select.Item value="debug">Debug</Select.Item>
|
||||
<Select.Item value="info">Info</Select.Item>
|
||||
<Select.Item value="warn">Warn</Select.Item>
|
||||
<Select.Item value="error">Error</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{canScopeToServer ? (
|
||||
<Tooltip
|
||||
content={
|
||||
scopedToServer
|
||||
? "Showing rows for the active server only. Click to show all."
|
||||
: "Showing all rows. Click to scope to the active server."
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="log-scope-toggle"
|
||||
data-scoped={scopedToServer ? "true" : "false"}
|
||||
aria-pressed={scopedToServer}
|
||||
onClick={() => onToggleScope(!scopedToServer)}
|
||||
>
|
||||
{scopedToServer ? "This server" : "All"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }} />
|
||||
<Tooltip content="Open logs folder">
|
||||
<button
|
||||
type="button"
|
||||
className="log-sidebar-icon-btn"
|
||||
aria-label="Open logs folder"
|
||||
onClick={() => void openLogsFolder().catch(() => undefined)}
|
||||
>
|
||||
<FilePlusIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Clear in-memory log">
|
||||
<button
|
||||
type="button"
|
||||
className="log-sidebar-icon-btn"
|
||||
aria-label="Clear logs"
|
||||
disabled={rows.length === 0}
|
||||
onClick={onClear}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Box
|
||||
className="log-body"
|
||||
ref={bodyRef}
|
||||
onScroll={(event) => {
|
||||
const body = event.currentTarget;
|
||||
const distanceFromBottom = body.scrollHeight - body.scrollTop - body.clientHeight;
|
||||
stickToBottomRef.current = distanceFromBottom < 80;
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" gap="0">
|
||||
{rows.map((row) => (
|
||||
<Grid
|
||||
key={row.id}
|
||||
columns="68px 44px 1fr"
|
||||
gap="2"
|
||||
align="baseline"
|
||||
className={`log-line log-${row.level}`}
|
||||
>
|
||||
<Text className="log-meta mono">{row.timestamp}</Text>
|
||||
<Text className="log-meta log-level mono">{row.level}</Text>
|
||||
<Text className="log-text mono">{row.message}</Text>
|
||||
</Grid>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Select,
|
||||
Table,
|
||||
Text,
|
||||
TextArea,
|
||||
TextField,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import { managementApi } from "../../services/management";
|
||||
import type {
|
||||
Category,
|
||||
CommandSpec,
|
||||
FieldSpec,
|
||||
HistoryDto,
|
||||
PublishResultDto,
|
||||
} from "../../types/management";
|
||||
import { formatTime } from "../../utils/formatting";
|
||||
import Combobox from "./Combobox";
|
||||
|
||||
export type AdminTabPrefill = {
|
||||
commandId: string;
|
||||
values: Record<string, unknown>;
|
||||
} | null;
|
||||
|
||||
export type AdminTabProps = {
|
||||
tunnelId: string;
|
||||
prefill?: AdminTabPrefill;
|
||||
onPrefillConsumed?: () => void;
|
||||
};
|
||||
|
||||
const CATEGORY_LABEL: Record<Category, string> = {
|
||||
items: "Inventory",
|
||||
player: "Player ops",
|
||||
progression: "Progression",
|
||||
movement: "Teleport & spawn",
|
||||
broadcast: "Broadcast",
|
||||
journey: "Story journey",
|
||||
exec: "Server scripts",
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: Category[] = [
|
||||
"broadcast",
|
||||
"items",
|
||||
"player",
|
||||
"progression",
|
||||
"movement",
|
||||
"journey",
|
||||
"exec",
|
||||
];
|
||||
|
||||
const CLIENT_DEFAULTS: Record<string, unknown> = {
|
||||
Quantity: 1,
|
||||
Durability: 1.0,
|
||||
WaterAmount: 1_000_000,
|
||||
Experience: 1000,
|
||||
Level: 1,
|
||||
SkillPoints: 0,
|
||||
BroadcastType: "Generic",
|
||||
BroadcastDuration: 30,
|
||||
Persistent: 1.0,
|
||||
};
|
||||
|
||||
function applyDefaults(spec: CommandSpec): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of spec.fields) {
|
||||
if (CLIENT_DEFAULTS[field.key] !== undefined) {
|
||||
out[field.key] = CLIENT_DEFAULTS[field.key];
|
||||
} else if (field.default !== undefined && field.default !== null) {
|
||||
out[field.key] = field.default;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function AdminTab({ tunnelId, prefill, onPrefillConsumed }: AdminTabProps) {
|
||||
const [commands, setCommands] = useState<CommandSpec[]>([]);
|
||||
const [selected, setSelected] = useState<CommandSpec | null>(null);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [history, setHistory] = useState<HistoryDto[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<PublishResultDto | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const appliedRef = useRef<{ selectedId: string; prefillFp: string | null } | null>(null);
|
||||
// Templates available for the currently-picked vehicle (SpawnVehicleAt).
|
||||
// Populated whenever values.ClassName changes so TemplateName renders as a
|
||||
// proper combobox of valid options instead of a free-text field.
|
||||
const [vehicleTemplates, setVehicleTemplates] = useState<string[]>([]);
|
||||
|
||||
const refreshHistory = useCallback(async () => {
|
||||
try {
|
||||
const list = await managementApi.history(tunnelId, 30);
|
||||
setHistory(list);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}, [tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
managementApi
|
||||
.listCommands(tunnelId)
|
||||
.then(setCommands)
|
||||
.catch((err) => setError(String(err)));
|
||||
void refreshHistory();
|
||||
}, [tunnelId, refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset values + apply prefill exactly once per (selected, prefill) pair.
|
||||
// The earlier two-effect version raced; the single-effect version still
|
||||
// clobbered prefill on the next render after onPrefillConsumed cleared it
|
||||
// because the [prefill] dep change re-ran the defaults reset. Track what
|
||||
// we've already applied so post-consumption re-renders are a no-op.
|
||||
if (!selected) {
|
||||
appliedRef.current = null;
|
||||
return;
|
||||
}
|
||||
const prefillFp =
|
||||
prefill && prefill.commandId === selected.id ? JSON.stringify(prefill) : null;
|
||||
const current = appliedRef.current;
|
||||
|
||||
if (!current || current.selectedId !== selected.id) {
|
||||
// Brand new command pick (sidebar click or first prefill into a new command).
|
||||
if (prefillFp) {
|
||||
setValues({ ...applyDefaults(selected), ...(prefill?.values ?? {}) });
|
||||
onPrefillConsumed?.();
|
||||
} else {
|
||||
setValues(applyDefaults(selected));
|
||||
}
|
||||
setResult(null);
|
||||
appliedRef.current = { selectedId: selected.id, prefillFp };
|
||||
return;
|
||||
}
|
||||
|
||||
// Same command. Only act if a NEW prefill arrived for it.
|
||||
if (prefillFp && prefillFp !== current.prefillFp) {
|
||||
setValues((prev) => ({ ...prev, ...(prefill?.values ?? {}) }));
|
||||
setResult(null);
|
||||
onPrefillConsumed?.();
|
||||
appliedRef.current = { selectedId: selected.id, prefillFp };
|
||||
}
|
||||
// Otherwise the prefill was cleared after we consumed it — leave values alone.
|
||||
}, [selected, prefill, onPrefillConsumed]);
|
||||
|
||||
useEffect(() => {
|
||||
// If a prefill arrives for a command different from what's currently
|
||||
// selected, switch the sidebar to that command. The effect above will
|
||||
// then notice prefill.commandId === selected.id and apply the values.
|
||||
if (!prefill || commands.length === 0) return;
|
||||
if (selected?.id === prefill.commandId) return;
|
||||
const target = commands.find((c) => c.id === prefill.commandId);
|
||||
if (!target) return;
|
||||
setSelected(target);
|
||||
}, [prefill, commands, selected?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// For SpawnVehicleAt, look up the templates of the picked vehicle so the
|
||||
// TemplateName field can render its real options.
|
||||
const cls =
|
||||
selected?.id === "SpawnVehicleAt" && typeof values.ClassName === "string"
|
||||
? (values.ClassName as string).trim()
|
||||
: "";
|
||||
if (!cls) {
|
||||
setVehicleTemplates([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const matches = await managementApi.searchVehicles(tunnelId, cls, 10);
|
||||
const hit = matches.find((v) => v.id === cls || v.actor_class === cls);
|
||||
const templates = hit?.templates ?? [];
|
||||
if (cancelled) return;
|
||||
setVehicleTemplates(templates);
|
||||
// If the current TemplateName isn't valid for this vehicle, auto-pick
|
||||
// the first available one. Keeps the form submittable without the user
|
||||
// having to know that TreadWheel doesn't carry a T0.
|
||||
if (templates.length > 0) {
|
||||
setValues((prev) => {
|
||||
const current = typeof prev.TemplateName === "string" ? prev.TemplateName : "";
|
||||
if (current && templates.includes(current)) return prev;
|
||||
return { ...prev, TemplateName: templates[0] };
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setVehicleTemplates([]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selected?.id, values.ClassName, tunnelId]);
|
||||
|
||||
const grouped = useMemo(() => groupByCategory(commands), [commands]);
|
||||
|
||||
const doPublish = useCallback(async () => {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const out = await managementApi.publish(tunnelId, selected.id, values);
|
||||
setResult(out);
|
||||
await refreshHistory();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [selected, tunnelId, values, refreshHistory]);
|
||||
|
||||
const publish = useCallback(() => {
|
||||
if (!selected) return;
|
||||
if (selected.destructive) {
|
||||
setConfirmOpen(true);
|
||||
} else {
|
||||
void doPublish();
|
||||
}
|
||||
}, [selected, doPublish]);
|
||||
|
||||
return (
|
||||
<Flex mt="3" gap="3" align="stretch" wrap="wrap">
|
||||
<Box style={{ flex: "0 0 240px", minWidth: 0 }}>
|
||||
<Text size="2" weight="medium">
|
||||
Commands
|
||||
</Text>
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const specs = grouped[cat];
|
||||
if (!specs || specs.length === 0) return null;
|
||||
return (
|
||||
<Box key={cat} mt="2">
|
||||
<Text size="1" color="gray" style={{ textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||
{CATEGORY_LABEL[cat] ?? cat}
|
||||
</Text>
|
||||
<Flex direction="column" gap="1" mt="1">
|
||||
{specs.map((spec) => (
|
||||
<Button
|
||||
key={spec.id}
|
||||
size="1"
|
||||
variant={selected?.id === spec.id ? "solid" : "surface"}
|
||||
color={spec.destructive ? "red" : undefined}
|
||||
onClick={() => setSelected(spec)}
|
||||
style={{ justifyContent: "flex-start" }}
|
||||
>
|
||||
{spec.label}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box style={{ flex: "1 1 400px", minWidth: 0 }}>
|
||||
{selected ? (
|
||||
<Box>
|
||||
<Flex justify="between" align="baseline" wrap="wrap" gap="2">
|
||||
<Text size="3" weight="medium">
|
||||
{selected.label}
|
||||
</Text>
|
||||
{selected.destructive ? <Badge color="red">destructive</Badge> : null}
|
||||
</Flex>
|
||||
<Text size="1" color="gray">
|
||||
{selected.describe}
|
||||
</Text>
|
||||
<Flex direction="column" gap="3" mt="3">
|
||||
{visibleFields(selected, values).map((field) => (
|
||||
<FieldInput
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={values[field.key]}
|
||||
onChange={(v) => setValues((prev) => ({ ...prev, [field.key]: v }))}
|
||||
tunnelId={tunnelId}
|
||||
vehicleTemplates={vehicleTemplates}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
{selected.id === "SpawnVehicleAt" ? (
|
||||
<UsePlayerPositionButton
|
||||
tunnelId={tunnelId}
|
||||
playerId={values.PlayerId as string | undefined}
|
||||
onLocation={(loc) =>
|
||||
setValues((prev) => ({ ...prev, X: loc.x, Y: loc.y, Z: loc.z }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Flex mt="3" gap="2" align="center">
|
||||
<Button onClick={publish} disabled={busy} color={selected.destructive ? "red" : undefined}>
|
||||
{busy ? "Publishing…" : selected.destructive ? "Publish (destructive)" : "Publish"}
|
||||
</Button>
|
||||
{result ? (
|
||||
<Badge color={result.ok ? "green" : "red"}>{result.ok ? "ok" : "failed"}</Badge>
|
||||
) : null}
|
||||
</Flex>
|
||||
{result && !result.ok && result.error ? (
|
||||
<Text size="1" color="red" mt="2">
|
||||
{result.error}
|
||||
</Text>
|
||||
) : null}
|
||||
{result?.output ? (
|
||||
<Box
|
||||
mt="2"
|
||||
className="mono"
|
||||
style={{ fontSize: 11, padding: 6, background: "var(--color-panel-translucent)", whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{result.output}
|
||||
</Box>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text size="1" color="red" mt="2">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
) : (
|
||||
<Text color="gray">Select a command on the left.</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box style={{ flex: "1 1 320px", minWidth: 0 }}>
|
||||
<Text size="2" weight="medium">
|
||||
Recent publishes
|
||||
</Text>
|
||||
<Table.Root variant="surface" size="1" mt="1">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>Cmd</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>OK</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>When</Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{history.map((h) => (
|
||||
<Table.Row key={h.id}>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11 }}>
|
||||
{h.command}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge color={h.ok ? "green" : "red"}>{h.ok ? "ok" : "fail"}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11 }}>
|
||||
{formatTime(h.createdAt)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
|
||||
<AlertDialog.Root open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialog.Content maxWidth="460px">
|
||||
<AlertDialog.Title>Run {selected?.label}?</AlertDialog.Title>
|
||||
<AlertDialog.Description size="2">
|
||||
This command is destructive and cannot be undone. {selected?.describe}
|
||||
</AlertDialog.Description>
|
||||
<Flex gap="2" mt="4" justify="end">
|
||||
<AlertDialog.Cancel>
|
||||
<Button variant="soft" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialog.Cancel>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
void doPublish();
|
||||
}}
|
||||
>
|
||||
Run it
|
||||
</Button>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByCategory(specs: CommandSpec[]): Record<string, CommandSpec[]> {
|
||||
const out: Record<string, CommandSpec[]> = {};
|
||||
for (const spec of specs) {
|
||||
if (!out[spec.category]) out[spec.category] = [];
|
||||
out[spec.category].push(spec);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function compareText(a: string | undefined | null, b: string | undefined | null): number {
|
||||
return (a || "").localeCompare(b || "", undefined, { sensitivity: "base", numeric: true });
|
||||
}
|
||||
|
||||
function comparePlayers(a: any, b: any): number {
|
||||
const aOnline = String(a.online || "").toLowerCase() === "online";
|
||||
const bOnline = String(b.online || "").toLowerCase() === "online";
|
||||
if (aOnline !== bOnline) return aOnline ? -1 : 1;
|
||||
return compareText(a.name || a.flsId, b.name || b.flsId);
|
||||
}
|
||||
|
||||
function sortCommandOptions(kind: ComboboxKind, options: any[]): any[] {
|
||||
const rows = [...options];
|
||||
if (kind === "players") return rows.sort(comparePlayers);
|
||||
if (kind === "vehicles") return rows.sort((a, b) => compareText(a.id, b.id));
|
||||
return rows.sort((a, b) => compareText(a.name || a.id, b.name || b.id));
|
||||
}
|
||||
|
||||
function UsePlayerPositionButton({
|
||||
tunnelId,
|
||||
playerId,
|
||||
onLocation,
|
||||
}: {
|
||||
tunnelId: string;
|
||||
playerId: string | undefined;
|
||||
onLocation: (loc: { x: number; y: number; z: number }) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const enabled = !!playerId && !busy;
|
||||
|
||||
const click = useCallback(async () => {
|
||||
if (!playerId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const loc = await managementApi.playerLocation(tunnelId, playerId);
|
||||
onLocation(loc);
|
||||
} catch (err) {
|
||||
// Backend wraps proxy errors as `GET /path -> STATUS: {"error":"…"}`.
|
||||
// Pull out the inner `error` field for a readable message; fall back to raw.
|
||||
const raw = String(err);
|
||||
let nice = raw;
|
||||
const bodyStart = raw.indexOf("{");
|
||||
if (bodyStart >= 0) {
|
||||
try {
|
||||
const obj = JSON.parse(raw.slice(bodyStart));
|
||||
if (obj && typeof obj.error === "string") nice = obj.error;
|
||||
} catch {
|
||||
// leave nice as raw
|
||||
}
|
||||
}
|
||||
setError(nice);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [tunnelId, playerId, onLocation]);
|
||||
|
||||
return (
|
||||
<Box mt="2">
|
||||
<Button size="1" variant="soft" disabled={!enabled} onClick={click}>
|
||||
{busy ? "Fetching…" : "Use player's current position"}
|
||||
</Button>
|
||||
{!playerId ? (
|
||||
<Text size="1" color="gray" ml="2">
|
||||
(pick a player first)
|
||||
</Text>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text size="1" color="red" as="div" mt="1">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type ComboboxKind = "items" | "vehicles" | "players" | "skill-modules";
|
||||
|
||||
function comboboxKindFor(fieldKey: string): ComboboxKind | null {
|
||||
switch (fieldKey) {
|
||||
case "ItemName":
|
||||
return "items";
|
||||
case "ClassName":
|
||||
return "vehicles";
|
||||
case "PlayerId":
|
||||
return "players";
|
||||
case "Module":
|
||||
return "skill-modules";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function FieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
tunnelId,
|
||||
vehicleTemplates,
|
||||
}: {
|
||||
field: FieldSpec;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
tunnelId: string;
|
||||
vehicleTemplates: string[];
|
||||
}) {
|
||||
const comboKind = comboboxKindFor(field.key);
|
||||
const templateMode = field.key === "TemplateName" && vehicleTemplates.length > 0;
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="between" align="baseline" gap="2">
|
||||
<Text size="2" weight="medium">
|
||||
{field.label}
|
||||
{field.required ? " *" : ""}
|
||||
</Text>
|
||||
{field.helper ? (
|
||||
<Text size="1" color="gray">
|
||||
{field.helper}
|
||||
</Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Box mt="1">
|
||||
{templateMode ? (
|
||||
<TemplateCombobox
|
||||
value={typeof value === "string" ? value : value == null ? "" : String(value)}
|
||||
onPick={onChange}
|
||||
templates={vehicleTemplates}
|
||||
/>
|
||||
) : comboKind ? (
|
||||
<CommandCombobox kind={comboKind} value={value} onPick={onChange} tunnelId={tunnelId} />
|
||||
) : (
|
||||
renderInput(field, value, onChange)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateCombobox({
|
||||
value,
|
||||
onPick,
|
||||
templates,
|
||||
}: {
|
||||
value: string;
|
||||
onPick: (v: unknown) => void;
|
||||
templates: string[];
|
||||
}) {
|
||||
const loadOptions = useCallback(
|
||||
async (query: string) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? templates.filter((t) => t.toLowerCase().includes(q))
|
||||
: templates;
|
||||
return [...filtered].sort(compareText).map((name) => ({ name }));
|
||||
},
|
||||
[templates],
|
||||
);
|
||||
return (
|
||||
<Combobox
|
||||
value={value}
|
||||
onChange={onPick}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(o: { name: string }) => o.name}
|
||||
resolveLabel={async (id) => id}
|
||||
renderOption={(o: { name: string }) => (
|
||||
<Text size="2" className="mono">{o.name}</Text>
|
||||
)}
|
||||
placeholder="Pick a template…"
|
||||
searchPlaceholder="Filter templates…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/// Filters the spec's field list down to what's relevant for the current
|
||||
/// values. Today only ServiceBroadcast has conditional fields — Generic
|
||||
/// hides the shutdown-specific knobs, ServerShutdown hides Generic-only
|
||||
/// fields, and a `ShouldCancel=true` hides everything except the cancel
|
||||
/// toggle itself.
|
||||
function visibleFields(
|
||||
spec: CommandSpec,
|
||||
values: Record<string, unknown>,
|
||||
): FieldSpec[] {
|
||||
if (spec.id !== "ServiceBroadcast") return [...spec.fields];
|
||||
const broadcastType = (values.BroadcastType as string) || "Generic";
|
||||
const shouldCancel = values.ShouldCancel === true;
|
||||
const GENERIC_ONLY = new Set(["Title", "Body"]);
|
||||
const SHUTDOWN_ONLY = new Set([
|
||||
"ShutdownType",
|
||||
"ShutdownDuration",
|
||||
"BroadcastFrequency",
|
||||
"ShouldCancel",
|
||||
]);
|
||||
return spec.fields.filter((field) => {
|
||||
if (field.key === "BroadcastType") return true;
|
||||
if (broadcastType === "Generic") {
|
||||
if (SHUTDOWN_ONLY.has(field.key)) return false;
|
||||
return true;
|
||||
}
|
||||
// ServerShutdown branch
|
||||
if (GENERIC_ONLY.has(field.key)) return false;
|
||||
if (shouldCancel && field.key !== "ShouldCancel") return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderInput(field: FieldSpec, value: unknown, onChange: (v: unknown) => void) {
|
||||
const strValue = value === undefined || value === null ? "" : String(value);
|
||||
if (field.kind === "select" && field.options) {
|
||||
return (
|
||||
<Select.Root value={strValue || field.options[0].value} onValueChange={onChange}>
|
||||
<Select.Trigger />
|
||||
<Select.Content>
|
||||
{field.options.map((opt) => (
|
||||
<Select.Item key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
);
|
||||
}
|
||||
if (field.kind === "text") {
|
||||
return <TextArea value={strValue} onChange={(e) => onChange(e.target.value)} rows={3} />;
|
||||
}
|
||||
if (field.kind === "bool") {
|
||||
const checked = value === true || strValue === "true" || strValue === "1";
|
||||
return (
|
||||
<Checkbox checked={checked} onCheckedChange={(c) => onChange(Boolean(c))} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TextField.Root
|
||||
value={strValue}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (field.kind === "int" || field.kind === "float") {
|
||||
onChange(raw === "" ? "" : Number(raw));
|
||||
} else {
|
||||
onChange(raw);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandCombobox({
|
||||
kind,
|
||||
value,
|
||||
onPick,
|
||||
tunnelId,
|
||||
}: {
|
||||
kind: ComboboxKind;
|
||||
value: unknown;
|
||||
onPick: (v: unknown) => void;
|
||||
tunnelId: string;
|
||||
}) {
|
||||
const strVal = typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
|
||||
const loadOptions = useCallback(
|
||||
async (query: string) => {
|
||||
try {
|
||||
if (kind === "items") {
|
||||
return sortCommandOptions(kind, await managementApi.searchItems(tunnelId, query, 30));
|
||||
}
|
||||
if (kind === "vehicles") {
|
||||
return sortCommandOptions(kind, await managementApi.searchVehicles(tunnelId, query, 30));
|
||||
}
|
||||
if (kind === "skill-modules") {
|
||||
return sortCommandOptions(kind, await managementApi.searchSkillModules(tunnelId, query, 50));
|
||||
}
|
||||
return sortCommandOptions(kind, await managementApi.searchPlayers(tunnelId, query, 30));
|
||||
} catch {
|
||||
return [] as never[];
|
||||
}
|
||||
},
|
||||
[kind, tunnelId],
|
||||
);
|
||||
|
||||
const resolveLabel = useCallback(
|
||||
async (id: string): Promise<string | null> => {
|
||||
if (!id) return null;
|
||||
try {
|
||||
if (kind === "items") {
|
||||
const r = await managementApi.searchItems(tunnelId, id, 5);
|
||||
const hit = r.find((it) => it.id === id);
|
||||
return hit ? `${hit.name} · ${hit.id}` : id;
|
||||
}
|
||||
if (kind === "players") {
|
||||
const r = await managementApi.searchPlayers(tunnelId, id, 5);
|
||||
const hit = r.find((p) => p.flsId === id);
|
||||
return hit ? `${hit.name} (${hit.online}) · ${hit.flsId}` : id;
|
||||
}
|
||||
if (kind === "skill-modules") {
|
||||
const r = await managementApi.searchSkillModules(tunnelId, id, 5);
|
||||
const hit = r.find((m) => m.id === id);
|
||||
return hit ? `${hit.name} · ${hit.id}` : id;
|
||||
}
|
||||
const r = await managementApi.searchVehicles(tunnelId, id, 5);
|
||||
const hit = r.find((v) => v.id === id || v.actor_class === id);
|
||||
if (!hit) return id;
|
||||
const templates = Array.isArray(hit.templates) && hit.templates.length > 0
|
||||
? ` · templates: ${hit.templates.join(", ")}`
|
||||
: "";
|
||||
return `${hit.id}${templates}`;
|
||||
} catch {
|
||||
return id;
|
||||
}
|
||||
},
|
||||
[kind, tunnelId],
|
||||
);
|
||||
|
||||
if (kind === "items") {
|
||||
return (
|
||||
<Combobox
|
||||
value={strVal}
|
||||
onChange={onPick}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(it: any) => it.id}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(it: any) => (
|
||||
<Flex justify="between" gap="2">
|
||||
<Text size="2">{it.name}</Text>
|
||||
<Text size="1" color="gray" className="mono">{it.id}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick an item…"
|
||||
searchPlaceholder="Search items…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (kind === "vehicles") {
|
||||
return (
|
||||
<Combobox
|
||||
value={strVal}
|
||||
onChange={onPick}
|
||||
loadOptions={loadOptions}
|
||||
// Server expects the DT_VehicleTemplates row key (e.g. "Sandbike"),
|
||||
// not the full BP actor class path.
|
||||
getOptionValue={(v: any) => v.id}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(v: any) => (
|
||||
<Flex direction="column">
|
||||
<Text size="2">{v.id}</Text>
|
||||
<Text size="1" color="gray">
|
||||
templates: {Array.isArray(v.templates) && v.templates.length > 0 ? v.templates.join(", ") : "—"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick a vehicle…"
|
||||
searchPlaceholder="Search vehicles…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (kind === "skill-modules") {
|
||||
return (
|
||||
<Combobox
|
||||
value={strVal}
|
||||
onChange={onPick}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(m: any) => m.id}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(m: any) => (
|
||||
<Flex justify="between" gap="2">
|
||||
<Box>
|
||||
<Text size="2">{m.name}</Text>
|
||||
<Text size="1" color="gray" as="div">
|
||||
{m.category} · max {m.maxLevel}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="1" color="gray" className="mono">{m.id}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick a skill module…"
|
||||
searchPlaceholder="Search skill modules…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Combobox
|
||||
value={strVal}
|
||||
onChange={onPick}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(p: any) => p.flsId}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(p: any) => (
|
||||
<Flex justify="between" gap="2" align="center">
|
||||
<Box>
|
||||
<Text size="2">{p.name || "(unnamed)"}</Text>
|
||||
<Text size="1" color="gray" as="div" className="mono">{p.flsId}</Text>
|
||||
</Box>
|
||||
<Badge color={p.online === "online" ? "green" : "gray"}>{p.online || "offline"}</Badge>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick a player…"
|
||||
searchPlaceholder="Search players…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Callout,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Flex,
|
||||
Link,
|
||||
Separator,
|
||||
Text,
|
||||
TextArea,
|
||||
TextField,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import { managementApi, managementService } from "../../services/management";
|
||||
import { openExternal } from "../../services/tauri";
|
||||
import type { RemoteServerRecord } from "../../types/server";
|
||||
import type {
|
||||
LogDto,
|
||||
RestartNoticeOptions,
|
||||
RunDto,
|
||||
ScheduleConfig,
|
||||
} from "../../types/management";
|
||||
import { formatDateTime, formatTime } from "../../utils/formatting";
|
||||
import Combobox from "./Combobox";
|
||||
import DumpPruneDialog from "./DumpPruneDialog";
|
||||
|
||||
const DIRECT_TASKS: Array<{ id: string; label: string }> = [
|
||||
{ id: "backup", label: "Backup" },
|
||||
{ id: "welcome-package", label: "Welcome package scan" },
|
||||
{ id: "update-check", label: "Check for server update" },
|
||||
{ id: "update-apply", label: "Apply server update" },
|
||||
{ id: "restart", label: "Restart server" },
|
||||
];
|
||||
|
||||
export type AutomatedTasksTabProps = {
|
||||
tunnelId: string;
|
||||
server: RemoteServerRecord;
|
||||
onAfterRestart?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
type LogState = {
|
||||
status: "idle" | "loading" | "ready" | "error";
|
||||
logs: LogDto[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export default function AutomatedTasksTab({
|
||||
tunnelId,
|
||||
server,
|
||||
onAfterRestart,
|
||||
}: AutomatedTasksTabProps) {
|
||||
const [runs, setRuns] = useState<RunDto[]>([]);
|
||||
const [busyTrigger, setBusyTrigger] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [logsByRun, setLogsByRun] = useState<Record<number, LogState>>({});
|
||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||
const [dumpPruneOpen, setDumpPruneOpen] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const r = await managementApi.listRuns(tunnelId, 50);
|
||||
setRuns(r);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}, [tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const handle = setInterval(reload, 5000);
|
||||
return () => clearInterval(handle);
|
||||
}, [reload]);
|
||||
|
||||
const fetchLogs = useCallback(
|
||||
async (runId: number) => {
|
||||
setLogsByRun((prev) => ({
|
||||
...prev,
|
||||
[runId]: { status: "loading", logs: prev[runId]?.logs ?? [] },
|
||||
}));
|
||||
try {
|
||||
const l = await managementApi.listLogs(tunnelId, 500, runId);
|
||||
setLogsByRun((prev) => ({ ...prev, [runId]: { status: "ready", logs: l } }));
|
||||
} catch (err) {
|
||||
setLogsByRun((prev) => ({
|
||||
...prev,
|
||||
[runId]: { status: "error", logs: prev[runId]?.logs ?? [], error: String(err) },
|
||||
}));
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
|
||||
const trigger = useCallback(
|
||||
async (task: string, options?: Record<string, unknown>) => {
|
||||
setBusyTrigger(task);
|
||||
try {
|
||||
await managementApi.triggerRun(tunnelId, task, options);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
alert(`Trigger ${task} failed: ${err}`);
|
||||
} finally {
|
||||
setBusyTrigger(null);
|
||||
}
|
||||
},
|
||||
[reload, tunnelId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box mt="3">
|
||||
<ScheduleSettings tunnelId={tunnelId} server={server} onAfterRestart={onAfterRestart} />
|
||||
|
||||
<Box mt="4">
|
||||
<Flex justify="between" align="start" gap="3" wrap="wrap">
|
||||
<Text size="2" color="gray">
|
||||
Run the scheduled maintenance tasks manually. Each run records its own log entries below.
|
||||
</Text>
|
||||
<Button size="1" variant="surface" onClick={reload}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex gap="2" wrap="wrap" mt="2" mb="3">
|
||||
{DIRECT_TASKS.map((t) => (
|
||||
<Button
|
||||
key={t.id}
|
||||
size="1"
|
||||
variant="surface"
|
||||
disabled={busyTrigger === t.id}
|
||||
onClick={() => trigger(t.id)}
|
||||
>
|
||||
{busyTrigger === t.id ? `Running ${t.label}…` : t.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="1"
|
||||
variant="surface"
|
||||
disabled={busyTrigger === "restart-notice"}
|
||||
onClick={() => setNoticeOpen(true)}
|
||||
>
|
||||
{busyTrigger === "restart-notice"
|
||||
? "Sending restart notice…"
|
||||
: "Send restart notice…"}
|
||||
</Button>
|
||||
<Button
|
||||
size="1"
|
||||
variant="surface"
|
||||
color="red"
|
||||
onClick={() => setDumpPruneOpen(true)}
|
||||
>
|
||||
Clean up database operations…
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{error ? (
|
||||
<Text size="1" color="red">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Box>
|
||||
<Text size="2" weight="medium" mb="2">
|
||||
Recent runs
|
||||
</Text>
|
||||
<Flex direction="column" gap="2" mt="2">
|
||||
{runs.length === 0 ? <Text color="gray">No runs yet.</Text> : null}
|
||||
{runs.map((run) => (
|
||||
<RunRow
|
||||
key={run.id}
|
||||
run={run}
|
||||
logsState={logsByRun[run.id]}
|
||||
onExpand={() => {
|
||||
if (!logsByRun[run.id]) void fetchLogs(run.id);
|
||||
}}
|
||||
onRefreshLogs={() => void fetchLogs(run.id)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<RestartNoticeDialog
|
||||
open={noticeOpen}
|
||||
onOpenChange={setNoticeOpen}
|
||||
onSubmit={async (options) => {
|
||||
setNoticeOpen(false);
|
||||
await trigger("restart-notice", options as Record<string, unknown>);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DumpPruneDialog
|
||||
open={dumpPruneOpen}
|
||||
onOpenChange={setDumpPruneOpen}
|
||||
tunnelId={tunnelId}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function RestartNoticeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (options: RestartNoticeOptions) => Promise<void>;
|
||||
}) {
|
||||
const [leadSecs, setLeadSecs] = useState(1800);
|
||||
const [frequencySecs, setFrequencySecs] = useState(600);
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content maxWidth="480px">
|
||||
<Dialog.Title>Send restart notice</Dialog.Title>
|
||||
<Dialog.Description size="2" color="gray" mb="3">
|
||||
Publishes a ServerShutdown countdown to the game. If you provide a
|
||||
title + body, an additional Generic broadcast carries them as a banner.
|
||||
</Dialog.Description>
|
||||
<Flex direction="column" gap="3">
|
||||
<Box>
|
||||
<Text size="2" weight="medium">Lead time (seconds)</Text>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
value={String(leadSecs)}
|
||||
onChange={(e) => setLeadSecs(Number(e.target.value) || 0)}
|
||||
/>
|
||||
<Text size="1" color="gray">
|
||||
How long until the restart fires. 1800 = 30 min.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="2" weight="medium">Warning frequency (seconds)</Text>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
value={String(frequencySecs)}
|
||||
onChange={(e) => setFrequencySecs(Number(e.target.value) || 0)}
|
||||
/>
|
||||
<Text size="1" color="gray">
|
||||
How often the game re-shows the countdown. 600 = every 10 min.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="2" weight="medium">Custom title (optional)</Text>
|
||||
<TextField.Root
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Scheduled maintenance"
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="2" weight="medium">Custom body (optional)</Text>
|
||||
<TextArea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Sent as an in-game banner alongside the countdown."
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex gap="2" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">Cancel</Button>
|
||||
</Dialog.Close>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const opts: RestartNoticeOptions = {
|
||||
leadSecs,
|
||||
frequencySecs,
|
||||
durationSecs: leadSecs,
|
||||
};
|
||||
if (title.trim()) opts.title = title.trim();
|
||||
if (body.trim()) opts.body = body.trim();
|
||||
void onSubmit(opts);
|
||||
}}
|
||||
>
|
||||
Send notice
|
||||
</Button>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleSettings({
|
||||
tunnelId,
|
||||
server,
|
||||
onAfterRestart,
|
||||
}: {
|
||||
tunnelId: string;
|
||||
server: RemoteServerRecord;
|
||||
onAfterRestart?: () => Promise<void> | void;
|
||||
}) {
|
||||
const [config, setConfig] = useState<ScheduleConfig | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [busyLabel, setBusyLabel] = useState("Saving…");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Editable form fields, mirroring ScheduleConfig. Reset from `config`
|
||||
// every time we enter edit mode so Cancel reverts cleanly.
|
||||
const [hour, setHour] = useState(5);
|
||||
const [minute, setMinute] = useState(0);
|
||||
const [warnFreq, setWarnFreq] = useState(600);
|
||||
const [warnDur, setWarnDur] = useState(1800);
|
||||
const [updateLead, setUpdateLead] = useState(1800);
|
||||
const [tz, setTz] = useState("UTC");
|
||||
// Master switches. Undefined from older services reads as enabled.
|
||||
const [restartEnabled, setRestartEnabled] = useState(true);
|
||||
const [updateEnabled, setUpdateEnabled] = useState(true);
|
||||
const [backupEnabled, setBackupEnabled] = useState(true);
|
||||
// 5-field cron (min hour dom mon dow); empty string = disabled.
|
||||
const [backupCron, setBackupCron] = useState("");
|
||||
const [backupCronStatus, setBackupCronStatus] = useState<
|
||||
| { state: "idle" }
|
||||
| { state: "validating" }
|
||||
| { state: "ok"; tz: string; next: string[] }
|
||||
| { state: "error"; message: string }
|
||||
>({ state: "idle" });
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const c = await managementApi.getConfig(tunnelId);
|
||||
setConfig(c);
|
||||
setHour(c.restartHour);
|
||||
setMinute(c.restartMinute);
|
||||
setWarnFreq(c.restartWarningFrequencySecs);
|
||||
setWarnDur(c.restartWarningDurationSecs);
|
||||
setUpdateLead(c.updateLeadSecs);
|
||||
setTz(c.restartTz);
|
||||
setRestartEnabled(c.restartEnabled ?? true);
|
||||
setUpdateEnabled(c.updateEnabled ?? true);
|
||||
setBackupEnabled(c.backupEnabled ?? true);
|
||||
setBackupCron(c.backupCron ?? "");
|
||||
setBackupCronStatus({ state: "idle" });
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}, [tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const startEdit = useCallback(() => {
|
||||
if (!config) return;
|
||||
setHour(config.restartHour);
|
||||
setMinute(config.restartMinute);
|
||||
setWarnFreq(config.restartWarningFrequencySecs);
|
||||
setWarnDur(config.restartWarningDurationSecs);
|
||||
setUpdateLead(config.updateLeadSecs);
|
||||
setTz(config.restartTz);
|
||||
setRestartEnabled(config.restartEnabled ?? true);
|
||||
setUpdateEnabled(config.updateEnabled ?? true);
|
||||
setBackupEnabled(config.backupEnabled ?? true);
|
||||
setBackupCron(config.backupCron ?? "");
|
||||
setBackupCronStatus({ state: "idle" });
|
||||
setEditing(true);
|
||||
setError(null);
|
||||
}, [config]);
|
||||
|
||||
// Live-validate the cron expression while editing. Empty = disabled (no
|
||||
// server round-trip). The service caps `count` at 20 and returns a parse
|
||||
// error string when invalid; we surface either the next-fire preview or
|
||||
// the error inline.
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
const trimmed = backupCron.trim();
|
||||
if (!trimmed) {
|
||||
setBackupCronStatus({ state: "idle" });
|
||||
return;
|
||||
}
|
||||
setBackupCronStatus({ state: "validating" });
|
||||
const handle = setTimeout(async () => {
|
||||
try {
|
||||
const result = await managementApi.cronPreview(tunnelId, trimmed, 5);
|
||||
if (result.ok) {
|
||||
setBackupCronStatus({ state: "ok", tz: result.tz, next: result.next });
|
||||
} else {
|
||||
setBackupCronStatus({ state: "error", message: result.error });
|
||||
}
|
||||
} catch (err) {
|
||||
setBackupCronStatus({ state: "error", message: String(err) });
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [backupCron, editing, tunnelId]);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setBusyLabel("Saving…");
|
||||
if (backupEnabled && !backupCron.trim()) {
|
||||
throw new Error("A cron expression is required while auto backup is enabled.");
|
||||
}
|
||||
if (backupCron.trim() && backupCronStatus.state === "error") {
|
||||
throw new Error(`Cron expression invalid: ${backupCronStatus.message}`);
|
||||
}
|
||||
await managementApi.setConfig(tunnelId, {
|
||||
restartHour: hour,
|
||||
restartMinute: minute,
|
||||
restartWarningFrequencySecs: warnFreq,
|
||||
restartWarningDurationSecs: warnDur,
|
||||
updateLeadSecs: updateLead,
|
||||
restartTz: tz,
|
||||
restartEnabled,
|
||||
updateEnabled,
|
||||
backupEnabled,
|
||||
backupCron: backupCron.trim(),
|
||||
});
|
||||
|
||||
setBusyLabel("Restarting service…");
|
||||
await managementService.restart({
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
});
|
||||
|
||||
// Poll until the API is back up. Local SSH tunnel survives the restart;
|
||||
// the remote axum listener just takes ~1s to rebind.
|
||||
const deadline = Date.now() + 15_000;
|
||||
let lastErr: unknown = null;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
try {
|
||||
await managementApi.getConfig(tunnelId);
|
||||
lastErr = null;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
if (lastErr) {
|
||||
throw new Error(`service did not come back up: ${lastErr}`);
|
||||
}
|
||||
|
||||
await refresh();
|
||||
await onAfterRestart?.();
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setBusyLabel("Saving…");
|
||||
}
|
||||
}, [
|
||||
tunnelId,
|
||||
hour,
|
||||
minute,
|
||||
warnFreq,
|
||||
warnDur,
|
||||
updateLead,
|
||||
tz,
|
||||
restartEnabled,
|
||||
updateEnabled,
|
||||
backupEnabled,
|
||||
backupCron,
|
||||
backupCronStatus,
|
||||
refresh,
|
||||
server.host,
|
||||
server.user,
|
||||
server.keyPath,
|
||||
server.port,
|
||||
onAfterRestart,
|
||||
]);
|
||||
|
||||
const loadTimezones = useCallback(
|
||||
async (query: string) => {
|
||||
try {
|
||||
const all = await managementApi.listTimezones(tunnelId);
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? all.filter((tz) => tz.toLowerCase().includes(q))
|
||||
: all;
|
||||
return [...filtered]
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }))
|
||||
.slice(0, 200)
|
||||
.map((name) => ({ name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
|
||||
const restartRequired = config?.restartRequired ?? false;
|
||||
const displayHour = config ? pad2(config.restartHour) : "—";
|
||||
const displayMinute = config ? pad2(config.restartMinute) : "—";
|
||||
|
||||
return (
|
||||
<Box className="schedule-section">
|
||||
<Flex justify="between" align="baseline" mb="2">
|
||||
<Text size="3" weight="medium">Schedule settings</Text>
|
||||
{!editing && config ? (
|
||||
<Button size="1" variant="surface" onClick={startEdit}>
|
||||
Configure
|
||||
</Button>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Text size="1" color="gray">
|
||||
Stored in the service's sqlite. Saving restarts the service automatically so changes take effect immediately.
|
||||
</Text>
|
||||
|
||||
<Separator size="4" my="3" />
|
||||
|
||||
{editing ? (
|
||||
<Box className="schedule-grid">
|
||||
<Text size="2">Auto restart</Text>
|
||||
<Flex align="center" gap="2">
|
||||
<Checkbox
|
||||
checked={restartEnabled}
|
||||
onCheckedChange={(checked) => setRestartEnabled(Boolean(checked))}
|
||||
/>
|
||||
<Text size="2" color="gray">
|
||||
Run the daily restart and its warning broadcast
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Text size="2">Daily restart (HH:MM)</Text>
|
||||
<Flex gap="2" align="center">
|
||||
<TextField.Root
|
||||
inputMode="numeric"
|
||||
value={pad2(hour)}
|
||||
onChange={(e) => setHour(clampInt(e.target.value, 23))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
<Text>:</Text>
|
||||
<TextField.Root
|
||||
inputMode="numeric"
|
||||
value={pad2(minute)}
|
||||
onChange={(e) => setMinute(clampInt(e.target.value, 59))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Text size="2">Timezone</Text>
|
||||
<Combobox
|
||||
value={tz}
|
||||
onChange={(v) => setTz(v)}
|
||||
loadOptions={loadTimezones}
|
||||
getOptionValue={(o: { name: string }) => o.name}
|
||||
resolveLabel={async (id) => id}
|
||||
renderOption={(o: { name: string }) => (
|
||||
<Text size="2" className="mono">{o.name}</Text>
|
||||
)}
|
||||
placeholder="Pick a timezone…"
|
||||
searchPlaceholder="Search IANA timezones…"
|
||||
/>
|
||||
|
||||
<Text size="2">Warning lead (seconds)</Text>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
value={String(warnDur)}
|
||||
onChange={(e) => setWarnDur(Number(e.target.value) || 0)}
|
||||
/>
|
||||
|
||||
<Text size="2">Warning frequency (seconds)</Text>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
value={String(warnFreq)}
|
||||
onChange={(e) => setWarnFreq(Number(e.target.value) || 0)}
|
||||
/>
|
||||
|
||||
<Text size="2">Auto update</Text>
|
||||
<Flex align="center" gap="2">
|
||||
<Checkbox
|
||||
checked={updateEnabled}
|
||||
onCheckedChange={(checked) => setUpdateEnabled(Boolean(checked))}
|
||||
/>
|
||||
<Text size="2" color="gray">
|
||||
Check Steam for new builds and apply them automatically
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Text size="2">Update apply lead (seconds)</Text>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
value={String(updateLead)}
|
||||
onChange={(e) => setUpdateLead(Number(e.target.value) || 0)}
|
||||
/>
|
||||
|
||||
<Text size="2">Auto backup</Text>
|
||||
<Flex align="center" gap="2">
|
||||
<Checkbox
|
||||
checked={backupEnabled}
|
||||
onCheckedChange={(checked) => setBackupEnabled(Boolean(checked))}
|
||||
/>
|
||||
<Text size="2" color="gray">
|
||||
Run scheduled backups (also requires a cron below)
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Text size="2">
|
||||
Backup cron (5-field){" "}
|
||||
<Link
|
||||
size="1"
|
||||
href="https://crontab.guru/"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openExternal("https://crontab.guru/");
|
||||
}}
|
||||
>
|
||||
crontab.guru
|
||||
</Link>
|
||||
</Text>
|
||||
<Box>
|
||||
<TextField.Root
|
||||
value={backupCron}
|
||||
onChange={(e) => setBackupCron(e.target.value)}
|
||||
placeholder="e.g. 0 4 * * * (every day at 04:00)"
|
||||
/>
|
||||
<Box mt="1">
|
||||
{backupEnabled && !backupCron.trim() ? (
|
||||
<Text size="1" color="red">
|
||||
A cron expression is required while auto backup is enabled.
|
||||
</Text>
|
||||
) : (
|
||||
<CronStatusHint status={backupCronStatus} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="schedule-grid">
|
||||
<Text size="2" color="gray">Auto restart</Text>
|
||||
<Text size="2">
|
||||
{config ? ((config.restartEnabled ?? true) ? "enabled" : "disabled") : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Daily restart</Text>
|
||||
<Text size="2">
|
||||
{displayHour}:{displayMinute}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Timezone</Text>
|
||||
<Text size="2" className="mono">{config?.restartTz ?? "—"}</Text>
|
||||
|
||||
<Text size="2" color="gray">Warning lead</Text>
|
||||
<Text size="2">
|
||||
{config ? `${config.restartWarningDurationSecs}s` : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Warning frequency</Text>
|
||||
<Text size="2">
|
||||
{config ? `${config.restartWarningFrequencySecs}s` : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Auto update</Text>
|
||||
<Text size="2">
|
||||
{config ? ((config.updateEnabled ?? true) ? "enabled" : "disabled") : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Update apply lead</Text>
|
||||
<Text size="2">
|
||||
{config ? `${config.updateLeadSecs}s` : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Auto backup</Text>
|
||||
<Text size="2">
|
||||
{config ? ((config.backupEnabled ?? true) ? "enabled" : "disabled") : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Backup cron</Text>
|
||||
<Text size="2" className="mono">
|
||||
{config
|
||||
? config.backupCron && config.backupCron.trim()
|
||||
? config.backupCron
|
||||
: "disabled (manual only)"
|
||||
: "—"}
|
||||
</Text>
|
||||
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<Text size="1" color="red" mt="2">{error}</Text>
|
||||
) : null}
|
||||
|
||||
<Flex gap="2" mt="3" align="center" wrap="wrap">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button size="1" onClick={save} disabled={busy}>
|
||||
{busy ? busyLabel : "Save"}
|
||||
</Button>
|
||||
<Button size="1" variant="soft" color="gray" onClick={cancelEdit} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{!editing && restartRequired ? (
|
||||
<Callout.Root color="amber" size="1" style={{ padding: "4px 10px" }}>
|
||||
<Callout.Text>
|
||||
Saved values differ from what the running service loaded — restart the service to apply them.
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function RunRow({
|
||||
run,
|
||||
logsState,
|
||||
onExpand,
|
||||
onRefreshLogs,
|
||||
}: {
|
||||
run: RunDto;
|
||||
logsState: LogState | undefined;
|
||||
onExpand: () => void;
|
||||
onRefreshLogs: () => void;
|
||||
}) {
|
||||
return (
|
||||
<details
|
||||
className="run-row"
|
||||
onToggle={(e) => {
|
||||
if ((e.currentTarget as HTMLDetailsElement).open) onExpand();
|
||||
}}
|
||||
>
|
||||
<summary className="run-row-summary">
|
||||
<Text size="1" className="mono" style={{ minWidth: 40, color: "var(--gray-9)" }}>
|
||||
#{run.id}
|
||||
</Text>
|
||||
<Text size="2" style={{ flex: "0 0 auto", minWidth: 140 }}>
|
||||
{run.taskId}
|
||||
</Text>
|
||||
<Badge color={statusColor(run.status)}>{run.status}</Badge>
|
||||
<Text size="1" className="mono" style={{ color: "var(--gray-10)" }}>
|
||||
{formatDateTime(run.startedAt)}
|
||||
</Text>
|
||||
<Text size="1" className="mono" style={{ color: "var(--gray-10)", marginLeft: "auto" }}>
|
||||
{run.durationMs != null ? `${(run.durationMs / 1000).toFixed(1)}s` : "—"}
|
||||
</Text>
|
||||
</summary>
|
||||
<Box className="run-row-body">
|
||||
<Flex justify="between" align="center" mb="2">
|
||||
<Text size="1" color="gray">
|
||||
{run.trigger}
|
||||
{run.dryRun ? " · dry-run" : ""}
|
||||
{run.error ? ` · error: ${run.error}` : ""}
|
||||
</Text>
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRefreshLogs();
|
||||
}}
|
||||
>
|
||||
{logsState?.status === "loading" ? "Loading…" : "Refresh logs"}
|
||||
</Button>
|
||||
</Flex>
|
||||
<Box className="run-log-box">
|
||||
{logsState === undefined || logsState.status === "loading" ? (
|
||||
<Text color="gray" size="1">
|
||||
Loading logs…
|
||||
</Text>
|
||||
) : logsState.status === "error" ? (
|
||||
<Text color="red" size="1">
|
||||
{logsState.error}
|
||||
</Text>
|
||||
) : logsState.logs.length === 0 ? (
|
||||
<Text color="gray" size="1">
|
||||
No log entries for this run.
|
||||
</Text>
|
||||
) : (
|
||||
logsState.logs.map((log) => (
|
||||
<div key={log.id}>
|
||||
<span className={`log-level-${log.level}`}>{log.level.toUpperCase()}</span>
|
||||
<span className="log-ts">{formatTime(log.createdAt)}</span>
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function CronStatusHint({
|
||||
status,
|
||||
}: {
|
||||
status:
|
||||
| { state: "idle" }
|
||||
| { state: "validating" }
|
||||
| { state: "ok"; tz: string; next: string[] }
|
||||
| { state: "error"; message: string };
|
||||
}) {
|
||||
if (status.state === "idle") {
|
||||
return (
|
||||
<Text size="1" color="gray">
|
||||
Empty = disabled. Standard 5-field cron (min hour day month dow) in your configured timezone.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (status.state === "validating") {
|
||||
return (
|
||||
<Text size="1" color="gray">
|
||||
Checking…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (status.state === "error") {
|
||||
return (
|
||||
<Text size="1" color="red">
|
||||
{status.message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Text size="1" color="green">
|
||||
Valid. Next runs ({status.tz}):
|
||||
</Text>
|
||||
<Flex direction="column" mt="1" gap="1">
|
||||
{status.next.map((time) => (
|
||||
<Text key={time} size="1" className="mono" color="gray">
|
||||
{time}
|
||||
</Text>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(s: string): "gray" | "green" | "red" | "amber" {
|
||||
if (s === "success") return "green";
|
||||
if (s === "failed") return "red";
|
||||
if (s === "running") return "amber";
|
||||
return "gray";
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
// Parse a numeric text field, ignoring non-digits, and clamp to [0, max].
|
||||
// Used by the HH:MM restart fields so they can show zero-padded values
|
||||
// (a native number input strips leading zeros).
|
||||
function clampInt(raw: string, max: number): number {
|
||||
const n = Number(raw.replace(/\D/g, ""));
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.min(Math.trunc(n), max);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Box, Button, Flex, Popover, Text, TextField } from "@radix-ui/themes";
|
||||
|
||||
export type ComboboxProps<T> = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
loadOptions: (query: string) => Promise<T[]>;
|
||||
getOptionValue: (option: T) => string;
|
||||
renderOption: (option: T) => ReactNode;
|
||||
/** Resolve the friendly label for the current value (e.g. fetch the item name). */
|
||||
resolveLabel?: (value: string) => Promise<string | null>;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
disabled?: boolean;
|
||||
/** Optional override of the trigger label (e.g. "All online" sentinel). */
|
||||
triggerLabelOverride?: (value: string) => string | null;
|
||||
};
|
||||
|
||||
export default function Combobox<T>({
|
||||
value,
|
||||
onChange,
|
||||
loadOptions,
|
||||
getOptionValue,
|
||||
renderOption,
|
||||
resolveLabel,
|
||||
placeholder = "(none)",
|
||||
searchPlaceholder = "Search…",
|
||||
disabled,
|
||||
triggerLabelOverride,
|
||||
}: ComboboxProps<T>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [options, setOptions] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resolvedLabel, setResolvedLabel] = useState<string | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Resolve the friendly label whenever the value changes (and isn't an override).
|
||||
useEffect(() => {
|
||||
const override = triggerLabelOverride?.(value);
|
||||
if (override !== undefined && override !== null) {
|
||||
setResolvedLabel(override);
|
||||
return;
|
||||
}
|
||||
if (!value || !resolveLabel) {
|
||||
setResolvedLabel(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
resolveLabel(value)
|
||||
.then((label) => {
|
||||
if (!cancelled) setResolvedLabel(label);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResolvedLabel(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [value, resolveLabel, triggerLabelOverride]);
|
||||
|
||||
// Debounced load on open + on query change.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const handle = setTimeout(() => {
|
||||
loadOptions(query)
|
||||
.then((opts) => {
|
||||
if (!cancelled) {
|
||||
setOptions(opts);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setOptions([]);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [open, query, loadOptions]);
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
// Reset search and focus the input when opening.
|
||||
setQuery("");
|
||||
setTimeout(() => searchRef.current?.focus(), 30);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pick = useCallback(
|
||||
(option: T) => {
|
||||
onChange(getOptionValue(option));
|
||||
setOpen(false);
|
||||
},
|
||||
[getOptionValue, onChange],
|
||||
);
|
||||
|
||||
const triggerLabel = resolvedLabel ?? value ?? "";
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger>
|
||||
<Button
|
||||
variant="surface"
|
||||
color="gray"
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
className="combobox-trigger"
|
||||
>
|
||||
<Flex align="center" justify="between" gap="2" width="100%">
|
||||
{triggerLabel ? (
|
||||
<Text size="2" className="combobox-trigger-label">
|
||||
{triggerLabel}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="2" color="gray">
|
||||
{placeholder}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="1" color="gray" aria-hidden>
|
||||
▾
|
||||
</Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content size="1" minWidth="320px" maxWidth="480px">
|
||||
<Flex direction="column" gap="2">
|
||||
<TextField.Root
|
||||
ref={searchRef}
|
||||
size="1"
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Box className="combobox-list">
|
||||
{loading ? (
|
||||
<Text size="1" color="gray">
|
||||
Loading…
|
||||
</Text>
|
||||
) : options.length === 0 ? (
|
||||
<Text size="1" color="gray">
|
||||
No matches.
|
||||
</Text>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<button
|
||||
key={getOptionValue(option)}
|
||||
type="button"
|
||||
className="combobox-option"
|
||||
onClick={() => pick(option)}
|
||||
>
|
||||
{renderOption(option)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
{value ? (
|
||||
<Flex justify="end">
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Flex,
|
||||
Separator,
|
||||
Text,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import { managementApi } from "../../services/management";
|
||||
import type { DumpPruneItem, DumpPruneResult } from "../../types/management";
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "confirming"; items: DumpPruneItem[]; selected: Set<string> }
|
||||
| { status: "deleting"; items: DumpPruneItem[]; selected: Set<string> }
|
||||
| { status: "done"; result: DumpPruneResult };
|
||||
|
||||
function itemKey(item: { namespace: string; name: string }): string {
|
||||
return `${item.namespace}/${item.name}`;
|
||||
}
|
||||
|
||||
export type DumpPruneDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tunnelId: string;
|
||||
};
|
||||
|
||||
export default function DumpPruneDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
tunnelId,
|
||||
}: DumpPruneDialogProps) {
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setState({ status: "loading" });
|
||||
try {
|
||||
const items = await managementApi.dumpPrunePreview(tunnelId);
|
||||
const selected = new Set(items.map(itemKey));
|
||||
// Drop straight into confirming — every row pre-selected; the operator
|
||||
// ticks off anything they want to keep before clicking Delete.
|
||||
setState({ status: "confirming", items, selected });
|
||||
} catch (err) {
|
||||
setState({ status: "error", message: String(err) });
|
||||
}
|
||||
}, [tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load();
|
||||
}, [open, load]);
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setState((prev) => {
|
||||
if (prev.status !== "confirming") return prev;
|
||||
const next = new Set(prev.selected);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return { ...prev, selected: next };
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = (checked: boolean) => {
|
||||
setState((prev) => {
|
||||
if (prev.status !== "confirming") return prev;
|
||||
const next = checked ? new Set(prev.items.map(itemKey)) : new Set<string>();
|
||||
return { ...prev, selected: next };
|
||||
});
|
||||
};
|
||||
|
||||
const runDelete = async () => {
|
||||
if (state.status !== "confirming") return;
|
||||
const targets = state.items
|
||||
.filter((item) => state.selected.has(itemKey(item)))
|
||||
.map((item) => ({ namespace: item.namespace, name: item.name }));
|
||||
if (targets.length === 0) return;
|
||||
setState({ status: "deleting", items: state.items, selected: state.selected });
|
||||
try {
|
||||
const result = await managementApi.dumpPruneExecute(tunnelId, targets);
|
||||
setState({ status: "done", result });
|
||||
} catch (err) {
|
||||
setState({ status: "error", message: String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content maxWidth="720px">
|
||||
<Dialog.Title>Clean up database operations</Dialog.Title>
|
||||
<Dialog.Description size="2" color="gray" mb="3">
|
||||
Removes terminal <Text className="mono">DatabaseOperation</Text> resources from the
|
||||
cluster — both <Badge color="green">Succeeded</Badge> (artifact on disk, CR is just
|
||||
bookkeeping) and <Badge color="red">Failed</Badge> (no artifact produced, pure
|
||||
clutter). Covers both <Text className="mono">dump</Text> and{" "}
|
||||
<Text className="mono">import</Text> actions. The pod attached to each is
|
||||
garbage-collected by Funcom's operator. The{" "}
|
||||
<Text className="mono">.backup</Text> file on disk is{" "}
|
||||
<Text weight="bold">never</Text> touched. In-progress operations are not listed.
|
||||
</Dialog.Description>
|
||||
|
||||
<Separator size="4" my="2" />
|
||||
|
||||
<Body state={state} onToggle={toggle} onSelectAll={selectAll} />
|
||||
|
||||
<Flex gap="2" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
{state.status === "done" ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
{state.status === "confirming" ? (
|
||||
<Button
|
||||
color="red"
|
||||
disabled={state.selected.size === 0}
|
||||
onClick={runDelete}
|
||||
>
|
||||
Delete {state.selected.size} selected
|
||||
</Button>
|
||||
) : null}
|
||||
{state.status === "done" ? (
|
||||
<Button onClick={() => void load()}>Refresh</Button>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({
|
||||
state,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
}: {
|
||||
state: LoadState;
|
||||
onToggle: (key: string) => void;
|
||||
onSelectAll: (checked: boolean) => void;
|
||||
}) {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<Text size="2" color="gray">
|
||||
Listing eligible dump operations…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<Text size="2" color="red">
|
||||
{state.message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (state.status === "deleting") {
|
||||
return (
|
||||
<Text size="2" color="gray">
|
||||
Deleting {state.selected.size} operation(s)…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (state.status === "done") {
|
||||
return (
|
||||
<Box>
|
||||
<Text size="2" weight="medium">
|
||||
Deleted: {state.result.deleted.length}
|
||||
</Text>
|
||||
{state.result.deleted.length > 0 ? (
|
||||
<Box mt="1" mb="2">
|
||||
{state.result.deleted.map((name) => (
|
||||
<Text key={name} size="1" className="mono" color="green" as="div">
|
||||
{name}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
{state.result.skipped.length > 0 ? (
|
||||
<Box mt="2">
|
||||
<Text size="2" weight="medium" color="amber">
|
||||
Skipped: {state.result.skipped.length}
|
||||
</Text>
|
||||
{state.result.skipped.map((row) => (
|
||||
<Text
|
||||
key={itemKey(row)}
|
||||
size="1"
|
||||
className="mono"
|
||||
color="amber"
|
||||
as="div"
|
||||
>
|
||||
{row.namespace}/{row.name} — {row.reason}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// confirming
|
||||
if (state.items.length === 0) {
|
||||
return (
|
||||
<Text size="2" color="gray">
|
||||
Nothing to clean up — no terminal dump operations found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const allSelected = state.selected.size === state.items.length;
|
||||
const succeeded = state.items.filter((i) => i.phase === "Succeeded").length;
|
||||
const failed = state.items.filter((i) => i.phase === "Failed").length;
|
||||
return (
|
||||
<Box>
|
||||
<Flex align="center" gap="3" mb="2" wrap="wrap">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(checked) => onSelectAll(Boolean(checked))}
|
||||
/>
|
||||
<Text size="2">
|
||||
{allSelected ? "Deselect all" : "Select all"} ({state.items.length} total)
|
||||
</Text>
|
||||
<Flex gap="2" ml="auto">
|
||||
<Badge color="green">{succeeded} succeeded</Badge>
|
||||
<Badge color="red">{failed} failed</Badge>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Box className="dump-prune-list">
|
||||
{state.items.map((item) => {
|
||||
const key = itemKey(item);
|
||||
const checked = state.selected.has(key);
|
||||
const badgeColor: "green" | "red" | "gray" =
|
||||
item.phase === "Succeeded" ? "green" : item.phase === "Failed" ? "red" : "gray";
|
||||
return (
|
||||
<Flex key={key} gap="2" align="center" py="1">
|
||||
<Checkbox checked={checked} onCheckedChange={() => onToggle(key)} />
|
||||
<Badge color={badgeColor}>{item.phase}</Badge>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="2" className="mono" as="div">
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text size="1" color="gray" as="div">
|
||||
ns={item.namespace} · action={item.action} · age={item.ageDays}d ·{" "}
|
||||
{item.backup ? `backup=${item.backup}` : "no backup recorded"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback } from "react";
|
||||
import { Flex, Text } from "@radix-ui/themes";
|
||||
|
||||
import { managementApi } from "../../services/management";
|
||||
import Combobox from "./Combobox";
|
||||
|
||||
export type ItemComboboxProps = {
|
||||
tunnelId: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export default function ItemCombobox({ tunnelId, value, onChange }: ItemComboboxProps) {
|
||||
const loadOptions = useCallback(
|
||||
async (query: string) => {
|
||||
try {
|
||||
const rows = await managementApi.searchItems(tunnelId, query, 30);
|
||||
return [...rows].sort((a, b) =>
|
||||
(a.name || a.id).localeCompare(b.name || b.id, undefined, {
|
||||
sensitivity: "base",
|
||||
numeric: true,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
|
||||
const resolveLabel = useCallback(
|
||||
async (id: string): Promise<string | null> => {
|
||||
if (!id) return null;
|
||||
try {
|
||||
const rows = await managementApi.searchItems(tunnelId, id, 5);
|
||||
const hit = rows.find((it) => it.id === id);
|
||||
return hit ? `${hit.name} · ${hit.id}` : id;
|
||||
} catch {
|
||||
return id;
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(it) => it.id}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(it) => (
|
||||
<Flex justify="between" gap="2">
|
||||
<Text size="2">{it.name}</Text>
|
||||
<Text size="1" color="gray" className="mono">{it.id}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick an item..."
|
||||
searchPlaceholder="Search items..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
Flex,
|
||||
Text,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerRecord } from "../../types/server";
|
||||
import { managementService } from "../../services/management";
|
||||
import {
|
||||
INSTALL_STEPS,
|
||||
type InstallProgressEvent,
|
||||
} from "../../types/management";
|
||||
import type { LogRow } from "../../types/log";
|
||||
import { listenToEvent } from "../../services/tauri";
|
||||
import { log } from "../../utils/logging";
|
||||
|
||||
import type { ManagementStatusState } from "./useManagementStatus";
|
||||
|
||||
export type ManagementServiceCardProps = {
|
||||
server: RemoteServerRecord;
|
||||
status: ManagementStatusState;
|
||||
onRefresh: () => Promise<void>;
|
||||
appendLogRow: (row: LogRow) => void;
|
||||
};
|
||||
|
||||
type StepStatus = "pending" | "running" | "ok" | "error" | "skipped";
|
||||
|
||||
type InstallPhase =
|
||||
| { kind: "idle" }
|
||||
| { kind: "installing"; steps: Record<string, { status: StepStatus; message?: string }> }
|
||||
| { kind: "done"; ok: boolean; message?: string };
|
||||
|
||||
export default function ManagementServiceCard({
|
||||
server,
|
||||
status,
|
||||
onRefresh,
|
||||
appendLogRow,
|
||||
}: ManagementServiceCardProps) {
|
||||
const [bundledVersion, setBundledVersion] = useState<string | null>(null);
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const [phase, setPhase] = useState<InstallPhase>({ kind: "idle" });
|
||||
const [uninstallOpen, setUninstallOpen] = useState(false);
|
||||
const [uninstallBusy, setUninstallBusy] = useState(false);
|
||||
const [restartBusy, setRestartBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
managementService.bundledVersion().then(setBundledVersion).catch(() => setBundledVersion(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!installOpen) return;
|
||||
let unlisten: (() => void) | null = null;
|
||||
const promise = listenToEvent<InstallProgressEvent>("management-install-progress", (ev) => {
|
||||
setPhase((current) => {
|
||||
if (current.kind !== "installing") return current;
|
||||
const next = { ...current.steps };
|
||||
next[ev.step] = { status: ev.status as StepStatus, message: ev.message ?? undefined };
|
||||
return { kind: "installing", steps: next };
|
||||
});
|
||||
const stepLabel = INSTALL_STEPS.find((s) => s.id === ev.step)?.label ?? ev.step;
|
||||
const detail = ev.message ? ` (${ev.message})` : "";
|
||||
if (ev.status === "error") {
|
||||
appendLogRow(
|
||||
log.error("mgmt.install", `${stepLabel} failed${detail}`, server.id),
|
||||
);
|
||||
} else if (ev.status === "running") {
|
||||
appendLogRow(log.info("mgmt.install", `${stepLabel} started${detail}`, server.id));
|
||||
} else if (ev.status === "ok") {
|
||||
appendLogRow(log.info("mgmt.install", `${stepLabel} ok${detail}`, server.id));
|
||||
}
|
||||
});
|
||||
promise.then((fn) => {
|
||||
unlisten = fn;
|
||||
});
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [installOpen, appendLogRow, server.id]);
|
||||
|
||||
const installed = status.kind === "ok" ? status.value.installed : false;
|
||||
const active = status.kind === "ok" ? status.value.active : false;
|
||||
const installedVersion = status.kind === "ok" ? status.value.installedVersion : null;
|
||||
const remoteBundled = status.kind === "ok" ? status.value.bundledVersion : null;
|
||||
const effectiveBundled = remoteBundled ?? bundledVersion;
|
||||
const updateAvailable =
|
||||
installed && !!installedVersion && !!effectiveBundled && installedVersion !== effectiveBundled;
|
||||
|
||||
const startInstall = useCallback(async () => {
|
||||
const initial: Record<string, { status: StepStatus; message?: string }> = {};
|
||||
for (const step of INSTALL_STEPS) {
|
||||
initial[step.id] = { status: "pending" };
|
||||
}
|
||||
setPhase({ kind: "installing", steps: initial });
|
||||
appendLogRow(
|
||||
log.info(
|
||||
"mgmt.install",
|
||||
`${installed ? "Updating" : "Installing"} dune-server-service on ${server.host}…`,
|
||||
server.id,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const result = await managementService.install({
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
});
|
||||
setPhase({
|
||||
kind: "done",
|
||||
ok: result.started,
|
||||
message: result.message,
|
||||
});
|
||||
appendLogRow(
|
||||
log.info(
|
||||
"mgmt.install",
|
||||
`${installed ? "Update" : "Install"} ${result.started ? "succeeded" : "completed but service not active"}${result.installedVersion ? ` (v${result.installedVersion})` : ""}.`,
|
||||
server.id,
|
||||
),
|
||||
);
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
setPhase({ kind: "done", ok: false, message });
|
||||
appendLogRow(
|
||||
log.error("mgmt.install", `Install failed: ${message}`, server.id),
|
||||
);
|
||||
}
|
||||
}, [onRefresh, server.host, server.id, server.keyPath, server.port, server.user, installed, appendLogRow]);
|
||||
|
||||
const closeInstall = useCallback(() => {
|
||||
setInstallOpen(false);
|
||||
setPhase({ kind: "idle" });
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setRestartBusy(true);
|
||||
appendLogRow(
|
||||
log.info("mgmt.restart", `Restarting dune-server-service on ${server.host}…`, server.id),
|
||||
);
|
||||
try {
|
||||
await managementService.restart({
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
});
|
||||
appendLogRow(log.info("mgmt.restart", `Restart issued on ${server.host}.`, server.id));
|
||||
// systemd needs a moment to come back up before /api/health responds again.
|
||||
setTimeout(() => void onRefresh(), 1500);
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
appendLogRow(log.error("mgmt.restart", `Restart failed: ${message}`, server.id));
|
||||
alert(`Restart failed: ${message}`);
|
||||
} finally {
|
||||
setRestartBusy(false);
|
||||
}
|
||||
}, [server, appendLogRow, onRefresh]);
|
||||
|
||||
const handleUninstall = useCallback(async () => {
|
||||
setUninstallBusy(true);
|
||||
appendLogRow(
|
||||
log.info("mgmt.uninstall", `Uninstalling dune-server-service from ${server.host}…`, server.id),
|
||||
);
|
||||
try {
|
||||
await managementService.uninstall({
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
});
|
||||
setUninstallOpen(false);
|
||||
appendLogRow(log.info("mgmt.uninstall", `Uninstalled from ${server.host}.`, server.id));
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
appendLogRow(log.error("mgmt.uninstall", `Uninstall failed: ${message}`, server.id));
|
||||
alert(`Uninstall failed: ${message}`);
|
||||
} finally {
|
||||
setUninstallBusy(false);
|
||||
}
|
||||
}, [onRefresh, server, appendLogRow]);
|
||||
|
||||
const showInstallButton = !installed;
|
||||
const showUpdateButton = installed && updateAvailable;
|
||||
const installInProgress = phase.kind === "installing";
|
||||
|
||||
return (
|
||||
<Card mt="3">
|
||||
<Flex justify="between" align="start" gap="3" wrap="wrap">
|
||||
<Box>
|
||||
<Text size="3" weight="medium">
|
||||
Management service
|
||||
</Text>
|
||||
<Flex align="center" gap="2" mt="1" wrap="wrap">
|
||||
<Badge color={installed ? (active ? "green" : "amber") : "gray"}>
|
||||
{status.kind === "loading"
|
||||
? "checking..."
|
||||
: installed
|
||||
? active
|
||||
? `active${installedVersion ? ` ${installedVersion}` : ""}`
|
||||
: `installed, not running${installedVersion ? ` (${installedVersion})` : ""}`
|
||||
: "not installed"}
|
||||
</Badge>
|
||||
{status.kind === "ok" && status.value.initSystem ? (
|
||||
<Badge color="gray" variant="surface">
|
||||
{status.value.initSystem}
|
||||
</Badge>
|
||||
) : null}
|
||||
{updateAvailable ? (
|
||||
<Badge color="amber" variant="soft">
|
||||
update available: {installedVersion} → {effectiveBundled}
|
||||
</Badge>
|
||||
) : installed && active && installedVersion && effectiveBundled && !updateAvailable ? (
|
||||
<Text size="1" color="gray">
|
||||
Up to date
|
||||
</Text>
|
||||
) : null}
|
||||
{status.kind === "error" ? (
|
||||
<Text size="1" color="red">
|
||||
{status.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Button size="1" variant="surface" onClick={() => void onRefresh()}>
|
||||
Refresh
|
||||
</Button>
|
||||
{showInstallButton ? (
|
||||
<Button size="1" variant="solid" onClick={() => setInstallOpen(true)}>
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
{showUpdateButton ? (
|
||||
<Button size="1" variant="solid" color="amber" onClick={() => setInstallOpen(true)}>
|
||||
Update
|
||||
</Button>
|
||||
) : null}
|
||||
{installed ? (
|
||||
<Button
|
||||
size="1"
|
||||
variant="surface"
|
||||
onClick={handleRestart}
|
||||
disabled={restartBusy}
|
||||
>
|
||||
{restartBusy ? "Restarting…" : "Restart"}
|
||||
</Button>
|
||||
) : null}
|
||||
{installed ? (
|
||||
<Button size="1" variant="surface" color="red" onClick={() => setUninstallOpen(true)}>
|
||||
Uninstall
|
||||
</Button>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Dialog.Root
|
||||
open={installOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !installInProgress) closeInstall();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content maxWidth="540px">
|
||||
<Dialog.Title>{installed ? "Update management service" : "Install management service"}</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="3" color="gray">
|
||||
Uploads the bundled dune-server-service binary to{" "}
|
||||
<Text className="mono">/opt/dune-server-service/</Text>, installs the unit, and starts the service.
|
||||
</Dialog.Description>
|
||||
|
||||
{phase.kind === "idle" ? (
|
||||
<Text size="2" color="gray">
|
||||
Ready to {installed ? "update" : "install"}. Click {installed ? '"Update"' : '"Install"'} to begin.
|
||||
</Text>
|
||||
) : (
|
||||
<Box className="install-step-list">
|
||||
{INSTALL_STEPS.map((step) => {
|
||||
const s =
|
||||
phase.kind === "installing"
|
||||
? phase.steps[step.id]?.status ?? "pending"
|
||||
: "ok";
|
||||
const msg =
|
||||
phase.kind === "installing" ? phase.steps[step.id]?.message : undefined;
|
||||
return (
|
||||
<Flex key={step.id} align="center" gap="2" className="install-step-row">
|
||||
<span className={`install-step-icon install-step-${s}`} aria-hidden>
|
||||
{iconFor(s)}
|
||||
</span>
|
||||
<Text size="2">{step.label}</Text>
|
||||
{msg ? (
|
||||
<Text size="1" color="gray">
|
||||
— {msg}
|
||||
</Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
{phase.kind === "done" && !phase.ok ? (
|
||||
<Text size="1" color="red" mt="2">
|
||||
{phase.message ?? "Install failed."}
|
||||
</Text>
|
||||
) : null}
|
||||
{phase.kind === "done" && phase.ok ? (
|
||||
<Text size="1" color="green" mt="2">
|
||||
{phase.message ?? "Install complete."}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Flex gap="2" mt="4" justify="end">
|
||||
{phase.kind === "idle" ? (
|
||||
<>
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Button onClick={startInstall}>{installed ? "Update" : "Install"}</Button>
|
||||
</>
|
||||
) : phase.kind === "done" ? (
|
||||
<Button onClick={closeInstall}>Close</Button>
|
||||
) : (
|
||||
<Button disabled>Installing…</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<AlertDialog.Root open={uninstallOpen} onOpenChange={setUninstallOpen}>
|
||||
<AlertDialog.Content maxWidth="420px">
|
||||
<AlertDialog.Title>Uninstall management service?</AlertDialog.Title>
|
||||
<AlertDialog.Description size="2">
|
||||
Stops and removes <Text className="mono">dune-server-service</Text> and its unit file from the host.
|
||||
The SQLite history database under <Text className="mono">/opt/dune-server-service</Text> will be deleted.
|
||||
</AlertDialog.Description>
|
||||
<Flex gap="2" mt="4" justify="end">
|
||||
<AlertDialog.Cancel>
|
||||
<Button variant="soft" color="gray" disabled={uninstallBusy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialog.Cancel>
|
||||
<Button color="red" onClick={handleUninstall} disabled={uninstallBusy}>
|
||||
{uninstallBusy ? "Uninstalling…" : "Uninstall"}
|
||||
</Button>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function iconFor(status: StepStatus): string {
|
||||
switch (status) {
|
||||
case "ok":
|
||||
return "OK";
|
||||
case "error":
|
||||
return "X";
|
||||
case "running":
|
||||
return "…";
|
||||
case "skipped":
|
||||
return "-";
|
||||
default:
|
||||
return " ";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
Flex,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
TextField,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import { managementApi } from "../../services/management";
|
||||
import type { PlayerDto } from "../../types/management";
|
||||
import { copyTextToClipboard } from "../../utils/clipboard";
|
||||
import { formatDateTime } from "../../utils/formatting";
|
||||
|
||||
import type { AdminTabPrefill } from "./AdminTab";
|
||||
|
||||
// The service sends last-seen as a UTC wall-clock string with no offset
|
||||
// ("YYYY-MM-DD HH:MM:SS"). Tag it as UTC so it localizes instead of being
|
||||
// parsed as the viewer's local time, then render in their timezone.
|
||||
function formatLastSeen(raw: string): string {
|
||||
const s = raw.trim();
|
||||
if (!s) return "—";
|
||||
return formatDateTime(`${s.replace(" ", "T")}Z`);
|
||||
}
|
||||
|
||||
export type UsersTabProps = {
|
||||
tunnelId: string;
|
||||
onSwitchToAdmin: (prefill: AdminTabPrefill) => void;
|
||||
};
|
||||
|
||||
export default function UsersTab({ tunnelId, onSwitchToAdmin }: UsersTabProps) {
|
||||
const [users, setUsers] = useState<PlayerDto[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [onlineOnly, setOnlineOnly] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(
|
||||
async (q: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const rows = await managementApi.searchPlayers(tunnelId, q, 200);
|
||||
setUsers(rows);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void reload("");
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void reload(query.trim());
|
||||
}, 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [query, reload]);
|
||||
|
||||
// Poll for live player-status changes. Without this the list only refreshed
|
||||
// on mount / manual click, so logins and logouts went unseen until the app
|
||||
// was reopened (#13). Toggleable per #14; on by default.
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const handle = setInterval(() => {
|
||||
void reload(query.trim());
|
||||
}, 5000);
|
||||
return () => clearInterval(handle);
|
||||
}, [autoRefresh, query, reload]);
|
||||
|
||||
const visible = useMemo(
|
||||
() => (onlineOnly ? users.filter((u) => u.online.toLowerCase() === "online") : users),
|
||||
[users, onlineOnly],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box mt="3">
|
||||
<Flex gap="3" align="center" wrap="wrap" mb="3">
|
||||
<TextField.Root
|
||||
placeholder="Search name or FLS id…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{ flex: "1 1 280px", minWidth: 0 }}
|
||||
/>
|
||||
<Flex align="center" gap="2">
|
||||
<Switch checked={onlineOnly} onCheckedChange={setOnlineOnly} />
|
||||
<Text size="2">Online only</Text>
|
||||
</Flex>
|
||||
<Flex align="center" gap="2">
|
||||
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
|
||||
<Text size="2">Auto-refresh</Text>
|
||||
</Flex>
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
onClick={() => void reload(query.trim())}
|
||||
disabled={busy}
|
||||
style={{ minWidth: 64, justifyContent: "center" }}
|
||||
>
|
||||
{busy ? "Loading…" : "Refresh"}
|
||||
</Button>
|
||||
<Text
|
||||
size="1"
|
||||
color="gray"
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
flexShrink: 0,
|
||||
minWidth: 96,
|
||||
textAlign: "right",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{visible.length} of {users.length}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Text size="1" color="red">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Table.Root variant="surface" size="1">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>Name</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>FLS ID</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Level</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Partition</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Status</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Last seen</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell></Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{visible.map((user) => (
|
||||
<Table.Row key={user.flsId}>
|
||||
<Table.Cell>{user.name || <Text color="gray">—</Text>}</Table.Cell>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11 }}>
|
||||
{user.flsId}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11 }}>
|
||||
{user.level ?? <Text color="gray">—</Text>}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11 }}>
|
||||
{user.partitionId ?? <Text color="gray">—</Text>}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge color={user.online.toLowerCase() === "online" ? "green" : "gray"}>
|
||||
{user.online || "offline"}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11, color: "var(--gray-10)" }}>
|
||||
{user.online.toLowerCase() === "online" ? "—" : formatLastSeen(user.lastSeen)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button size="1" variant="ghost">
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onSelect={() => void copyTextToClipboard(user.flsId)}>
|
||||
Copy FLS ID
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() =>
|
||||
onSwitchToAdmin({
|
||||
commandId: "AddItemToInventory",
|
||||
values: { PlayerId: user.flsId },
|
||||
})
|
||||
}
|
||||
>
|
||||
Grant item…
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() =>
|
||||
onSwitchToAdmin({
|
||||
commandId: "AwardXP",
|
||||
values: { PlayerId: user.flsId },
|
||||
})
|
||||
}
|
||||
>
|
||||
Award XP…
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() =>
|
||||
onSwitchToAdmin({
|
||||
commandId: "TeleportTo",
|
||||
values: { PlayerId: user.flsId },
|
||||
})
|
||||
}
|
||||
>
|
||||
Teleport…
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
color="red"
|
||||
onSelect={() =>
|
||||
onSwitchToAdmin({
|
||||
commandId: "KickPlayer",
|
||||
values: { PlayerId: user.flsId },
|
||||
})
|
||||
}
|
||||
>
|
||||
Kick player…
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
{visible.length === 0 && !busy ? (
|
||||
<Table.Row>
|
||||
<Table.Cell colSpan={7}>
|
||||
<Text color="gray">
|
||||
No users{onlineOnly ? " online" : ""}.
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : null}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
PlusIcon,
|
||||
PaperPlaneIcon,
|
||||
ReloadIcon,
|
||||
TrashIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Flex,
|
||||
Separator,
|
||||
Table,
|
||||
Text,
|
||||
TextArea,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import { managementApi, managementService } from "../../services/management";
|
||||
import type { PlayerDto, PublishResultDto, ScheduleConfig, WelcomeGrantDto } from "../../types/management";
|
||||
import type { RemoteServerRecord } from "../../types/server";
|
||||
import { formatDateTime } from "../../utils/formatting";
|
||||
import Combobox from "./Combobox";
|
||||
import ItemCombobox from "./ItemCombobox";
|
||||
|
||||
type WelcomeAction = { type: "grantItem"; itemName: string; quantity: number };
|
||||
|
||||
export type WelcomePackageTabProps = {
|
||||
tunnelId: string;
|
||||
server: RemoteServerRecord;
|
||||
onAfterRestart?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export default function WelcomePackageTab({
|
||||
tunnelId,
|
||||
server,
|
||||
onAfterRestart,
|
||||
}: WelcomePackageTabProps) {
|
||||
const [config, setConfig] = useState<ScheduleConfig | null>(null);
|
||||
const [grants, setGrants] = useState<WelcomeGrantDto[]>([]);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [messageEnabled, setMessageEnabled] = useState(false);
|
||||
const [whisperSourcePlayer, setWhisperSourcePlayer] = useState("");
|
||||
const [welcomeMessage, setWelcomeMessage] = useState("");
|
||||
const [testRecipientPlayer, setTestRecipientPlayer] = useState("");
|
||||
const [testMessage, setTestMessage] = useState("");
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
const [actions, setActions] = useState<WelcomeAction[]>([]);
|
||||
const [contentsOpen, setContentsOpen] = useState(true);
|
||||
const [jsonMode, setJsonMode] = useState(false);
|
||||
const [jsonText, setJsonText] = useState("[]");
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [sendingWhisper, setSendingWhisper] = useState(false);
|
||||
const [whisperResult, setWhisperResult] = useState<PublishResultDto | null>(null);
|
||||
const [retryingKey, setRetryingKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const c = await managementApi.getConfig(tunnelId);
|
||||
const g = await managementApi.welcomeGrants(tunnelId, 50);
|
||||
setConfig(c);
|
||||
setEnabled(c.welcomePackageEnabled);
|
||||
setMessageEnabled(c.welcomeMessageEnabled ?? false);
|
||||
setWhisperSourcePlayer(c.welcomeWhisperSourcePlayer ?? "");
|
||||
setWelcomeMessage(c.welcomeMessage ?? "");
|
||||
const rawJson = c.welcomePackageActionsJson || c.welcomePackageItemsJson || "[]";
|
||||
setActions(parseActions(rawJson));
|
||||
// Pretty-print and keep the JSON-mode textarea in sync with what the
|
||||
// service is actually persisting, so toggling into JSON mode after a
|
||||
// reload shows the current config rather than a stale buffer.
|
||||
try {
|
||||
setJsonText(JSON.stringify(JSON.parse(rawJson), null, 2));
|
||||
} catch {
|
||||
setJsonText(rawJson);
|
||||
}
|
||||
setJsonError(null);
|
||||
setGrants(g);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}, [tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const retryGrant = useCallback(
|
||||
async (grant: WelcomeGrantDto) => {
|
||||
const key = `${grant.playerId}:${grant.packageVersion}:${grant.accountId}`;
|
||||
setRetryingKey(key);
|
||||
setError(null);
|
||||
try {
|
||||
await managementApi.retryWelcomeGrant(
|
||||
tunnelId,
|
||||
grant.playerId,
|
||||
grant.packageVersion,
|
||||
grant.accountId,
|
||||
);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setRetryingKey(null);
|
||||
}
|
||||
},
|
||||
[refresh, tunnelId],
|
||||
);
|
||||
|
||||
const actionsJson = useMemo(() => JSON.stringify(actions, null, 2), [actions]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (messageEnabled && !welcomeMessage.trim()) {
|
||||
throw new Error("Enabled welcome message needs message text.");
|
||||
}
|
||||
// In JSON mode the textarea is the source of truth — validate by
|
||||
// parsing it through the same shape check the visual editor uses.
|
||||
let outgoingActionsJson = actionsJson;
|
||||
if (jsonMode) {
|
||||
const parsed = parseActions(jsonText);
|
||||
validateActions(parsed);
|
||||
outgoingActionsJson = JSON.stringify(parsed, null, 2);
|
||||
} else {
|
||||
validateActions(actions);
|
||||
}
|
||||
await managementApi.setConfig(tunnelId, {
|
||||
welcomeMessageEnabled: messageEnabled,
|
||||
welcomePackageEnabled: enabled,
|
||||
welcomePackageVersion: "v1",
|
||||
welcomePackageActionsJson: outgoingActionsJson,
|
||||
welcomeWhisperSourcePlayer: whisperSourcePlayer,
|
||||
welcomeMessage,
|
||||
});
|
||||
await managementService.restart({
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
});
|
||||
await waitForConfig(tunnelId);
|
||||
await refresh();
|
||||
await onAfterRestart?.();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [
|
||||
actions,
|
||||
actionsJson,
|
||||
enabled,
|
||||
jsonMode,
|
||||
jsonText,
|
||||
messageEnabled,
|
||||
refresh,
|
||||
server.host,
|
||||
server.keyPath,
|
||||
server.port,
|
||||
server.user,
|
||||
tunnelId,
|
||||
whisperSourcePlayer,
|
||||
welcomeMessage,
|
||||
onAfterRestart,
|
||||
]);
|
||||
|
||||
const sendWhisper = useCallback(async () => {
|
||||
setSendingWhisper(true);
|
||||
setError(null);
|
||||
setWhisperResult(null);
|
||||
try {
|
||||
if (!testRecipientPlayer.trim()) throw new Error("Pick a recipient player.");
|
||||
if (!testMessage.trim()) throw new Error("Welcome message must not be empty.");
|
||||
const result = await managementApi.sendWelcomeWhisper(
|
||||
tunnelId,
|
||||
testRecipientPlayer,
|
||||
whisperSourcePlayer,
|
||||
testMessage,
|
||||
);
|
||||
setWhisperResult(result);
|
||||
if (result.ok) setTestOpen(false);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setSendingWhisper(false);
|
||||
}
|
||||
}, [testMessage, testRecipientPlayer, tunnelId, whisperSourcePlayer]);
|
||||
|
||||
const trigger = useCallback(async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
await managementApi.triggerRun(tunnelId, "welcome-package");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [refresh, tunnelId]);
|
||||
|
||||
const restartRequired = config?.restartRequired ?? false;
|
||||
|
||||
return (
|
||||
<Box mt="3">
|
||||
<Flex justify="between" align="start" gap="3" wrap="wrap">
|
||||
<Box>
|
||||
<Text size="3" weight="medium">Welcome automation</Text>
|
||||
<Flex gap="2" mt="2" align="center" wrap="wrap">
|
||||
<Badge color={messageEnabled ? "green" : "gray"}>
|
||||
message {messageEnabled ? "enabled" : "off"}
|
||||
</Badge>
|
||||
<Badge color={enabled ? "green" : "gray"}>
|
||||
package {enabled ? "enabled" : "off"}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Flex gap="2" align="center" wrap="wrap">
|
||||
<Button size="1" variant="surface" onClick={refresh} disabled={busy || running}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="1" variant="surface" onClick={trigger} disabled={busy || running}>
|
||||
{running ? "Running..." : "Run scan"}
|
||||
</Button>
|
||||
<Button size="1" onClick={save} disabled={busy || running}>
|
||||
{busy ? "Saving..." : "Save & restart service"}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Separator size="4" my="3" />
|
||||
|
||||
<Box className="run-row-body">
|
||||
<Flex direction="column" gap="3">
|
||||
<Flex justify="between" align="center" gap="3" wrap="wrap">
|
||||
<Text size="2" weight="medium">Welcome message</Text>
|
||||
<Flex gap="2" align="center" wrap="wrap">
|
||||
{whisperResult ? (
|
||||
<Badge color={whisperResult.ok ? "green" : "red"}>
|
||||
{whisperResult.ok ? "sent" : "failed"}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Button
|
||||
size="1"
|
||||
variant="surface"
|
||||
onClick={() => {
|
||||
setTestMessage(welcomeMessage);
|
||||
setTestOpen(true);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<PaperPlaneIcon />
|
||||
Test
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex align="center" gap="2">
|
||||
<Checkbox
|
||||
checked={messageEnabled}
|
||||
onCheckedChange={(checked) => setMessageEnabled(Boolean(checked))}
|
||||
/>
|
||||
<Text size="2">Enabled</Text>
|
||||
</Flex>
|
||||
<Flex gap="3" align="end" wrap="wrap">
|
||||
<Box style={{ flex: "1 1 280px", minWidth: 240 }}>
|
||||
<Text size="1" color="gray">Sender identity</Text>
|
||||
<PlayerCombobox
|
||||
tunnelId={tunnelId}
|
||||
value={whisperSourcePlayer}
|
||||
onChange={setWhisperSourcePlayer}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box>
|
||||
<Text size="1" color="gray">Message</Text>
|
||||
<TextArea
|
||||
value={welcomeMessage}
|
||||
onChange={(e) => setWelcomeMessage(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Separator size="4" my="4" />
|
||||
|
||||
<Box className="run-row-body">
|
||||
<Flex direction="column" gap="2" mb="2">
|
||||
<Text size="2" weight="medium">Welcome package</Text>
|
||||
<Flex align="center" gap="2">
|
||||
<Checkbox checked={enabled} onCheckedChange={(checked) => setEnabled(Boolean(checked))} />
|
||||
<Text size="2">Enabled</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Box mt="3">
|
||||
<Flex justify="between" align="center" gap="3" wrap="wrap" mb={contentsOpen ? "3" : "0"}>
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
onClick={() => setContentsOpen((open) => !open)}
|
||||
aria-expanded={contentsOpen}
|
||||
>
|
||||
{contentsOpen ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
||||
<Text size="2" weight="medium">Package contents</Text>
|
||||
<Badge color="gray">{actions.length} item{actions.length === 1 ? "" : "s"}</Badge>
|
||||
</Button>
|
||||
{contentsOpen ? (
|
||||
<Flex gap="2" wrap="wrap" align="center">
|
||||
<Text as="label" size="1" color="gray">
|
||||
<Flex align="center" gap="1">
|
||||
<Checkbox
|
||||
checked={jsonMode}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = checked === true;
|
||||
if (next) {
|
||||
// Visual -> JSON: seed the textarea from the current
|
||||
// actions so the operator can copy / hand-edit.
|
||||
setJsonText(JSON.stringify(actions, null, 2));
|
||||
setJsonError(null);
|
||||
setJsonMode(true);
|
||||
} else {
|
||||
// JSON -> Visual: parse the textarea and only switch
|
||||
// back if it's valid; otherwise stay in JSON mode and
|
||||
// show the error so nothing silently drops.
|
||||
try {
|
||||
const parsed = parseActions(jsonText);
|
||||
validateActions(parsed);
|
||||
setActions(parsed);
|
||||
setJsonError(null);
|
||||
setJsonMode(false);
|
||||
} catch (err) {
|
||||
setJsonError(String(err));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
JSON mode
|
||||
</Flex>
|
||||
</Text>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Flex>
|
||||
|
||||
{contentsOpen && jsonMode ? (
|
||||
<Flex direction="column" gap="2">
|
||||
<TextArea
|
||||
value={jsonText}
|
||||
onChange={(e) => {
|
||||
setJsonText(e.target.value);
|
||||
if (jsonError) setJsonError(null);
|
||||
}}
|
||||
placeholder='[{"type":"grantItem","itemName":"PlantFiber","quantity":1}]'
|
||||
rows={16}
|
||||
style={{ fontFamily: "var(--code-font-family, monospace)", fontSize: 12 }}
|
||||
/>
|
||||
{jsonError ? (
|
||||
<Text size="1" color="red">{jsonError}</Text>
|
||||
) : (
|
||||
<Text size="1" color="gray">
|
||||
Raw JSON of package contents. Saved after validation. Toggle JSON mode off to switch back to the visual editor.
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
) : null}
|
||||
|
||||
{contentsOpen && !jsonMode ? (
|
||||
<Flex direction="column" gap="3">
|
||||
{actions.length === 0 ? (
|
||||
<Text size="2" color="gray">No items configured.</Text>
|
||||
) : (
|
||||
<Table.Root variant="surface" size="1">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>Item</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell width="120px">Qty</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell width="44px"></Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{actions.map((action, index) => (
|
||||
<ActionRow
|
||||
key={`${index}:${action.itemName}`}
|
||||
tunnelId={tunnelId}
|
||||
action={action}
|
||||
onChange={(next) =>
|
||||
setActions((prev) => prev.map((row, i) => (i === index ? next : row)))
|
||||
}
|
||||
onRemove={() => setActions((prev) => prev.filter((_, i) => i !== index))}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
)}
|
||||
<Box>
|
||||
<Button
|
||||
size="1"
|
||||
variant="surface"
|
||||
onClick={() =>
|
||||
setActions((prev) => [
|
||||
...prev,
|
||||
{ type: "grantItem", itemName: "", quantity: 1 },
|
||||
])
|
||||
}
|
||||
>
|
||||
<PlusIcon />
|
||||
Add item
|
||||
</Button>
|
||||
</Box>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{restartRequired ? (
|
||||
<Text size="1" color="amber" as="div" mt="3">
|
||||
Saved values differ from the running service; save/restart applies the current package.
|
||||
</Text>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text size="1" color="red" as="div" mt="3">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Dialog.Root open={testOpen} onOpenChange={setTestOpen}>
|
||||
<Dialog.Content maxWidth="520px">
|
||||
<Dialog.Title>Test welcome message</Dialog.Title>
|
||||
<Flex direction="column" gap="3" mt="3">
|
||||
<Box>
|
||||
<Text size="1" color="gray">Recipient</Text>
|
||||
<PlayerCombobox
|
||||
tunnelId={tunnelId}
|
||||
value={testRecipientPlayer}
|
||||
onChange={setTestRecipientPlayer}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="1" color="gray">Message</Text>
|
||||
<TextArea
|
||||
value={testMessage}
|
||||
onChange={(e) => setTestMessage(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex justify="end" gap="2" mt="4">
|
||||
<Dialog.Close>
|
||||
<Button size="1" variant="ghost" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Button
|
||||
size="1"
|
||||
onClick={sendWhisper}
|
||||
disabled={busy || sendingWhisper}
|
||||
>
|
||||
<PaperPlaneIcon />
|
||||
{sendingWhisper ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Box mt="4">
|
||||
<Text size="2" weight="medium">Recent grants</Text>
|
||||
<Table.Root variant="surface" size="1" mt="2">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>Status</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Player</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Updated</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell width="64px"></Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{grants.length === 0 ? (
|
||||
<Table.Row>
|
||||
<Table.Cell colSpan={4}>
|
||||
<Text size="1" color="gray">No grants recorded yet.</Text>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : (
|
||||
grants.map((grant) => {
|
||||
const key = `${grant.playerId}:${grant.packageVersion}:${grant.accountId}`;
|
||||
return (
|
||||
<Table.Row key={key}>
|
||||
<Table.Cell>
|
||||
<Badge color={grant.status === "granted" ? "green" : grant.status === "failed" ? "red" : "amber"}>
|
||||
{grant.status}
|
||||
</Badge>
|
||||
{grant.status === "failed" && grant.lastError ? (
|
||||
<Text size="1" color="red" as="div" style={{ maxWidth: 320 }}>
|
||||
{grant.lastError}
|
||||
</Text>
|
||||
) : null}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text size="1" className="mono">{grant.playerId}</Text>
|
||||
{grant.characterName ? (
|
||||
<Text size="1" color="gray" as="div">{grant.characterName}</Text>
|
||||
) : null}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="mono">{formatDateTime(grant.updatedAt)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{grant.status === "failed" ? (
|
||||
<Tooltip content="Clear the failed record so the next scan retries">
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
disabled={retryingKey === key}
|
||||
onClick={() => void retryGrant(grant)}
|
||||
aria-label="Retry welcome package"
|
||||
>
|
||||
<ReloadIcon />
|
||||
{retryingKey === key ? "Retrying..." : "Retry"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({
|
||||
tunnelId,
|
||||
action,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
tunnelId: string;
|
||||
action: WelcomeAction;
|
||||
onChange: (action: WelcomeAction) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<ItemCombobox
|
||||
tunnelId={tunnelId}
|
||||
value={action.itemName}
|
||||
onChange={(itemName) => onChange({ ...action, itemName })}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
min="1"
|
||||
value={String(action.quantity)}
|
||||
onChange={(e) => onChange({ ...action, quantity: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Flex justify="center" align="center">
|
||||
<Tooltip content="Remove item">
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerCombobox({
|
||||
tunnelId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
tunnelId: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const loadOptions = useCallback(
|
||||
async (query: string) => managementApi.searchPlayers(tunnelId, query, 30),
|
||||
[tunnelId],
|
||||
);
|
||||
const resolveLabel = useCallback(
|
||||
async (id: string): Promise<string | null> => {
|
||||
if (!id) return null;
|
||||
try {
|
||||
const rows = await managementApi.searchPlayers(tunnelId, id, 5);
|
||||
const hit = rows.find((p) => p.flsId === id);
|
||||
return hit ? `${hit.name || "(unnamed)"} (${hit.online}) · ${hit.flsId}` : id;
|
||||
} catch {
|
||||
return id;
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
return (
|
||||
<Combobox<PlayerDto>
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(p) => p.flsId}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(p) => (
|
||||
<Flex justify="between" gap="2" align="center">
|
||||
<Box>
|
||||
<Text size="2">{p.name || "(unnamed)"}</Text>
|
||||
<Text size="1" color="gray" as="div" className="mono">{p.flsId}</Text>
|
||||
</Box>
|
||||
<Badge color={p.online?.toLowerCase() === "online" ? "green" : "gray"}>
|
||||
{p.online || "offline"}
|
||||
</Badge>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick a player…"
|
||||
searchPlaceholder="Search players…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function parseActions(raw: string): WelcomeAction[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || "[]");
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
if (parsed.some((row) => row && typeof row === "object" && "type" in row)) {
|
||||
return parsed
|
||||
.map((row): WelcomeAction | null => {
|
||||
if (row?.type === "grantItem") {
|
||||
return {
|
||||
type: "grantItem",
|
||||
itemName: String(row.itemName ?? row.item_name ?? ""),
|
||||
quantity: Number(row.quantity ?? 1) || 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((row): row is WelcomeAction => !!row);
|
||||
}
|
||||
return parsed
|
||||
.map((row): WelcomeAction | null => ({
|
||||
type: "grantItem",
|
||||
itemName: String(row?.itemName ?? row?.item_name ?? ""),
|
||||
quantity: Number(row?.quantity ?? 1) || 1,
|
||||
}))
|
||||
.filter((row): row is WelcomeAction => !!row && row.type === "grantItem" && row.itemName.trim().length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function validateActions(actions: WelcomeAction[]) {
|
||||
for (const action of actions) {
|
||||
if (action.type === "grantItem") {
|
||||
if (!action.itemName.trim()) throw new Error("Every item grant needs an item.");
|
||||
if (action.quantity <= 0) throw new Error(`Quantity for ${action.itemName} must be greater than 0.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConfig(tunnelId: string) {
|
||||
const deadline = Date.now() + 15_000;
|
||||
let lastErr: unknown = null;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
try {
|
||||
await managementApi.getConfig(tunnelId);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
throw new Error(`service did not come back up: ${lastErr}`);
|
||||
}
|
||||