docs(reference): import Dune: Awakening server-manager references
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 22s

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>
This commit is contained in:
Vantz Stockwell
2026-06-11 21:08:05 -04:00
parent 0715492ddf
commit 651a35d4be
1334 changed files with 238971 additions and 0 deletions

View File

@@ -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
))));
}
}

View File

@@ -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}"
))),
}
}

View File

@@ -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}"))?
}

View File

@@ -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()
}

View File

@@ -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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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
}

View File

@@ -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(),
})
}

View File

@@ -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,
};

View File

@@ -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,
})
}

View File

@@ -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('\'', "'\"'\"'"))
}

View File

@@ -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}"))?
}

View File

@@ -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");
}
}

View File

@@ -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"
)
}

View File

@@ -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
}
}

View File

@@ -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(&registry, 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(&registry, &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(&registry, 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(&registry, 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()))
}
}

View File

@@ -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)
}

View 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,
}

View 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");
}

View 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)
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
dune_dedicated_server_manager_app_lib::run();
}

View 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();
}
}
}