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,69 @@
# Reference Repos
Third-party Dune: Awakening server-management projects, kept here as **behavior
references** for Phase 2 (the Corrosion host-agent Dune adapter + future panel
Dune features). These are NOT Corrosion code and are not built or shipped — they
are read-only references. `.git` histories, `node_modules`, and compiled
binaries were stripped on import (the 38 MB `icehunter/web/dune-admin` build
artifact and a Tauri `.icns` are intentionally absent).
> Imported 2026-06-12 from `/tmp/dune-re`. Each was a separate upstream repo;
> see each project's own `LICENSE` and `README.md`. Treat as documentation.
## Why these are here
Dune: Awakening does **not** use SteamCMD or a plain game-server process like
Rust/Conan/Soulmask. It ships as **Docker container(s)** fronted by a **RabbitMQ
broker** (admin + game vhosts) and a **PostgreSQL** admin database (`dune`
schema), orchestrated as a "**battlegroup**". The game process is
`DuneSandboxServer-Linux-Shipping` (one per partition). Server settings live in
INI files (`UserEngine.ini` / `UserGame.ini`) and only take effect after a
restart. Our Dune adapter must model that container/broker/DB world instead of
the process+SteamCMD model — these repos are how that world actually works in
the wild.
## The references
### `icehunter/` — `dune-admin` (Go backend + React SPA)
The richest ops reference. A web admin panel with **four interchangeable control
planes**: `docker`, `kubectl`, `local`, and `amp` (CubeCoders AMP / podman).
Most relevant to us:
- **`SETUP_DOCKER.md`** — the Docker control plane: `docker start/stop/restart`
for lifecycle, `docker logs -f` for streaming, `docker exec` into the broker
container for RabbitMQ (`rabbitmqctl`) commands, direct TCP to the `dune`
Postgres. Optional SSH tunnelling when the admin is off-host. **This is the
closest analog to what the Corrosion host-agent Dune adapter must do.**
- `cmd/dune-admin/control_docker.go` / `control_kubectl.go` / `control_local.go`
/ `control_amp.go` — the `ControlPlane` interface and its implementations
(the start/stop/restart/status/log/broker abstraction we mirror as a Rust
game-adapter trait).
- `db.go` / `model.go` — the full Dune admin data model (players, bases,
inventory, exchange/market) for when Corrosion grows a richer Dune admin
surface beyond lifecycle.
- `CLAUDE.md` — upstream's own engineering notes; the AMP section documents the
INI-vs-API server-settings gotcha (AMP regenerates INIs on start).
### `adainrivers/` — Dune Dedicated Server Manager (Rust / Tauri desktop)
**The Rust reference.** Manages already-provisioned servers over **SSH +
Kubernetes** ("BattleGroup" start/stop/restart/update), with secure SSH tunnels
to Director / File Browser / Postgres / PgHero, an in-game admin console (item
grants, vehicle spawns, journey/XP tags), and a bundled **`dune-server-service`**
daemon for scheduled maintenance (timed restarts with in-game warnings, backups,
update apply). Closest to our stack idiomatically — read it for Rust patterns on
SSH control, the maintenance-daemon design, and the in-game command surface.
### `the4rchangel/` — Dune: Awakening Server Manager (Node.js local web UI)
**Matches the Commander's exact self-host path.** A local dashboard that
replaces the `battlegroup.bat` terminal menu — guided VM import (Hyper-V),
network, SSH, bootstrap, then daily ops: battlegroup start/stop/restart/update,
character editor, visual game-config editor (PvP, sandstorms, sandworms, mining
rates, decay, building limits), monitoring, DB access. Read it to understand the
`battlegroup.bat` workflow our agent has to drive on a Windows/Hyper-V host.
## How we use them
- **Lifecycle/control** → mirror `icehunter`'s `ControlPlane` docker provider as
the agent's Dune game-adapter (compose/`docker` lifecycle, `docker logs`
console, reject SteamCMD).
- **Rust idioms / maintenance daemon / SSH** → `adainrivers`.
- **Battlegroup.bat reality / setup flow / game-config schema** → `the4rchangel`.

View File

@@ -0,0 +1,71 @@
name: CI
on:
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
checks:
name: Workspace checks (${{ matrix.platform }})
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
platform: [windows-latest, ubuntu-22.04, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: app/package-lock.json
- name: Install Linux Tauri dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf pkg-config libssl-dev
- name: Install frontend dependencies
working-directory: app
run: npm ci
- name: Rust format
run: cargo fmt --all -- --check
- name: Rust check
run: cargo check --workspace
- name: Rust tests
run: cargo test --workspace
- name: Core API docs
run: cargo doc -p dune-manager-core --no-deps
- name: Frontend build
working-directory: app
run: npm run build
- name: Tauri shell check
run: cargo check -p dune-dedicated-server-manager-app
- name: Secret and machine-constant scan
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
rg -n -S "I:|AutoUpdate|192\.168\.2\.|menna|dune-awakening|C:\\WINDOWS\\System32\\OpenSSH|C:\\Windows\\System32\\OpenSSH|change-me-before-exposing|c05564d|d177d3bbc40be761|qRmQx|FuncomLiveServices__ServiceAuthToken" . -g "!app/**/target/**" -g "!crates/**/target/**" -g "!target/**" -g "!app/node_modules/**" -g "!app/dist/**" -g "!*.md" -g "!app/steamcmd/**" -g "!app/dune-server/**" -g "!app/vm/**" -g "!app/vm-*/**" -g "!vm/**" -g "!.tmp/**"
if ($LASTEXITCODE -eq 0) {
throw "Secret or machine-specific constant scan found matches."
}
if ($LASTEXITCODE -ne 1) {
exit $LASTEXITCODE
}

View File

@@ -0,0 +1,203 @@
name: Release
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
version:
description: "Version to release, for example 0.1.0"
required: true
type: string
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
linux-service-binary:
name: Build dune-server-service (musl)
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- name: Install Zig
uses: mlugg/setup-zig@v1
with:
version: 0.13.0
- name: Install cargo-zigbuild
run: cargo install --locked cargo-zigbuild
- name: Resolve release version
shell: bash
env:
WORKFLOW_VERSION: ${{ inputs.version }}
run: |
version="$WORKFLOW_VERSION"
if [ -z "$version" ]; then
version="${GITHUB_REF_NAME#v}"
fi
if [ -z "$version" ]; then
echo "could not resolve release version" >&2
exit 1
fi
echo "RELEASE_VERSION=$version" >> "$GITHUB_ENV"
echo "RELEASE_TAG=v$version" >> "$GITHUB_ENV"
- name: Build musl binary
run: |
cargo zigbuild -p dune-server-service --release --target x86_64-unknown-linux-musl
strip target/x86_64-unknown-linux-musl/release/dune-server-service
- name: Stage release artifacts
run: |
mkdir -p release-artifacts
cp target/x86_64-unknown-linux-musl/release/dune-server-service release-artifacts/dune-server-service
cp crates/dune-server-service/systemd/dune-server-service.service release-artifacts/dune-server-service.service
cp crates/dune-server-service/openrc/dune-server-service release-artifacts/dune-server-service.openrc
- name: Upload artifact for desktop bundle
uses: actions/upload-artifact@v4
with:
name: dune-server-service-musl
path: release-artifacts/
retention-days: 7
- name: Resolve release notes
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
notes_path="release-notes/${RELEASE_VERSION}.md"
if [ -f "$notes_path" ]; then
echo "RELEASE_BODY_PATH=$notes_path" >> "$GITHUB_ENV"
else
tmp=$(mktemp)
printf 'Release v%s. No release-notes/%s.md was provided — see the commit log for details.\n' \
"$RELEASE_VERSION" "$RELEASE_VERSION" > "$tmp"
echo "RELEASE_BODY_PATH=$tmp" >> "$GITHUB_ENV"
fi
- name: Attach to GitHub release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_TAG }}
body_path: ${{ env.RELEASE_BODY_PATH }}
files: |
release-artifacts/dune-server-service
release-artifacts/dune-server-service.service
release-artifacts/dune-server-service.openrc
desktop-app:
name: Build ${{ matrix.name }} app
needs: linux-service-binary
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- name: Windows
platform: windows-latest
args: --bundles nsis
- name: Linux
platform: ubuntu-22.04
args: --bundles appimage,deb
- name: macOS Apple Silicon
platform: macos-latest
args: --target aarch64-apple-darwin --bundles dmg
- name: macOS Intel
platform: macos-latest
args: --target x86_64-apple-darwin --bundles dmg
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ startsWith(matrix.name, 'macOS') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: app/package-lock.json
- name: Install Linux Tauri dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf pkg-config libssl-dev
- name: Install frontend dependencies
working-directory: app
run: npm ci
- name: Download bundled dune-server-service binary
uses: actions/download-artifact@v4
with:
name: dune-server-service-musl
path: app/src-tauri/binaries/
- name: Resolve release version
shell: pwsh
env:
WORKFLOW_VERSION: ${{ inputs.version }}
run: |
$version = $env:WORKFLOW_VERSION
if ([string]::IsNullOrWhiteSpace($version)) {
$version = "${{ github.ref_name }}".TrimStart("v")
}
if ([string]::IsNullOrWhiteSpace($version)) {
throw "Release version could not be resolved."
}
"RELEASE_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append
"RELEASE_TAG=v$version" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Prepare release config
shell: pwsh
run: |
$version = $env:RELEASE_VERSION
Push-Location app
npm version --no-git-tag-version --allow-same-version $version
Pop-Location
$tauriConfigPath = "app/src-tauri/tauri.conf.json"
$config = Get-Content $tauriConfigPath -Raw
$config = $config -replace '"version":\s*"[^"]+"', ('"version": "' + $version + '"')
# Release builds publish signed updater artifacts; the checked-in
# default keeps this off so local debug builds do not require
# TAURI_SIGNING_PRIVATE_KEY.
$config = $config -replace '"createUpdaterArtifacts":\s*false', '"createUpdaterArtifacts": true'
Set-Content -Path $tauriConfigPath -Value $config -NoNewline
# The body is set by the linux-service-binary job's softprops step.
# tauri-action only uploads desktop bundles + the signed updater
# artifacts here; we don't pass releaseBody to avoid clobbering.
- name: Build and publish Tauri release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_ENABLE_STARTUP_UPDATE_CHECK: "true"
with:
projectPath: app
tagName: ${{ env.RELEASE_TAG }}
releaseName: "Dune Dedicated Server Manager ${{ env.RELEASE_TAG }}"
releaseDraft: false
prerelease: false
args: ${{ matrix.args }}

View File

@@ -0,0 +1,68 @@
# Dependencies
node_modules/
app/node_modules/
# Frontend build
dist/
app/dist/
app/src-tauri/gen/schemas/
# Rust/Tauri build outputs
target/
src-tauri/target/
app/src-tauri/target/
manager-api/target/
# Local environment
.env
.env.*
!.env.example
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Docs are scratch notes for now; keep README trackable later
*.md
!README.md
!docs/
!docs/*.md
docs/rabbitmq-protocol.md
# Release notes go on GitHub releases via the release workflow.
!release-notes/
!release-notes/*.md
# Editor and OS noise
.idea/
.vscode/
*.swp
*.swo
Thumbs.db
Desktop.ini
# Local app/runtime data and secrets
.tmp/
.playwright-mcp/
app/default-config.json
app/steamcmd/
app/dune-server/
dune-server/
app/vm/
app/vm-*/
app/src-tauri/dune-server/
app/src-tauri/vm/
app/src-tauri/resources/manager-api/dune-manager-api
app/src-tauri/resources/manager-api/dune-manager-api.exe
vm/
*.pem
*.key
sshKey
codex_vm_ed25519_dropbear
codex_vm_ed25519_dropbear.pub
snapshots/
keys/
initial-setup-log.txt
secrets/

7156
docs/reference-repos/adainrivers/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
[workspace]
members = ["crates/dune-manager-core", "crates/dune-server-service", "app/src-tauri"]
resolver = "2"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 gaming.tools
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,59 @@
# Dune Dedicated Server Manager
A desktop manager for existing Dune Awakening dedicated servers.
![Dashboard — BattleGroup status, lifecycle actions, management service, and tunnel controls](images/ss-1.png)
The app manages already-provisioned Dune dedicated servers over SSH and
Kubernetes control commands. It does not install the game server, create VMs,
configure Hyper-V, provision Ubuntu, or manage external tools such as SteamCMD.
## Features
- Remote server profile management with SSH private-key authentication
- BattleGroup status, start, stop, restart, and update controls
- Component diagnostics, log viewing, and safe restart actions
- Secure Director, File Browser, PostgreSQL, and PgHero access through local SSH tunnels
- Bundled `dune-server-service` daemon for on-host scheduled maintenance (daily restarts with in-game warnings, automated backups, server update check + apply) — installed over SSH straight from the Management card
- Admin console for in-game actions: item grants, vehicle spawns, skill/journey/XP tags, player lookup with live pawn location, and a logged history of every published command
- Automated tasks tab with editable schedule settings (daily restart time, warning lead/frequency, update apply lead, IANA timezone) — saving auto-restarts the service so changes apply immediately
- Welcome Package automation: a per-player onboarding chain (item grants, water refill, welcome whisper) driven by Postgres player detection, tracked in the management service's SQLite ledger, and configurable from the Welcome Package tab with both a visual editor and a raw JSON mode
![Admin tab — granting items to online players with a searchable Funcom item picker](images/ss-2.png)
More management features coming soon.
## Install
Download the latest release for your operating system from GitHub Releases.
- Windows: run the NSIS installer.
- Linux: use the AppImage or Debian package.
- macOS: use the DMG for your Mac architecture.
After launching the app, add an existing server profile with its host, SSH user,
and private key path, then refresh it to detect BattleGroups and management
endpoints.
## Managed Server Assumptions
The target server must already be installed and reachable over SSH. The app
expects the Dune Kubernetes resources and vendor management scripts to exist on
the server before you add it.
Required player-facing/server ports depend on your own server deployment. A
typical dedicated-server deployment uses:
- UDP 7777-7810 for game servers
- TCP 31982 for RMQ
If you found a bug or are having other issues, please create an issue here:
https://github.com/adainrivers/dune-dedicated-server-manager/issues
## Building From Source
See [Building From Source](docs/building-from-source.md).
## License
MIT License. See [LICENSE](LICENSE).

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dune Dedicated Server Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Funnel+Display:wght@400;500;600;700&family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
{
"name": "dune-dedicated-server-manager-app",
"private": true,
"version": "0.3.16",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 1420",
"build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1 --port 1420",
"tauri": "tauri"
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/themes": "^3.2.1",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
"markdown-to-jsx": "^9.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

View File

@@ -0,0 +1,26 @@
[package]
name = "dune-dedicated-server-manager-app"
version = "0.2.0"
description = "Desktop shell for Dune Dedicated Server Manager"
authors = ["Dune Dedicated Server Manager"]
edition = "2021"
[lib]
name = "dune_dedicated_server_manager_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
dune-manager-core = { path = "../../crates/dune-manager-core" }
tauri = { version = "2", features = ["devtools"] }
serde = { workspace = true }
serde_json = { workspace = true }
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-shell = "2"
base64 = "0.22"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
reqwest = { version = "0.12", default-features = false, features = ["json"] }

View File

@@ -0,0 +1,6 @@
# Populated by CI from the `linux-service-binary` job artifact, or locally
# via `cargo zigbuild -p dune-server-service --release --target
# x86_64-unknown-linux-musl` + manual copy. Not tracked.
dune-server-service
dune-server-service.service
dune-server-service.openrc

View File

@@ -0,0 +1,23 @@
# Bundled service binaries
This directory holds the Linux `dune-server-service` binary (musl-static), its
systemd unit, and its OpenRC init script. They are populated by the
`linux-service-binary` job in `.github/workflows/release.yml` and bundled into
the desktop installer as Tauri resources.
For local debug builds the directory can be empty — the `install_management_service`
Tauri command surfaces a friendly error when the resource is missing.
For a local end-to-end test, build the service yourself:
```powershell
rustup target add x86_64-unknown-linux-musl
cargo install --locked cargo-zigbuild
cargo zigbuild -p dune-server-service --release --target x86_64-unknown-linux-musl
Copy-Item target\x86_64-unknown-linux-musl\release\dune-server-service `
app\src-tauri\binaries\dune-server-service
Copy-Item crates\dune-server-service\systemd\dune-server-service.service `
app\src-tauri\binaries\dune-server-service.service
Copy-Item crates\dune-server-service\openrc\dune-server-service `
app\src-tauri\binaries\dune-server-service.openrc
```

View File

@@ -0,0 +1,67 @@
fn main() {
expose_dune_server_service_version();
rerun_if_bundled_binaries_change();
tauri_build::build();
}
/// Tauri's resource-copy step only fires when Cargo decides build.rs needs to
/// re-run, which by default doesn't watch arbitrary files. Without these
/// `rerun-if-changed` lines, refreshing the bundled `dune-server-service`
/// binary or its systemd/openrc units in `binaries/` after a previous build
/// produces a stale `target/release/binaries/` copy — the running exe then
/// pushes the OLD binary on Install/Update, with no visible signal.
fn rerun_if_bundled_binaries_change() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("binaries");
// Watch the directory itself so file additions/deletions also trigger a rerun.
println!("cargo:rerun-if-changed={}", dir.display());
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
// Skip README, .gitignore, and similar bookkeeping files.
if matches!(
path.file_name().and_then(|n| n.to_str()),
Some("README.md") | Some(".gitignore")
) {
continue;
}
println!("cargo:rerun-if-changed={}", path.display());
}
}
}
fn expose_dune_server_service_version() {
let cargo_toml = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/dune-server-service/Cargo.toml");
println!("cargo:rerun-if-changed={}", cargo_toml.display());
let contents = std::fs::read_to_string(&cargo_toml)
.unwrap_or_else(|err| panic!("reading {}: {err}", cargo_toml.display()));
let version = parse_package_version(&contents).unwrap_or_else(|| {
panic!(
"could not find [package].version in {}",
cargo_toml.display()
)
});
println!("cargo:rustc-env=DUNE_SERVER_SERVICE_VERSION={version}");
}
fn parse_package_version(toml: &str) -> Option<String> {
let mut in_package = false;
for line in toml.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[package]";
continue;
}
if !in_package {
continue;
}
if let Some(rest) = trimmed.strip_prefix("version") {
let rest = rest.trim_start();
let rest = rest.strip_prefix('=')?.trim_start();
let rest = rest.trim_start_matches('"');
let end = rest.find('"')?;
return Some(rest[..end].to_string());
}
}
None
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default desktop app permissions",
"windows": ["main"],
"permissions": ["core:default", "dialog:allow-open", "process:default", "shell:allow-open", "updater:default"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

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

View File

@@ -0,0 +1,55 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Dune Dedicated Server Manager",
"version": "0.3.16",
"identifier": "dev.dune.dedicated-server-manager",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Dune Dedicated Server Manager",
"width": 1280,
"height": 820,
"minWidth": 960,
"minHeight": 640,
"devtools": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": false,
"resources": {
"binaries/dune-server-service": "binaries/dune-server-service",
"binaries/dune-server-service.service": "binaries/dune-server-service.service",
"binaries/dune-server-service.openrc": "binaries/dune-server-service.openrc"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE2N0QwRkY3NzBBNTMxNTAKUldSUU1hVnc5dzk5cGg2b3ZVKzNpUGI0M3BNVHdzWk1mdzQwSzMrdm9HVllvQlg5bEdyM1BCU0UK",
"endpoints": [
"https://github.com/adainrivers/dune-dedicated-server-manager/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
}
}

View File

@@ -0,0 +1,193 @@
import { Box, Flex, Theme } from "@radix-ui/themes";
import AppErrorBoundary from "./components/AppErrorBoundary";
import Header from "./components/layout/Header";
import LogWindow from "./components/logs/LogWindow";
import RemoteAttachDialog from "./components/dialogs/RemoteAttachDialog";
import RemoveRemoteServerDialog from "./components/dialogs/RemoveRemoteServerDialog";
import UpdateDialog from "./components/dialogs/UpdateDialog";
import ServerDetailPage from "./components/servers/ServerDetailPage";
import ServersListPage from "./components/servers/ServersListPage";
import { useAppUpdates } from "./hooks/useAppUpdates";
import { useComponentActions } from "./hooks/useComponentActions";
import { useOperationLogs } from "./hooks/useOperationLogs";
import { useRemoteServerStatus } from "./hooks/useRemoteServerStatus";
import { useRemoteServers } from "./hooks/useRemoteServers";
import { useServerTunnels } from "./hooks/useServerTunnels";
import { useActivePage } from "./hooks/useActivePage";
import { log } from "./utils/logging";
export function App() {
const {
logLevelFilter,
setLogLevelFilter,
logPanelCollapsed,
setLogPanelCollapsed,
scopeToActiveServer,
setScopeToActiveServer,
appendLogRow,
clearLogRows,
renderedLogRows,
} = useOperationLogs();
const remoteServersHook = useRemoteServers({ appendLogRow });
const tunnels = useServerTunnels({ appendLogRow });
const status = useRemoteServerStatus({
appendLogRow,
setRemoteServers: remoteServersHook.setRemoteServers,
});
const componentActions = useComponentActions({
appendLogRow,
detectRemoteServerDetails: status.detectRemoteServerDetails,
setRemoteServerComponents: status.setRemoteServerComponents,
setRemoteComponentLogs: status.setRemoteComponentLogs,
setRemoteComponentLogBusy: status.setRemoteComponentLogBusy,
setRemoteComponentRestartBusy: status.setRemoteComponentRestartBusy,
});
const updates = useAppUpdates({ appendLogRow });
remoteServersHook.bindRefreshRemoteServerStatus(status.refreshRemoteServerStatus);
remoteServersHook.bindRemoteServerBusy(status.remoteServerBusy);
remoteServersHook.bindClearStatusForServer(status.clearStatusForServer);
remoteServersHook.bindStopTunnelsForServer(tunnels.stopTunnelsForServer);
const { activePage, openServer, openServersList, setSub } = useActivePage({
remoteServers: remoteServersHook.remoteServers,
});
const scopeServerId = activePage.kind === "server" ? activePage.serverId : undefined;
const visibleLogRows =
scopeServerId && scopeToActiveServer
? renderedLogRows.filter((row) => !row.serverId || row.serverId === scopeServerId)
: renderedLogRows;
const activeServer =
activePage.kind === "server"
? remoteServersHook.remoteServers.find((server) => server.id === activePage.serverId)
: undefined;
return (
<Theme
appearance="dark"
accentColor="bronze"
grayColor="sand"
radius="medium"
scaling="100%"
panelBackground="solid"
>
<Flex direction="column" height="100vh" className="app-shell">
<Header
activePage={activePage}
servers={remoteServersHook.remoteServers}
statuses={status.remoteServerStatuses}
statusErrors={status.remoteServerStatusErrors}
busyMap={status.remoteServerBusy}
onOpenServersList={openServersList}
onOpenServer={openServer}
onAddServer={() => remoteServersHook.setRemoteAttachOpen(true)}
updateStatus={updates.updateStatus}
update={updates.availableUpdate}
updateProgress={updates.updateProgress}
onCheckUpdate={updates.checkForAppUpdate}
onOpenUpdate={() => updates.setUpdateDialogOpen(true)}
/>
<Flex className="content-shell" gap="3" p="4" pt="0" minHeight="0">
<Box className="main-pane">
<AppErrorBoundary onError={(message) => appendLogRow(log.error("ui", message))}>
{activePage.kind === "servers" || !activeServer ? (
<ServersListPage
servers={remoteServersHook.remoteServers}
statuses={status.remoteServerStatuses}
statusErrors={status.remoteServerStatusErrors}
busyMap={status.remoteServerBusy}
onOpenServer={openServer}
onAddServer={() => remoteServersHook.setRemoteAttachOpen(true)}
/>
) : (
<ServerDetailPage
server={activeServer}
sub={activePage.sub}
onSubChange={setSub}
status={status.remoteServerStatuses[activeServer.id]}
statusError={status.remoteServerStatusErrors[activeServer.id]}
busyLabel={status.remoteServerBusy[activeServer.id]}
components={status.remoteServerComponents[activeServer.id] ?? []}
componentLogs={status.remoteComponentLogs}
componentLogBusy={status.remoteComponentLogBusy}
componentRestartBusy={status.remoteComponentRestartBusy}
tunnels={tunnels.serverTunnels}
tunnelBusy={tunnels.serverTunnelBusy}
onRefresh={() => status.refreshRemoteServerStatus(activeServer)}
onRemove={() => remoteServersHook.setRemoteServerToRemove(activeServer)}
onStartBattlegroup={() => status.runRemoteBattlegroupAction(activeServer, "start")}
onStopBattlegroup={() => status.runRemoteBattlegroupAction(activeServer, "stop")}
onRestartBattlegroup={() =>
status.runRemoteBattlegroupAction(activeServer, "restart")
}
onUpdateBattlegroup={() => status.runRemoteBattlegroupAction(activeServer, "update")}
onStartTunnel={tunnels.startServerTunnel}
onStartCustomTunnel={tunnels.startCustomTunnel}
onStopTunnel={tunnels.stopServerTunnel}
onOpenTunnel={tunnels.openServerTunnel}
onRefreshComponentLog={(component) =>
componentActions.refreshRemoteComponentLog(activeServer, component)
}
onRestartComponent={(component) =>
componentActions.restartRemoteComponent(activeServer, component)
}
appendLogRow={appendLogRow}
/>
)}
</AppErrorBoundary>
</Box>
<LogWindow
rows={visibleLogRows}
level={logLevelFilter}
collapsed={logPanelCollapsed}
scopedToServer={scopeToActiveServer}
canScopeToServer={!!scopeServerId}
onLevelChange={setLogLevelFilter}
onClear={clearLogRows}
onToggleCollapsed={() => setLogPanelCollapsed((collapsed) => !collapsed)}
onToggleScope={setScopeToActiveServer}
/>
</Flex>
<RemoteAttachDialog
open={remoteServersHook.remoteAttachOpen}
form={remoteServersHook.remoteAttachForm}
running={remoteServersHook.remoteAttachRunning}
errorMessage={remoteServersHook.remoteAttachError}
preflight={remoteServersHook.remoteAttachPreflight}
onOpenChange={(open) => {
remoteServersHook.setRemoteAttachOpen(open);
if (!open) remoteServersHook.setRemoteAttachError(null);
}}
onChange={remoteServersHook.setRemoteAttachForm}
onAttach={remoteServersHook.addRemoteServer}
/>
<RemoveRemoteServerDialog
server={remoteServersHook.remoteServerToRemove}
onOpenChange={(open) => {
if (!open) remoteServersHook.setRemoteServerToRemove(null);
}}
onRemove={(server) => {
remoteServersHook.removeRemoteServer(server);
remoteServersHook.setRemoteServerToRemove(null);
}}
/>
<UpdateDialog
open={updates.updateDialogOpen}
update={updates.availableUpdate}
status={updates.updateStatus}
progress={updates.updateProgress}
onOpenChange={updates.setUpdateDialogOpen}
onInstall={updates.installAppUpdate}
/>
</Flex>
</Theme>
);
}

View File

@@ -0,0 +1,43 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Card, Flex, Heading, Text } from "@radix-ui/themes";
export type AppErrorBoundaryProps = {
onError: (message: string) => void;
children: ReactNode;
};
type AppErrorBoundaryState = {
error: string | null;
};
export default class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
state: AppErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): AppErrorBoundaryState {
return { error: error.message };
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError(`${error.message}\n${info.componentStack}`);
}
render() {
if (this.state.error) {
return (
<Card size="3" variant="surface" className="pane page-pane">
<Flex direction="column" gap="3">
<Heading size="4">UI Error</Heading>
<Text size="2" color="gray">
The view failed to render. Details were written to the log window.
</Text>
<Text size="2" className="mono">
{this.state.error}
</Text>
</Flex>
</Card>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import { getVersion } from "@tauri-apps/api/app";
import { InfoCircledIcon } from "@radix-ui/react-icons";
import { Button, Dialog, Flex, IconButton, Link, Text } from "@radix-ui/themes";
import { openExternal } from "../../services/tauri";
const REPO_URL = "https://github.com/adainrivers/dune-dedicated-server-manager";
const ISSUES_URL = `${REPO_URL}/issues`;
/**
* Small info button (sits next to "Check for updates") that opens an About
* modal showing the app version and links back to the project. Self-contained:
* owns its open state so it can be dropped into the header without prop
* threading.
*/
export default function AboutDialog() {
const [open, setOpen] = useState(false);
const [version, setVersion] = useState<string | null>(null);
// The bundled dune-server-service ships with the same version as the app,
// so this number identifies both. Fetched from the Tauri runtime rather than
// package.json so it reflects the actually-installed build.
useEffect(() => {
let active = true;
void getVersion()
.then((v) => {
if (active) setVersion(v);
})
.catch(() => {
if (active) setVersion(null);
});
return () => {
active = false;
};
}, []);
const openLink = (url: string) => () => {
void openExternal(url);
};
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>
<IconButton size="1" variant="surface" aria-label="About this app" title="About">
<InfoCircledIcon />
</IconButton>
</Dialog.Trigger>
<Dialog.Content maxWidth="460px">
<Dialog.Title>About</Dialog.Title>
<Dialog.Description size="2" style={{ color: "var(--color-text-muted)" }}>
Dune Dedicated Server Manager
</Dialog.Description>
<Flex direction="column" gap="3" mt="4">
<Flex justify="between" align="center">
<Text size="2" color="gray">
Version
</Text>
<Text size="2" className="mono">
{version ?? "—"}
</Text>
</Flex>
<Flex justify="between" align="center">
<Text size="2" color="gray">
Repository
</Text>
<Link size="2" href={REPO_URL} onClick={(e) => { e.preventDefault(); openLink(REPO_URL)(); }}>
GitHub
</Link>
</Flex>
<Flex justify="between" align="center">
<Text size="2" color="gray">
Found a bug?
</Text>
<Link size="2" href={ISSUES_URL} onClick={(e) => { e.preventDefault(); openLink(ISSUES_URL)(); }}>
Report an issue
</Link>
</Flex>
</Flex>
<Flex gap="3" justify="end" mt="5">
<Dialog.Close>
<Button variant="soft" color="gray">
Close
</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,134 @@
import { Button, Callout, Dialog, Flex, Grid, TextField } from "@radix-ui/themes";
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import { openFileDialog, type PreflightCheck } from "../../services/tauri";
import type { RemoteAttachForm } from "../../types/ui";
import ActionButton from "../ui/ActionButton";
import Field from "../ui/Field";
export type RemoteAttachDialogProps = {
open: boolean;
form: RemoteAttachForm;
running: boolean;
errorMessage?: string | null;
preflight?: PreflightCheck | null;
onOpenChange: (open: boolean) => void;
onChange: (form: RemoteAttachForm) => void;
onAttach: () => void;
};
export default function RemoteAttachDialog({
open,
form,
running,
errorMessage,
preflight,
onOpenChange,
onChange,
onAttach,
}: RemoteAttachDialogProps) {
const canAttach =
form.host.trim().length > 0 &&
form.user.trim().length > 0 &&
form.keyPath.trim().length > 0 &&
form.port > 0 &&
!running;
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Content maxWidth="540px">
<Dialog.Title>Add Remote Server</Dialog.Title>
<Dialog.Description size="2" style={{ color: "var(--color-text-muted)" }}>
Connect over SSH and detect existing Dune battlegroups. Vendor wrapper commands
always execute as <code>dune</code>; if you log in as root we drop into dune via
sudo automatically.
</Dialog.Description>
<Flex direction="column" gap="3" mt="4">
<Field label="Host or IP">
<TextField.Root
placeholder="203.0.113.10"
disabled={running}
value={form.host}
onChange={(event) => onChange({ ...form, host: event.target.value })}
/>
</Field>
<Grid columns="3fr 1fr" gap="3">
<Field label="SSH User">
<TextField.Root
placeholder="dune"
disabled={running}
value={form.user}
onChange={(event) => onChange({ ...form, user: event.target.value })}
/>
</Field>
<Field label="SSH Port">
<TextField.Root
placeholder="22"
disabled={running}
type="number"
min={1}
max={65535}
value={String(form.port)}
onChange={(event) => {
const parsed = parseInt(event.target.value, 10);
onChange({ ...form, port: isNaN(parsed) ? 22 : parsed });
}}
/>
</Field>
</Grid>
<Field label="Private Key">
<Grid columns="1fr auto" gap="2">
<TextField.Root
placeholder="Choose SSH private key"
value={form.keyPath}
disabled={running}
onChange={(event) => onChange({ ...form, keyPath: event.target.value })}
/>
<Button
type="button"
variant="surface"
disabled={running}
onClick={async () => {
const selected = await openFileDialog("Choose SSH private key");
if (selected) onChange({ ...form, keyPath: selected });
}}
>
Choose
</Button>
</Grid>
</Field>
{errorMessage ? (
<Callout.Root color="red" variant="surface">
<Callout.Icon>
<ExclamationTriangleIcon />
</Callout.Icon>
<Callout.Text style={{ whiteSpace: "pre-wrap" }}>{errorMessage}</Callout.Text>
</Callout.Root>
) : null}
{preflight && !errorMessage ? (
<Callout.Root color="green" variant="surface">
<Callout.Text>
Preflight passed: SSH ok, sudo to dune ok, dune passwordless sudo ok.
</Callout.Text>
</Callout.Root>
) : null}
</Flex>
<Flex gap="3" justify="end" mt="5">
<Dialog.Close>
<Button variant="soft" color="gray" disabled={running}>
Cancel
</Button>
</Dialog.Close>
<ActionButton
onClick={onAttach}
busy={running}
disabled={!canAttach}
tone="accent"
pendingLabel="Checking"
>
Detect and Add
</ActionButton>
</Flex>
</Dialog.Content>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,45 @@
import { AlertDialog, Box, Button, Flex } from "@radix-ui/themes";
import type { RemoteServerRecord } from "../../types/server";
import Metric from "../ui/Metric";
export type RemoveRemoteServerDialogProps = {
server: RemoteServerRecord | null;
onOpenChange: (open: boolean) => void;
onRemove: (server: RemoteServerRecord) => void;
};
export default function RemoveRemoteServerDialog({
server,
onOpenChange,
onRemove,
}: RemoveRemoteServerDialogProps) {
return (
<AlertDialog.Root open={!!server} onOpenChange={onOpenChange}>
<AlertDialog.Content maxWidth="520px">
<AlertDialog.Title>Forget Remote Server</AlertDialog.Title>
<AlertDialog.Description size="2" color="gray">
This only removes the saved server entry from this app. The remote host and Dune battlegroup will not be changed.
</AlertDialog.Description>
{server ? (
<Box className="info-card" mt="4">
<Metric label="Host" value={server.host} />
<Metric label="Battlegroup" value={server.battlegroupName || "unknown"} />
</Box>
) : null}
<Flex gap="3" justify="end" mt="5">
<AlertDialog.Cancel>
<Button variant="soft" color="gray">
Cancel
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action>
<Button color="red" onClick={() => server && onRemove(server)}>
Forget Server
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
);
}

View File

@@ -0,0 +1,101 @@
import type { ComponentPropsWithoutRef } from "react";
import Markdown from "markdown-to-jsx";
import { AlertDialog, Box, Button, Flex, Link, Text } from "@radix-ui/themes";
import { openExternal } from "../../services/tauri";
import type { Update } from "../../services/updater";
import type { UpdateStatus } from "../../types/update";
const RELEASES_URL = "https://github.com/adainrivers/dune-dedicated-server-manager/releases";
// Links inside the release notes must open in the system browser, not navigate
// the Tauri webview away from the app.
function NotesLink({ href, children }: ComponentPropsWithoutRef<"a">) {
return (
<Link
size="2"
href={href}
onClick={(e) => {
e.preventDefault();
if (href) void openExternal(href);
}}
>
{children}
</Link>
);
}
export type UpdateDialogProps = {
open: boolean;
update: Update | null;
status: UpdateStatus;
progress: string | null;
onOpenChange: (open: boolean) => void;
onInstall: () => void;
};
export default function UpdateDialog({
open,
update,
status,
progress,
onOpenChange,
onInstall,
}: UpdateDialogProps) {
const busy = status === "installing" || status === "relaunching";
return (
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
<AlertDialog.Content maxWidth="520px">
<AlertDialog.Title>Install app update?</AlertDialog.Title>
<AlertDialog.Description size="2">
{update
? `Version ${update.version} is available. The app will download the signed installer, install it, and relaunch.`
: "No update is currently selected."}
</AlertDialog.Description>
{update?.body ? (
<Box mt="3">
<Text size="2" weight="medium">
What&apos;s new
</Text>
{/* Render the release notes as markdown, bounded with scroll so a
long changelog can never push the dialog past the viewport. */}
<Box className="release-notes-md">
<Markdown options={{ forceBlock: true, overrides: { a: NotesLink } }}>
{update.body}
</Markdown>
</Box>
<Flex mt="2">
<Link
size="1"
href={RELEASES_URL}
onClick={(e) => {
e.preventDefault();
void openExternal(RELEASES_URL);
}}
>
Full release notes
</Link>
</Flex>
</Box>
) : null}
{progress ? (
<Text as="p" size="2" color="gray" mt="3" className="mono">
{progress}
</Text>
) : null}
<Flex gap="3" mt="4" justify="end">
<AlertDialog.Cancel disabled={busy}>
<Button variant="soft" color="gray" disabled={busy}>
Later
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action disabled={!update || busy}>
<Button disabled={!update || busy} onClick={onInstall}>
{busy ? "Installing..." : "Install update"}
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
);
}

View File

@@ -0,0 +1,75 @@
import { Flex } from "@radix-ui/themes";
import type { Update } from "../../services/updater";
import type { RemoteServerRecord, RemoteServerStatus } from "../../types/server";
import type { ActivePage } from "../../types/ui";
import type { UpdateStatus } from "../../types/update";
import TopNav from "./TopNav";
import UpdateHeaderControl from "./UpdateHeaderControl";
export type HeaderProps = {
activePage: ActivePage;
servers: RemoteServerRecord[];
statuses: Record<string, RemoteServerStatus>;
statusErrors: Record<string, string>;
busyMap: Record<string, string>;
onOpenServersList: () => void;
onOpenServer: (serverId: string) => void;
onAddServer: () => void;
updateStatus: UpdateStatus;
update: Update | null;
updateProgress: string | null;
onCheckUpdate: () => void;
onOpenUpdate: () => void;
};
export default function Header({
activePage,
servers,
statuses,
statusErrors,
busyMap,
onOpenServersList,
onOpenServer,
onAddServer,
updateStatus,
update,
updateProgress,
onCheckUpdate,
onOpenUpdate,
}: HeaderProps) {
return (
<Flex asChild align="center" justify="between" px="4" py="3" className="app-header">
<header>
<Flex align="center" gap="4">
<Flex align="center" gap="3">
<span className="app-glyph" aria-hidden>
D
</span>
<Flex direction="column" gap="0">
<span className="app-title">Dune Dedicated Server Manager</span>
<span className="app-title-sub">Operator console</span>
</Flex>
</Flex>
<TopNav
activePage={activePage}
servers={servers}
statuses={statuses}
statusErrors={statusErrors}
busyMap={busyMap}
onOpenServersList={onOpenServersList}
onOpenServer={onOpenServer}
onAddServer={onAddServer}
/>
</Flex>
<UpdateHeaderControl
status={updateStatus}
update={update}
progress={updateProgress}
onCheck={onCheckUpdate}
onOpenUpdate={onOpenUpdate}
/>
</header>
</Flex>
);
}

View File

@@ -0,0 +1,83 @@
import { TabNav, Tooltip } from "@radix-ui/themes";
import { PlusIcon } from "@radix-ui/react-icons";
import type { RemoteServerRecord, RemoteServerStatus } from "../../types/server";
import type { ActivePage } from "../../types/ui";
import { resolveServerStatus } from "../../utils/remote-server";
export type TopNavProps = {
activePage: ActivePage;
servers: RemoteServerRecord[];
statuses: Record<string, RemoteServerStatus>;
statusErrors: Record<string, string>;
busyMap: Record<string, string>;
onOpenServersList: () => void;
onOpenServer: (serverId: string) => void;
onAddServer: () => void;
};
export default function TopNav({
activePage,
servers,
statuses,
statusErrors,
busyMap,
onOpenServersList,
onOpenServer,
onAddServer,
}: TopNavProps) {
const serversActive = activePage.kind === "servers";
const activeServerId = activePage.kind === "server" ? activePage.serverId : null;
return (
<nav aria-label="Primary navigation" className="top-nav">
<TabNav.Root size="2" color="bronze" className="server-tab-strip">
<TabNav.Link
href="#"
active={serversActive}
aria-current={serversActive ? "page" : undefined}
onClick={(event) => {
event.preventDefault();
onOpenServersList();
}}
>
Servers ({servers.length})
</TabNav.Link>
{servers.map((server) => {
const status = statuses[server.id];
const resolved = resolveServerStatus(
statusErrors[server.id],
status,
!!busyMap[server.id],
server,
);
const isActive = activeServerId === server.id;
return (
<TabNav.Link
key={server.id}
href="#"
active={isActive}
aria-current={isActive ? "page" : undefined}
onClick={(event) => {
event.preventDefault();
onOpenServer(server.id);
}}
>
<span className="server-tab-dot" data-tone={resolved.tone} aria-hidden />
<span className="server-tab-label">{server.name}</span>
</TabNav.Link>
);
})}
<Tooltip content="Add remote server">
<button
type="button"
className="server-tab-add"
aria-label="Add remote server"
onClick={onAddServer}
>
<PlusIcon />
</button>
</Tooltip>
</TabNav.Root>
</nav>
);
}

View File

@@ -0,0 +1,36 @@
import { Badge, Button, Flex } from "@radix-ui/themes";
import type { Update } from "../../services/updater";
import type { UpdateStatus } from "../../types/update";
import { updateLabel, updateTone } from "../../utils/formatting";
import AboutDialog from "../dialogs/AboutDialog";
export type UpdateHeaderControlProps = {
status: UpdateStatus;
update: Update | null;
progress: string | null;
onCheck: () => void;
onOpenUpdate: () => void;
};
export default function UpdateHeaderControl({
status,
update,
progress,
onCheck,
onOpenUpdate,
}: UpdateHeaderControlProps) {
const busy = status === "checking" || status === "installing" || status === "relaunching";
const hasUpdate = Boolean(update);
return (
<Flex align="center" gap="2" className="header-update">
<Badge color={updateTone(status)} variant="soft">
{updateLabel(status, update, progress)}
</Badge>
<Button size="1" variant={hasUpdate ? "solid" : "surface"} disabled={busy} onClick={hasUpdate ? onOpenUpdate : onCheck}>
{busy ? "Working..." : hasUpdate ? "Install" : "Check for updates"}
</Button>
<AboutDialog />
</Flex>
);
}

View File

@@ -0,0 +1,185 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Box, Flex, Grid, Select, Text, Tooltip } from "@radix-ui/themes";
import {
ChevronLeftIcon,
ChevronRightIcon,
FilePlusIcon,
TrashIcon,
} from "@radix-ui/react-icons";
import { getLogsFolder, openLogsFolder } from "../../services/tauri";
import type { LogLevelFilter, LogRow } from "../../types/log";
export type LogWindowProps = {
rows: LogRow[];
level: LogLevelFilter;
collapsed: boolean;
scopedToServer: boolean;
canScopeToServer: boolean;
onLevelChange: (level: LogLevelFilter) => void;
onClear: () => void;
onToggleCollapsed: () => void;
onToggleScope: (next: boolean) => void;
};
export default function LogWindow({
rows,
level,
collapsed,
scopedToServer,
canScopeToServer,
onLevelChange,
onClear,
onToggleCollapsed,
onToggleScope,
}: LogWindowProps) {
const bodyRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(true);
const [logsFolder, setLogsFolder] = useState<string>("");
useLayoutEffect(() => {
const body = bodyRef.current;
if (!body) return;
if (stickToBottomRef.current) {
body.scrollTop = body.scrollHeight;
}
}, [rows]);
useEffect(() => {
void getLogsFolder()
.then(setLogsFolder)
.catch(() => undefined);
}, []);
const latestLevel = rows.length > 0 ? rows[rows.length - 1].level : "info";
if (collapsed) {
return (
<aside className="log-sidebar" data-collapsed="true">
<Tooltip content="Expand logs">
<button
type="button"
className="log-sidebar-toggle"
aria-label="Expand logs"
onClick={onToggleCollapsed}
>
<ChevronLeftIcon />
</button>
</Tooltip>
<div className="log-sidebar-rail">
<span className="log-sidebar-rail-label">LOGS</span>
<span className="log-sidebar-rail-count">{rows.length}</span>
<span className={`log-sidebar-rail-dot log-${latestLevel}`} aria-hidden />
</div>
</aside>
);
}
return (
<aside className="log-sidebar" data-collapsed="false">
<Flex direction="column" height="100%" minHeight="0" gap="2" p="3">
<Flex align="center" justify="between" gap="2" wrap="wrap">
<Tooltip content={logsFolder ? `Persisted at ${logsFolder}` : "Operation log"}>
<Box>
<Text as="div" size="2" weight="medium">
Logs
</Text>
<Text as="div" size="1" style={{ color: "var(--color-text-muted)" }}>
{rows.length} entries
</Text>
</Box>
</Tooltip>
<Tooltip content="Collapse logs">
<button
type="button"
className="log-sidebar-toggle"
aria-label="Collapse logs"
onClick={onToggleCollapsed}
>
<ChevronRightIcon />
</button>
</Tooltip>
</Flex>
<Flex align="center" gap="2" wrap="wrap">
<Select.Root size="1" value={level} onValueChange={(value) => onLevelChange(value as LogLevelFilter)}>
<Select.Trigger variant="surface" aria-label="Minimum log level" />
<Select.Content>
<Select.Item value="debug">Debug</Select.Item>
<Select.Item value="info">Info</Select.Item>
<Select.Item value="warn">Warn</Select.Item>
<Select.Item value="error">Error</Select.Item>
</Select.Content>
</Select.Root>
{canScopeToServer ? (
<Tooltip
content={
scopedToServer
? "Showing rows for the active server only. Click to show all."
: "Showing all rows. Click to scope to the active server."
}
>
<button
type="button"
className="log-scope-toggle"
data-scoped={scopedToServer ? "true" : "false"}
aria-pressed={scopedToServer}
onClick={() => onToggleScope(!scopedToServer)}
>
{scopedToServer ? "This server" : "All"}
</button>
</Tooltip>
) : null}
<div style={{ flex: 1 }} />
<Tooltip content="Open logs folder">
<button
type="button"
className="log-sidebar-icon-btn"
aria-label="Open logs folder"
onClick={() => void openLogsFolder().catch(() => undefined)}
>
<FilePlusIcon />
</button>
</Tooltip>
<Tooltip content="Clear in-memory log">
<button
type="button"
className="log-sidebar-icon-btn"
aria-label="Clear logs"
disabled={rows.length === 0}
onClick={onClear}
>
<TrashIcon />
</button>
</Tooltip>
</Flex>
<Box
className="log-body"
ref={bodyRef}
onScroll={(event) => {
const body = event.currentTarget;
const distanceFromBottom = body.scrollHeight - body.scrollTop - body.clientHeight;
stickToBottomRef.current = distanceFromBottom < 80;
}}
>
<Flex direction="column" gap="0">
{rows.map((row) => (
<Grid
key={row.id}
columns="68px 44px 1fr"
gap="2"
align="baseline"
className={`log-line log-${row.level}`}
>
<Text className="log-meta mono">{row.timestamp}</Text>
<Text className="log-meta log-level mono">{row.level}</Text>
<Text className="log-text mono">{row.message}</Text>
</Grid>
))}
</Flex>
</Box>
</Flex>
</aside>
);
}

Some files were not shown because too many files have changed in this diff Show More