Compare commits
30 Commits
redesign/d
...
4a4ae7a5d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a4ae7a5d4 | ||
|
|
930f655bf5 | ||
|
|
700dc2254d | ||
|
|
7fdca2cd4f | ||
|
|
18f978dde1 | ||
|
|
9e5e828c8d | ||
|
|
fccd5c61c5 | ||
|
|
c72a280361 | ||
|
|
a3b4b5cc7d | ||
|
|
4e184ca571 | ||
|
|
fde0926d52 | ||
|
|
4d99c9d99d | ||
|
|
b8f0ccba3c | ||
|
|
068a476f39 | ||
|
|
f706c3c47e | ||
|
|
4c9c322c29 | ||
|
|
47fa72763c | ||
|
|
b455bf9f14 | ||
|
|
4abf0ab889 | ||
|
|
cea3d66cdd | ||
|
|
1abe57ca40 | ||
|
|
a8722a7a07 | ||
|
|
180631989a | ||
|
|
23decd9b08 | ||
|
|
8b84bba165 | ||
|
|
9a5b93dd08 | ||
|
|
3545e6f5c8 | ||
|
|
1edaaf985d | ||
|
|
f2b09b281a | ||
|
|
be57d2839a |
@@ -42,3 +42,6 @@ FRONTEND_URL=http://localhost:5174
|
|||||||
|
|
||||||
# Frontend (Vite — must be prefixed with VITE_)
|
# Frontend (Vite — must be prefixed with VITE_)
|
||||||
VITE_PANEL_URL=https://panel.corrosionmgmt.com
|
VITE_PANEL_URL=https://panel.corrosionmgmt.com
|
||||||
|
|
||||||
|
# Hostnames that serve the marketing site (comma-separated); all other hosts get the panel
|
||||||
|
VITE_MARKETING_HOSTS=corrosionmgmt.com,www.corrosionmgmt.com
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build Companion Agent
|
name: Build Host Agent
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -26,19 +26,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd companion-agent
|
cd companion-agent
|
||||||
mkdir -p bin
|
mkdir -p bin
|
||||||
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-linux-amd64 ./cmd/agent
|
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-linux-amd64 ./cmd/agent
|
||||||
chmod +x bin/corrosion-companion-linux-amd64
|
chmod +x bin/corrosion-host-agent-linux-amd64
|
||||||
|
|
||||||
- name: Build Windows AMD64
|
- name: Build Windows AMD64
|
||||||
run: |
|
run: |
|
||||||
cd companion-agent
|
cd companion-agent
|
||||||
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-windows-amd64.exe ./cmd/agent
|
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-windows-amd64.exe ./cmd/agent
|
||||||
|
|
||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
cd companion-agent/bin
|
cd companion-agent/bin
|
||||||
sha256sum corrosion-companion-linux-amd64 > checksums.txt
|
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
|
||||||
sha256sum corrosion-companion-windows-amd64.exe >> checksums.txt
|
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
|
||||||
cat checksums.txt
|
cat checksums.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
RESPONSE=$(curl -s -X POST \
|
RESPONSE=$(curl -s -X POST \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Companion Agent ${VERSION}\", \"body\": \"Companion Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
|
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Corrosion Host Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
|
||||||
"${API_URL}/repos/${REPO}/releases")
|
"${API_URL}/repos/${REPO}/releases")
|
||||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
|
|
||||||
@@ -68,15 +68,15 @@ jobs:
|
|||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary @companion-agent/bin/corrosion-companion-linux-amd64 \
|
--data-binary @companion-agent/bin/corrosion-host-agent-linux-amd64 \
|
||||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-linux-amd64"
|
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-linux-amd64"
|
||||||
|
|
||||||
# Upload Windows binary
|
# Upload Windows binary
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary @companion-agent/bin/corrosion-companion-windows-amd64.exe \
|
--data-binary @companion-agent/bin/corrosion-host-agent-windows-amd64.exe \
|
||||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-windows-amd64.exe"
|
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-windows-amd64.exe"
|
||||||
|
|
||||||
# Upload checksums
|
# Upload checksums
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
@@ -89,43 +89,43 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
CDN_URL="https://cdn.corrosionmgmt.com"
|
CDN_URL="https://cdn.corrosionmgmt.com"
|
||||||
|
|
||||||
# Upload Linux binary to /companion/latest/
|
# Upload Linux binary to /host-agent/latest/
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
|
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
|
||||||
"${CDN_URL}/companion/latest/corrosion-companion-linux-amd64"
|
"${CDN_URL}/host-agent/latest/corrosion-host-agent-linux-amd64"
|
||||||
|
|
||||||
# Upload Windows binary to /companion/latest/
|
# Upload Windows binary to /host-agent/latest/
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
|
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
|
||||||
"${CDN_URL}/companion/latest/corrosion-companion-windows-amd64.exe"
|
"${CDN_URL}/host-agent/latest/corrosion-host-agent-windows-amd64.exe"
|
||||||
|
|
||||||
# Upload checksums
|
# Upload checksums
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/checksums.txt" \
|
-F "file=@companion-agent/bin/checksums.txt" \
|
||||||
"${CDN_URL}/companion/latest/checksums.txt"
|
"${CDN_URL}/host-agent/latest/checksums.txt"
|
||||||
|
|
||||||
# Also upload versioned copies
|
# Also upload versioned copies
|
||||||
VERSION=${{ steps.version.outputs.VERSION }}
|
VERSION=${{ steps.version.outputs.VERSION }}
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
|
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
|
||||||
"${CDN_URL}/companion/${VERSION}/corrosion-companion-linux-amd64"
|
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-linux-amd64"
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
|
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
|
||||||
"${CDN_URL}/companion/${VERSION}/corrosion-companion-windows-amd64.exe"
|
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-windows-amd64.exe"
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/checksums.txt" \
|
-F "file=@companion-agent/bin/checksums.txt" \
|
||||||
"${CDN_URL}/companion/${VERSION}/checksums.txt"
|
"${CDN_URL}/host-agent/${VERSION}/checksums.txt"
|
||||||
|
|
||||||
echo "CDN upload complete: ${CDN_URL}/companion/latest/"
|
echo "CDN upload complete: ${CDN_URL}/host-agent/latest/"
|
||||||
|
|
||||||
- name: Build Summary
|
- name: Build Summary
|
||||||
run: |
|
run: |
|
||||||
echo "## Companion Agent Build Complete" >> $GITHUB_STEP_SUMMARY
|
echo "## Corrosion Host Agent Build Complete" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
|
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY
|
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
120
.gitea/workflows/build-host-agent.yml
Normal file
120
.gitea/workflows/build-host-agent.yml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
name: Build Host Agent (Rust)
|
||||||
|
|
||||||
|
# Rust agent ships on its own tag namespace (agent-v*) so it never collides
|
||||||
|
# with the legacy Go pipeline (v*.*.*). Artifacts publish to the CDN /alpha/
|
||||||
|
# channel — /host-agent/latest/ stays on the Go build until cutover.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'agent-v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Override the macOS toolchain names in corrosion-host-agent/.cargo/config.toml
|
||||||
|
# (real env beats the config [env] table).
|
||||||
|
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
|
||||||
|
CC_x86_64_unknown_linux_musl: musl-gcc
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/agent-v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Verify tag matches Cargo.toml
|
||||||
|
run: |
|
||||||
|
CARGO_VERSION=$(grep '^version' corrosion-host-agent/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||||
|
if [ "${{ steps.version.outputs.VERSION }}" != "$CARGO_VERSION" ]; then
|
||||||
|
echo "Tag agent-v${{ steps.version.outputs.VERSION }} does not match Cargo.toml version $CARGO_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The Asgard runner executes jobs in a bare node:20-bullseye container
|
||||||
|
# (no Rust, no sudo, runs as root) — bootstrap the toolchain per-run,
|
||||||
|
# same pattern as actions/setup-go in the Go pipeline.
|
||||||
|
- name: Install Rust + cross toolchains
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq build-essential musl-tools gcc-mingw-w64-x86-64 curl
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
"$HOME/.cargo/bin/rustup" target add x86_64-unknown-linux-musl x86_64-pc-windows-gnu
|
||||||
|
|
||||||
|
- name: Build Linux AMD64 (static musl)
|
||||||
|
run: |
|
||||||
|
cd corrosion-host-agent
|
||||||
|
cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
mkdir -p bin
|
||||||
|
cp target/x86_64-unknown-linux-musl/release/corrosion-host-agent bin/corrosion-host-agent-linux-amd64
|
||||||
|
chmod +x bin/corrosion-host-agent-linux-amd64
|
||||||
|
|
||||||
|
- name: Build Windows AMD64 (mingw)
|
||||||
|
run: |
|
||||||
|
cd corrosion-host-agent
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu
|
||||||
|
cp target/x86_64-pc-windows-gnu/release/corrosion-host-agent.exe bin/corrosion-host-agent-windows-amd64.exe
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: |
|
||||||
|
cd corrosion-host-agent/bin
|
||||||
|
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
|
||||||
|
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
|
||||||
|
cat checksums.txt
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
API_URL="${{ github.server_url }}/api/v1"
|
||||||
|
REPO="${{ github.repository }}"
|
||||||
|
VERSION="agent-v${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -X POST \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Rust host agent release ${VERSION}\", \"draft\": false, \"prerelease\": true}" \
|
||||||
|
"${API_URL}/repos/${REPO}/releases")
|
||||||
|
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
|
|
||||||
|
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @corrosion-host-agent/bin/$f \
|
||||||
|
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Upload to CDN (alpha channel)
|
||||||
|
run: |
|
||||||
|
CDN_URL="https://cdn.corrosionmgmt.com"
|
||||||
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@corrosion-host-agent/bin/$f" \
|
||||||
|
"${CDN_URL}/host-agent/alpha/$f"
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@corrosion-host-agent/bin/$f" \
|
||||||
|
"${CDN_URL}/host-agent/${VERSION}/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "CDN upload complete: ${CDN_URL}/host-agent/alpha/"
|
||||||
|
|
||||||
|
- name: Build Summary
|
||||||
|
run: |
|
||||||
|
echo "## Corrosion Host Agent (Rust) Build Complete" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Channel:** alpha (latest/ untouched until cutover)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Linux AMD64 static musl ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Windows AMD64 mingw ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY
|
||||||
122
.gitea/workflows/ci.yml
Normal file
122
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Test gate for every push to main. The deploy story: main must be green here
|
||||||
|
# before the stack is rebuilt (deploy workflow enforces it once SSH transport
|
||||||
|
# secrets land). Jobs run in the runner's bare node:20-bullseye container —
|
||||||
|
# toolchains bootstrap per-run.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-types:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Type-check NestJS backend
|
||||||
|
run: |
|
||||||
|
cd backend-nest
|
||||||
|
npm ci --no-audit --no-fund 2>&1 | tail -2
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build frontend (vue-tsc gate + vite)
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm ci --no-audit --no-fund 2>&1 | tail -2
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
agent-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
corrosion-host-agent/target
|
||||||
|
key: cargo-${{ hashFiles('corrosion-host-agent/Cargo.lock') }}
|
||||||
|
- name: Install Rust
|
||||||
|
run: |
|
||||||
|
apt-get update -qq && apt-get install -y -qq build-essential curl
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
- name: Test agent
|
||||||
|
run: |
|
||||||
|
cd corrosion-host-agent
|
||||||
|
cargo test
|
||||||
|
- name: Upload agent binary for integration
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: agent-debug
|
||||||
|
path: corrosion-host-agent/target/debug/corrosion-host-agent
|
||||||
|
|
||||||
|
integration:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: agent-tests
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: corrosion
|
||||||
|
POSTGRES_PASSWORD: citest
|
||||||
|
POSTGRES_DB: corrosion
|
||||||
|
nats:
|
||||||
|
image: nats:2.10-alpine
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download agent binary
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: agent-debug
|
||||||
|
path: agent-bin
|
||||||
|
|
||||||
|
- name: Apply migrations to fresh DB
|
||||||
|
run: |
|
||||||
|
apt-get update -qq && apt-get install -y -qq postgresql-client
|
||||||
|
until PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -c 'SELECT 1' >/dev/null 2>&1; do sleep 1; done
|
||||||
|
for f in $(ls backend/migrations/*.sql | sort); do
|
||||||
|
echo "applying $f"
|
||||||
|
PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -v ON_ERROR_STOP=1 -q -f "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Build + boot backend
|
||||||
|
run: |
|
||||||
|
cd backend-nest
|
||||||
|
npm ci --no-audit --no-fund 2>&1 | tail -2
|
||||||
|
npm run build
|
||||||
|
DATABASE_URL=postgres://corrosion:citest@postgres:5432/corrosion \
|
||||||
|
NATS_URL=nats://nats:4222 \
|
||||||
|
JWT_SECRET=ci-secret ENCRYPTION_KEY=ci-encryption-key \
|
||||||
|
ADMIN_EMAIL=ci@corrosion.test ADMIN_PASSWORD=ci-password-123 ADMIN_USERNAME=CI \
|
||||||
|
nohup node dist/main.js > /tmp/backend.log 2>&1 &
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/auth/login -X POST -H 'Content-Type: application/json' -d '{}' || true)
|
||||||
|
[ "$code" = "400" ] && echo "backend up" && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "backend failed to come up"; cat /tmp/backend.log; exit 1
|
||||||
|
|
||||||
|
- name: Run agent↔backend contract suite
|
||||||
|
run: |
|
||||||
|
chmod +x agent-bin/corrosion-host-agent
|
||||||
|
LICENSE_ID=$(PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -t -A -c 'SELECT id FROM licenses LIMIT 1')
|
||||||
|
echo "license under test: $LICENSE_ID"
|
||||||
|
[ -n "$LICENSE_ID" ] || { echo "admin seed did not create a license"; cat /tmp/backend.log; exit 1; }
|
||||||
|
LICENSE_ID="$LICENSE_ID" \
|
||||||
|
DATABASE_URL=postgres://corrosion:citest@postgres:5432/corrosion \
|
||||||
|
NATS_URL=nats://nats:4222 \
|
||||||
|
AGENT_BIN=$PWD/agent-bin/corrosion-host-agent \
|
||||||
|
node contract-tests/agent-backend.contract.mjs
|
||||||
|
|
||||||
|
- name: Backend log on failure
|
||||||
|
if: failure()
|
||||||
|
run: cat /tmp/backend.log || true
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
name: Test Asgard Runner
|
name: Test Asgard Runner
|
||||||
on: [push]
|
# On-demand only — no reason to spin a container on every push.
|
||||||
|
on: [workflow_dispatch]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -17,8 +18,15 @@ jobs:
|
|||||||
echo "Memory: $(free -h | grep Mem | awk '{print $2}')"
|
echo "Memory: $(free -h | grep Mem | awk '{print $2}')"
|
||||||
echo "Disk: $(df -h / | tail -1 | awk '{print $4}')"
|
echo "Disk: $(df -h / | tail -1 | awk '{print $4}')"
|
||||||
echo "==========================================="
|
echo "==========================================="
|
||||||
echo "Go: $(go version)"
|
# Jobs run in a bare node:20-bullseye container: toolchains are NOT
|
||||||
echo "Rust: $(rustc --version)"
|
# preinstalled — workflows must bootstrap them (setup-go, rustup).
|
||||||
echo "Docker: $(docker --version)"
|
# Report presence honestly instead of green-lighting a missing tool.
|
||||||
|
for tool in go rustc docker node; do
|
||||||
|
if command -v "$tool" >/dev/null 2>&1; then
|
||||||
|
echo "$tool: $($tool --version 2>&1 | head -1)"
|
||||||
|
else
|
||||||
|
echo "$tool: NOT PRESENT (workflows must install per-run)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
echo "==========================================="
|
echo "==========================================="
|
||||||
echo "✅ Asgard runner is OPERATIONAL"
|
echo "✅ Asgard runner reachable — container is node:20-bullseye, bootstrap toolchains per-run"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
### **TYPE 1: THE SCOUT (Intelligence)**
|
### **TYPE 1: THE SCOUT (Intelligence)**
|
||||||
|
|
||||||
- **Model:** haiku
|
- **Model:** sonnet[1m]
|
||||||
|
|
||||||
- **Role:** Reconnaissance, Context Mapping, Log Analysis.
|
- **Role:** Reconnaissance, Context Mapping, Log Analysis.
|
||||||
|
|
||||||
|
|||||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -4,6 +4,50 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added (Host-Agent v2 Consumer + SEO Meta — 2026-06-11)
|
||||||
|
|
||||||
|
**Backend (NestJS):**
|
||||||
|
- `HostAgentConsumerService` (new) — consumes wire protocol v2: `corrosion.*.host.heartbeat` updates `companion_last_seen` + `connection_status='connected'` (auto-registers the connection row on first contact); `host.going_offline` flips offline; a 60s staleness sweep marks hosts offline after 180s of silence. Previously NOTHING persisted heartbeats — `connection_status` was set once at setup and never changed again. Tenant-validated (UUID + license existence, cached) per NATS-consumer doctrine
|
||||||
|
- `NatsBridgeService` — bridges `host_heartbeat` / `host_going_offline` events to the panel WebSocket
|
||||||
|
- Verified by contract test: real agent → production NATS → captured with the backend's own `nats` lib under the real license; subjects, schema 2, real telemetry, offline beacon all confirmed
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Per-route document titles + meta descriptions (router `afterEach`, no new deps): six marketing pages get real titles/descriptions/OG tags (previously every page was "Corrosion Management" with zero meta — invisible to search and link previews); panel views get mechanical "{View} — Corrosion" titles
|
||||||
|
|
||||||
|
**CI:**
|
||||||
|
- `test-runner.yml` — honest per-tool presence checks (was printing "OPERATIONAL" while every toolchain probe failed); on-demand trigger instead of every push
|
||||||
|
|
||||||
|
### Added (Corrosion Host Agent — Rust rewrite Phase 0 — 2026-06-11)
|
||||||
|
|
||||||
|
**New: `corrosion-host-agent/`** — Rust rewrite of the Go companion agent (which stays in-tree as the behavior reference until parity). Wire protocol v2 (COA-B, Commander-approved): instance-scoped subjects `corrosion.{license}.{instance}.*` with host-level `corrosion.{license}.host.*` — full spec in `corrosion-host-agent/PROTOCOL.md`.
|
||||||
|
|
||||||
|
- Multi-instance TOML config baked into the foundation (one agent supervises N game instances; rust/conan/soulmask/dune), env overrides for secrets, strict validation (subject-safe ids, reserved segments)
|
||||||
|
- NATS layer with the production-proven Vigilance profile: infinite reconnect w/ capped backoff, 30s ping, 8192-msg offline send buffer, `tls://` scheme support
|
||||||
|
- Host heartbeat with REAL telemetry via sysinfo (CPU/mem/disks/per-instance state) — the Go agent hardcoded disk=50000MB and cpu=0.0; this is the first true Resources data
|
||||||
|
- Connectivity prober (outbound TCP + latency, periodic jittered + on-demand) — first piece of the support-triage story
|
||||||
|
- Host command channel (`ping`/`probe`/`sysinfo`, request-reply), going-offline beacon, CancellationToken graceful shutdown
|
||||||
|
- Version embedding (semver + git hash + build ts) in `--version` and every heartbeat
|
||||||
|
- Verified live against production NATS: connected, heartbeats published, clean shutdown
|
||||||
|
- Deploy artifacts verified: 3.7MB fully-static linux-musl binary, 3.8MB windows .exe (static CRT, no VC++ redist needed)
|
||||||
|
|
||||||
|
**Next phases**: 1 = process-class adapter (spawn/RCON/SteamCMD/files for Rust/Conan/Soulmask) + NestJS v2 heartbeat consumer; 2 = Dune Docker adapter; 3 = signed self-update (release gate) + service install.
|
||||||
|
|
||||||
|
### Fixed (Site Audit — Fake Data, Resilience, Fonts — 2026-06-11)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `SetupWizardView.vue` — Replaced fake install instructions (`get.corrosionmgmt.com | sh` install script and `corrosion-agent` binary, neither of which exists) with the real host-agent download + run commands matching ServerView; multi-game copy on the completion step
|
||||||
|
- Marketing views (Landing, Pricing, HowItWorks, Roadmap, EarlyAccess) — Replaced "View live demo" CTA (no demo exists; it linked to the panel login) with an honest "Sign in" link
|
||||||
|
- `ErrorBoundary.vue` — Error state now resets on route change (previously one failed view bricked the entire SPA, including marketing pages, until manual reload); added `content` variant
|
||||||
|
- `DashboardLayout.vue` — Routed views are now wrapped in a content-scoped ErrorBoundary so the sidebar/topbar survive a view failure instead of the whole panel unmounting
|
||||||
|
- `index.html` / `styles/tokens/fonts.css` — Google Fonts moved from CSS `@import` to `<link>` tags. The bundler silently dropped the mid-bundle `@import`, so production shipped system fallback fonts (Geist/JetBrains Mono/Oxanium never loaded)
|
||||||
|
- `StatusPageView.vue` — Platform KPIs show "—" until the first successful fetch instead of fake zeros
|
||||||
|
- `LoginView.vue` — Added missing "Forgot password?" link (route + backend endpoint already existed)
|
||||||
|
|
||||||
|
**Backend (NestJS):**
|
||||||
|
- `AdminSeedService` (new, auth module) — Bootstraps a super-admin user + active license from `ADMIN_EMAIL`/`ADMIN_PASSWORD`/`ADMIN_USERNAME`/`ADMIN_LICENSE_KEY` when the users table is empty. A fresh deploy previously had a schema but no possible login. Compose already passes the env vars
|
||||||
|
|
||||||
|
**Purpose:** Findings from the full-site fake-data audit. Show real data or honest empty states — never invented values, dead URLs, or fabricated zeros.
|
||||||
|
|
||||||
### Fixed (Safe Formatting Utilities — 2026-02-15)
|
### Fixed (Safe Formatting Utilities — 2026-02-15)
|
||||||
|
|
||||||
**Frontend:**
|
**Frontend:**
|
||||||
|
|||||||
30
CLAUDE.md
30
CLAUDE.md
@@ -55,7 +55,12 @@ frontend/ # Vue 3 + TypeScript
|
|||||||
package.json
|
package.json
|
||||||
vite.config.ts # Proxies /api to :3000
|
vite.config.ts # Proxies /api to :3000
|
||||||
|
|
||||||
companion-agent/ # Go binary for bare metal servers
|
corrosion-host-agent/ # Rust host agent (ACTIVE) — multi-game ops runtime
|
||||||
|
src/ # main, config, bus (NATS), telemetry, prober, hostcmd
|
||||||
|
PROTOCOL.md # Wire protocol v2 spec (instance-scoped subjects)
|
||||||
|
agent.example.toml # Multi-instance config reference
|
||||||
|
|
||||||
|
companion-agent/ # Go binary (LEGACY — behavior reference until Rust parity)
|
||||||
cmd/agent/ # main.go entry point
|
cmd/agent/ # main.go entry point
|
||||||
internal/ # Core agent logic (nats, commands, process)
|
internal/ # Core agent logic (nats, commands, process)
|
||||||
Makefile # Build for Linux/Windows
|
Makefile # Build for Linux/Windows
|
||||||
@@ -91,14 +96,16 @@ cd backend-nest && npx tsc --noEmit # Type-check without building
|
|||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
cd frontend && npm run dev # Vite dev server (port 5174)
|
cd frontend && npm run dev # Vite dev server (port 5174)
|
||||||
cd frontend && npm run build # Production build → dist/
|
cd frontend && npm run build # vue-tsc -b && vite build (type-check included; no separate lint/type-check scripts exist)
|
||||||
cd frontend && npm run lint # ESLint
|
|
||||||
cd frontend && npm run type-check # TypeScript checking (vue-tsc)
|
|
||||||
|
|
||||||
# Companion Agent (Go)
|
# Host Agent (Rust — ACTIVE)
|
||||||
|
cd corrosion-host-agent && cargo check # Fast validation
|
||||||
|
cd corrosion-host-agent && cargo build --release --target x86_64-unknown-linux-musl # Static Linux binary
|
||||||
|
cd corrosion-host-agent && cargo xwin build --release --target x86_64-pc-windows-msvc # Windows (local)
|
||||||
|
# CI: push tag agent-vX.Y.Z (must match Cargo.toml version) → Asgard builds → CDN /host-agent/alpha/
|
||||||
|
|
||||||
|
# Companion Agent (Go — LEGACY, behavior reference until Rust parity)
|
||||||
cd companion-agent && make build # Build for current platform
|
cd companion-agent && make build # Build for current platform
|
||||||
cd companion-agent && make linux # Cross-compile for Linux
|
|
||||||
cd companion-agent && make windows # Cross-compile for Windows
|
|
||||||
|
|
||||||
# Docker (from docker/ directory — Commander ALWAYS builds with --no-cache)
|
# Docker (from docker/ directory — Commander ALWAYS builds with --no-cache)
|
||||||
docker compose build --no-cache && docker compose up -d # Full rebuild + start
|
docker compose build --no-cache && docker compose up -d # Full rebuild + start
|
||||||
@@ -374,7 +381,8 @@ Default to Sonnet. Escalate to Opus when the problem demands it, not as a comfor
|
|||||||
- Treat every change as production deployment (`corrosionmgmt.com`)
|
- Treat every change as production deployment (`corrosionmgmt.com`)
|
||||||
- Document why, not just what, in commits and CHANGELOG
|
- Document why, not just what, in commits and CHANGELOG
|
||||||
- **Always commit and push when done touching code — never ask, never wait for permission**
|
- **Always commit and push when done touching code — never ask, never wait for permission**
|
||||||
- **Tag companion agent builds when Go code in `companion-agent/` is modified** — increment from latest tag (currently v1.0.3), push tag to trigger CI build + CDN upload
|
- **Tag agent builds when agent code is modified** — Rust agent: `agent-vX.Y.Z` (must match `corrosion-host-agent/Cargo.toml`; CI publishes to CDN `/host-agent/alpha/`, while `/latest/` stays on the Go build until cutover). Legacy Go agent: `vX.Y.Z`. Tags roll FORWARD only — never reuse or re-push a tag; cut the next version
|
||||||
|
- **The Asgard CI runner executes jobs in a bare `node:20-bullseye` container** — no Rust/Go/Docker/sudo preinstalled; workflows must bootstrap toolchains per-run (setup-go, rustup via curl)
|
||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
|
|
||||||
@@ -435,3 +443,9 @@ Things I discovered about myself building a sister platform across multiple sess
|
|||||||
22. **Build-green is not render-correct — visually verify UI work before calling it done.** The entire design-system re-skin (50+ files, six green commits) rendered almost completely unstyled in the browser — white background, no surfaces, no accent — because the design tokens never loaded. `vue-tsc -b` + `vite build` passed clean the whole time; CSS that *compiles* can still apply *zero* styles. One Playwright screenshot of the login exposed it in seconds. When the deliverable is visual, a green build is necessary but not sufficient: load it in a real browser (Playwright on the dev server at :5174), screenshot it, and assert on `getComputedStyle` — don't trust compilation alone. This is Lesson 17 with teeth.
|
22. **Build-green is not render-correct — visually verify UI work before calling it done.** The entire design-system re-skin (50+ files, six green commits) rendered almost completely unstyled in the browser — white background, no surfaces, no accent — because the design tokens never loaded. `vue-tsc -b` + `vite build` passed clean the whole time; CSS that *compiles* can still apply *zero* styles. One Playwright screenshot of the login exposed it in seconds. When the deliverable is visual, a green build is necessary but not sufficient: load it in a real browser (Playwright on the dev server at :5174), screenshot it, and assert on `getComputedStyle` — don't trust compilation alone. This is Lesson 17 with teeth.
|
||||||
|
|
||||||
23. **Tailwind v4 silently drops a nested `@import` barrel placed after `@import "tailwindcss"`.** `style.css` did `@import "tailwindcss"; @import "./styles/corrosion.css";` where corrosion.css was a barrel of eight `@import` token files. Once Tailwind v4 expands the tailwindcss import in place, the barrel's inner @imports no longer precede all statements, so PostCSS drops them — emitting only an easily-ignored "@import must precede all other statements" warning. Result: every design token resolved empty and the whole panel rendered unstyled. Import token/design CSS files **directly and contiguously** in the entry stylesheet; never via a nested barrel after the Tailwind import. The build warning you wave off as "pre-existing" may be the entire feature silently failing.
|
23. **Tailwind v4 silently drops a nested `@import` barrel placed after `@import "tailwindcss"`.** `style.css` did `@import "tailwindcss"; @import "./styles/corrosion.css";` where corrosion.css was a barrel of eight `@import` token files. Once Tailwind v4 expands the tailwindcss import in place, the barrel's inner @imports no longer precede all statements, so PostCSS drops them — emitting only an easily-ignored "@import must precede all other statements" warning. Result: every design token resolved empty and the whole panel rendered unstyled. Import token/design CSS files **directly and contiguously** in the entry stylesheet; never via a nested barrel after the Tailwind import. The build warning you wave off as "pre-existing" may be the entire feature silently failing.
|
||||||
|
|
||||||
|
24. **`onModuleInit` runs before async `onModuleInit` of dependencies completes — register NATS/external subscriptions in `onApplicationBootstrap`.** `NatsService.onModuleInit` connects to NATS (async); `NatsBridgeService`/`HostAgentConsumerService` registered their subscriptions in their own `onModuleInit`, which fired while the connection was still null — so every `subscribe()` hit the `[OFFLINE]` no-op path and the WS bridge was dead-on-boot in *every* production build, silently. Nest guarantees `onApplicationBootstrap` runs only after all module init (including the awaited connect) finishes. Anything that depends on another provider's async startup belongs in bootstrap, not init. The tell: a subscription that "should be there" but the handler never fires and there's no error — trace the *startup ordering*, not the handler.
|
||||||
|
|
||||||
|
25. **Fixing a dead code path detonates the live code behind it — budget for the second bug.** The moment Lesson 24's fix made the NATS→WS bridge actually deliver events, the API crashed on the first forwarded heartbeat: `WebSocket.OPEN` was `undefined` at runtime because `esModuleInterop` is off, so `import WebSocket from 'ws'` compiled to `ws_1.default` (undefined). That crash had sat behind the dead bridge since the gateway was written — never hit because no event ever reached it. When you resurrect a path that was silently no-op, everything downstream of it is effectively *untested code running for the first time in production*. Verify the whole chain end-to-end (I watched the DB row appear, then flip offline), don't stop at "the subscription fires now." This is Lesson 10 with a fuse on it. Import-runtime gotcha worth remembering: when `esModuleInterop` is off, prefer instance constants (`client.OPEN`) over class statics (`WebSocket.OPEN`) for `ws`.
|
||||||
|
|
||||||
|
26. **A jail check at the entry point does not jail the recursive walk behind it — and my own "line-by-line" review missed it; the automated security review didn't.** The file manager's `jail()` correctly canonicalized and prefix-checked the top-level path, and I traced every escape vector through it and signed off. But `copy_recursive` then walked the directory tree with `fs::metadata` (which *follows* symlinks). A symlink planted inside the jail pointing at `/etc`, then a `copy` of its parent, would dereference it and pull external content *into* the jail to be read — a jail escape the entry check never sees, because the escape is reintroduced by a descendant during traversal. Fix: `symlink_metadata` (lstat) everywhere you recurse, and refuse/never-follow symlinks across the boundary. The transferable rule: **validate at the boundary AND at every step that re-derives a path** (recursion, `read_dir`, glob, archive extraction). And the humbling part — I was confident after reviewing the jail function; the security-review pass caught the HIGH I'd waved through. Trust adversarial verification over your own once-over on security-critical code, especially path/traversal logic.
|
||||||
|
|||||||
@@ -44,10 +44,16 @@ import { FurnaceSplitterModule } from './modules/furnacesplitter/furnacesplitter
|
|||||||
import { BetterChatModule } from './modules/betterchat/betterchat.module';
|
import { BetterChatModule } from './modules/betterchat/betterchat.module';
|
||||||
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
||||||
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
||||||
|
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||||
|
|
||||||
// Shared Services
|
// Shared Services
|
||||||
import { NatsService } from './services/nats.service';
|
import { NatsService } from './services/nats.service';
|
||||||
import { NatsBridgeService } from './services/nats-bridge.service';
|
import { NatsBridgeService } from './services/nats-bridge.service';
|
||||||
|
import { HostAgentConsumerService } from './services/host-agent-consumer.service';
|
||||||
|
import { ServerConnection } from './entities/server-connection.entity';
|
||||||
|
import { License } from './entities/license.entity';
|
||||||
|
import { AgentHost } from './entities/agent-host.entity';
|
||||||
|
import { GameInstance } from './entities/game-instance.entity';
|
||||||
import { SteamService } from './services/steam.service';
|
import { SteamService } from './services/steam.service';
|
||||||
|
|
||||||
// Gateway
|
// Gateway
|
||||||
@@ -90,6 +96,9 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
// Scheduler
|
// Scheduler
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
|
|
||||||
|
// Repositories for app-level shared services (host-agent consumer)
|
||||||
|
TypeOrmModule.forFeature([ServerConnection, License, AgentHost, GameInstance]),
|
||||||
|
|
||||||
// Feature Modules
|
// Feature Modules
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
@@ -123,6 +132,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
BetterChatModule,
|
BetterChatModule,
|
||||||
TimedExecuteModule,
|
TimedExecuteModule,
|
||||||
RaidableBasesModule,
|
RaidableBasesModule,
|
||||||
|
EarlyAccessModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global guards (order matters: auth first, then license, then permissions)
|
// Global guards (order matters: auth first, then license, then permissions)
|
||||||
@@ -132,6 +142,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
// Shared services
|
// Shared services
|
||||||
NatsService,
|
NatsService,
|
||||||
NatsBridgeService,
|
NatsBridgeService,
|
||||||
|
HostAgentConsumerService,
|
||||||
SteamService,
|
SteamService,
|
||||||
|
|
||||||
// WebSocket gateway
|
// WebSocket gateway
|
||||||
|
|||||||
74
backend-nest/src/entities/agent-host.entity.ts
Normal file
74
backend-nest/src/entities/agent-host.entity.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check, Unique } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
export interface AgentHostDisk {
|
||||||
|
mount: string;
|
||||||
|
total_mb: number;
|
||||||
|
free_mb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Corrosion host agent / one machine. Owns the machine-level facts.
|
||||||
|
*
|
||||||
|
* NOTE: distinct from the B2B `hosts` table (hosting-partner companies). This
|
||||||
|
* is `agent_hosts` — the physical/virtual box a customer runs the agent on.
|
||||||
|
*/
|
||||||
|
@Entity('agent_hosts')
|
||||||
|
@Unique(['license_id', 'hostname'])
|
||||||
|
@Check(`"status" IN ('connected', 'degraded', 'offline')`)
|
||||||
|
export class AgentHost {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, default: '' })
|
||||||
|
hostname: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
|
agent_version: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
|
agent_commit: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||||
|
os: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||||
|
arch: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'offline' })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
last_heartbeat_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'double precision', nullable: true })
|
||||||
|
cpu_percent: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: true })
|
||||||
|
cpu_cores: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
mem_total_mb: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
mem_used_mb: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
uptime_seconds: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
disks: AgentHostDisk[] | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
59
backend-nest/src/entities/game-instance.entity.ts
Normal file
59
backend-nest/src/entities/game-instance.entity.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
import { AgentHost } from './agent-host.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One game server process / orchestrated unit (a Rust server, a Conan world,
|
||||||
|
* a Dune battlegroup). The billing unit — plans count instances.
|
||||||
|
* `agent_instance_id` is the agent's slug and the NATS subject segment.
|
||||||
|
*/
|
||||||
|
@Entity('game_instances')
|
||||||
|
@Unique(['license_id', 'agent_instance_id'])
|
||||||
|
export class GameInstance {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true })
|
||||||
|
host_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true })
|
||||||
|
cluster_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64 })
|
||||||
|
agent_instance_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32 })
|
||||||
|
game: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
label: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, default: 'unknown' })
|
||||||
|
state: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
root_path: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', default: 0 })
|
||||||
|
uptime_seconds: number;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
last_seen_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
|
||||||
|
@ManyToOne(() => AgentHost, { onDelete: 'SET NULL', nullable: true })
|
||||||
|
@JoinColumn({ name: 'host_id' })
|
||||||
|
host: AgentHost | null;
|
||||||
|
}
|
||||||
38
backend-nest/src/entities/instance-cluster.entity.ts
Normal file
38
backend-nest/src/entities/instance-cluster.entity.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional grouping of instances for games with linked topologies:
|
||||||
|
* Soulmask main/child clusters, Dune BattleGroup → Sietches. Reserved now;
|
||||||
|
* cluster orchestration ships with those game adapters.
|
||||||
|
*/
|
||||||
|
@Entity('instance_clusters')
|
||||||
|
export class InstanceCluster {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32 })
|
||||||
|
game: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||||
|
topology: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
config: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
38
backend-nest/src/entities/instance-stats.entity.ts
Normal file
38
backend-nest/src/entities/instance-stats.entity.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { GameInstance } from './game-instance.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-instance time-series game metrics (player count, FPS, …). Populated once
|
||||||
|
* game-level telemetry is collected via RCON/plugin — the host heartbeat
|
||||||
|
* carries host metrics, not game metrics, so this stays empty in Phase A.
|
||||||
|
*/
|
||||||
|
@Entity('instance_stats')
|
||||||
|
export class InstanceStats {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
instance_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 0 })
|
||||||
|
player_count: number;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 0 })
|
||||||
|
max_players: number;
|
||||||
|
|
||||||
|
@Column({ type: 'double precision', default: 0 })
|
||||||
|
fps: number;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 0 })
|
||||||
|
memory_usage_mb: number;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
recorded_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => GameInstance, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'instance_id' })
|
||||||
|
instance: GameInstance;
|
||||||
|
}
|
||||||
@@ -71,7 +71,10 @@ export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconne
|
|||||||
|
|
||||||
// Subscribe to NATS events for this license
|
// Subscribe to NATS events for this license
|
||||||
const listener = (event: string, data: unknown) => {
|
const listener = (event: string, data: unknown) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
// client.OPEN (instance constant) — NOT WebSocket.OPEN: with
|
||||||
|
// esModuleInterop off, the default `ws` import is undefined at
|
||||||
|
// runtime, so the static crashes. The instance constant is safe.
|
||||||
|
if (client.readyState === client.OPEN) {
|
||||||
client.send(JSON.stringify({
|
client.send(JSON.stringify({
|
||||||
type: 'event',
|
type: 'event',
|
||||||
license_id: payload.license_id,
|
license_id: payload.license_id,
|
||||||
|
|||||||
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { User } from '../../entities/user.entity';
|
||||||
|
import { License } from '../../entities/license.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstraps the first admin account on a fresh database.
|
||||||
|
*
|
||||||
|
* A fresh deploy builds the schema via docker-entrypoint-initdb.d but contains
|
||||||
|
* zero users, so the panel has no possible login. If ADMIN_EMAIL and
|
||||||
|
* ADMIN_PASSWORD are set and the users table is empty, this creates a
|
||||||
|
* super-admin user plus an active license — the same rows the register flow
|
||||||
|
* would create. It never runs against a database that already has users.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AdminSeedService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(AdminSeedService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
@InjectRepository(User) private readonly userRepository: Repository<User>,
|
||||||
|
@InjectRepository(License) private readonly licenseRepository: Repository<License>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.seedAdminIfEmpty();
|
||||||
|
} catch (err) {
|
||||||
|
// A failed seed must not take the API down — surface it loudly and move on
|
||||||
|
this.logger.error(`Admin bootstrap failed: ${(err as Error).message}`, (err as Error).stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedAdminIfEmpty(): Promise<void> {
|
||||||
|
const email = this.config.get<string>('admin.email');
|
||||||
|
const password = this.config.get<string>('admin.password');
|
||||||
|
const username = this.config.get<string>('admin.username') || 'Commander';
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
this.logger.log('Admin bootstrap skipped: ADMIN_EMAIL / ADMIN_PASSWORD not set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCount = await this.userRepository.count();
|
||||||
|
if (userCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await argon2.hash(password);
|
||||||
|
const user = this.userRepository.create({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
email_verified: true,
|
||||||
|
is_super_admin: true,
|
||||||
|
});
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
const licenseKey = this.config.get<string>('admin.licenseKey') || this.generateLicenseKey();
|
||||||
|
const license = this.licenseRepository.create({
|
||||||
|
license_key: licenseKey,
|
||||||
|
owner_user_id: user.id,
|
||||||
|
status: 'active',
|
||||||
|
modules_enabled: [],
|
||||||
|
webstore_active: false,
|
||||||
|
});
|
||||||
|
await this.licenseRepository.save(license);
|
||||||
|
|
||||||
|
this.logger.log(`Bootstrap admin created: ${user.email} (license ${license.license_key})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateLicenseKey(): string {
|
||||||
|
const part1 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
const part2 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
const part3 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
return `CORR-${part1}-${part2}-${part3}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { AdminSeedService } from './admin-seed.service';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { User } from '../../entities/user.entity';
|
import { User } from '../../entities/user.entity';
|
||||||
import { License } from '../../entities/license.entity';
|
import { License } from '../../entities/license.entity';
|
||||||
@@ -27,7 +28,7 @@ import { TeamMember } from '../../entities/team-member.entity';
|
|||||||
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
|
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, AdminSeedService, JwtStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
|
|
||||||
const message = JSON.stringify({ event, data });
|
const message = JSON.stringify({ event, data });
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
// client.OPEN, not WebSocket.OPEN — esModuleInterop is off so the
|
||||||
|
// default `ws` import is undefined at runtime (would crash on forward).
|
||||||
|
if (client.readyState === client.OPEN) {
|
||||||
client.send(message);
|
client.send(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateEarlyAccessDto {
|
||||||
|
@ApiProperty({ example: 'admin@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'rust', description: 'Primary game interest or server count' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
server_count?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
import { EarlyAccessService } from './early-access.service';
|
||||||
|
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
|
||||||
|
|
||||||
|
@ApiTags('early-access')
|
||||||
|
@Controller()
|
||||||
|
export class EarlyAccessController {
|
||||||
|
constructor(private readonly earlyAccessService: EarlyAccessService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('early-access')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Register for early access' })
|
||||||
|
async register(@Body() dto: CreateEarlyAccessDto) {
|
||||||
|
return this.earlyAccessService.register(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend-nest/src/modules/early-access/early-access.module.ts
Normal file
12
backend-nest/src/modules/early-access/early-access.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
|
||||||
|
import { EarlyAccessController } from './early-access.controller';
|
||||||
|
import { EarlyAccessService } from './early-access.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([EarlyAccessSignup])],
|
||||||
|
controllers: [EarlyAccessController],
|
||||||
|
providers: [EarlyAccessService],
|
||||||
|
})
|
||||||
|
export class EarlyAccessModule {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
|
||||||
|
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EarlyAccessService {
|
||||||
|
private readonly logger = new Logger(EarlyAccessService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(EarlyAccessSignup)
|
||||||
|
private readonly repo: Repository<EarlyAccessSignup>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(dto: CreateEarlyAccessDto): Promise<{ success: true; alreadyRegistered: boolean }> {
|
||||||
|
const existing = await this.repo.findOne({ where: { email: dto.email } });
|
||||||
|
if (existing) {
|
||||||
|
// Duplicate email — return friendly success rather than a 409 that would break the UX
|
||||||
|
return { success: true, alreadyRegistered: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signup = this.repo.create({
|
||||||
|
email: dto.email,
|
||||||
|
server_count: dto.server_count ?? 'not specified',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.repo.save(signup);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Guard against a race-condition duplicate (unique constraint violation)
|
||||||
|
const pg = err as { code?: string };
|
||||||
|
if (pg.code === '23505') {
|
||||||
|
return { success: true, alreadyRegistered: true };
|
||||||
|
}
|
||||||
|
this.logger.error('Failed to save early-access signup', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, alreadyRegistered: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
261
backend-nest/src/services/host-agent-consumer.service.ts
Normal file
261
backend-nest/src/services/host-agent-consumer.service.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import { Interval } from '@nestjs/schedule';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { NatsService } from './nats.service';
|
||||||
|
import { ServerConnection } from '../entities/server-connection.entity';
|
||||||
|
import { License } from '../entities/license.entity';
|
||||||
|
import { AgentHost, AgentHostDisk } from '../entities/agent-host.entity';
|
||||||
|
import { GameInstance } from '../entities/game-instance.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes Corrosion wire protocol v2 host-agent subjects
|
||||||
|
* (corrosion-host-agent/PROTOCOL.md) and keeps the fleet model truthful.
|
||||||
|
*
|
||||||
|
* Writes the License → Host → Instance model (hosts + game_instances) from
|
||||||
|
* each heartbeat, AND maintains the legacy single-server `server_connections`
|
||||||
|
* row so the current panel keeps working during the fleet UI transition.
|
||||||
|
*
|
||||||
|
* Host identity: until enrollment issues a stable host id, a host is keyed by
|
||||||
|
* (license_id, hostname). One agent = one host today; the schema is already
|
||||||
|
* multi-host-ready.
|
||||||
|
*/
|
||||||
|
interface HeartbeatPayload {
|
||||||
|
schema?: number;
|
||||||
|
timestamp?: string;
|
||||||
|
agent?: { version?: string; commit?: string; os?: string; arch?: string };
|
||||||
|
host?: {
|
||||||
|
hostname?: string | null;
|
||||||
|
cpu_percent?: number;
|
||||||
|
cpu_cores?: number;
|
||||||
|
mem_total_mb?: number;
|
||||||
|
mem_used_mb?: number;
|
||||||
|
uptime_seconds?: number;
|
||||||
|
disks?: AgentHostDisk[];
|
||||||
|
};
|
||||||
|
instances?: Array<{
|
||||||
|
id: string;
|
||||||
|
game: string;
|
||||||
|
label?: string | null;
|
||||||
|
state?: string;
|
||||||
|
uptime_seconds?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HostAgentConsumerService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(HostAgentConsumerService.name);
|
||||||
|
|
||||||
|
private knownLicenses = new Map<string, number>();
|
||||||
|
private warnedUnknown = new Set<string>();
|
||||||
|
|
||||||
|
private static readonly UUID_RE =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
private static readonly LICENSE_CACHE_TTL_MS = 5 * 60_000;
|
||||||
|
private static readonly OFFLINE_AFTER_MS = 180_000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly nats: NatsService,
|
||||||
|
@InjectRepository(ServerConnection)
|
||||||
|
private readonly connectionRepository: Repository<ServerConnection>,
|
||||||
|
@InjectRepository(License)
|
||||||
|
private readonly licenseRepository: Repository<License>,
|
||||||
|
@InjectRepository(AgentHost)
|
||||||
|
private readonly hostRepository: Repository<AgentHost>,
|
||||||
|
@InjectRepository(GameInstance)
|
||||||
|
private readonly instanceRepository: Repository<GameInstance>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Bootstrap, not module-init: subscriptions registered before NatsService
|
||||||
|
// finished connecting silently no-op (see NatsBridgeService note).
|
||||||
|
onApplicationBootstrap() {
|
||||||
|
this.nats.subscribe('corrosion.*.host.heartbeat', (data, subject) => {
|
||||||
|
const licenseId = subject.split('.')[1];
|
||||||
|
void this.onHeartbeat(licenseId, data as HeartbeatPayload).catch((err) =>
|
||||||
|
this.logger.error(`heartbeat handling failed for ${licenseId}: ${err.message}`, err.stack),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.nats.subscribe('corrosion.*.host.going_offline', (_data, subject) => {
|
||||||
|
const licenseId = subject.split('.')[1];
|
||||||
|
void this.onGoingOffline(licenseId).catch((err) =>
|
||||||
|
this.logger.error(`going_offline handling failed for ${licenseId}: ${err.message}`, err.stack),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log('Host agent (protocol v2) consumer subscriptions initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onHeartbeat(licenseId: string, payload: HeartbeatPayload): Promise<void> {
|
||||||
|
if (!(await this.isValidTenant(licenseId))) return;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await this.updateLegacyConnection(licenseId, now);
|
||||||
|
const host = await this.upsertHost(licenseId, payload, now);
|
||||||
|
await this.upsertInstances(licenseId, host, payload, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy single-server row — keeps the current panel working. */
|
||||||
|
private async updateLegacyConnection(licenseId: string, now: Date): Promise<void> {
|
||||||
|
const existing = await this.connectionRepository.findOne({ where: { license_id: licenseId } });
|
||||||
|
if (existing) {
|
||||||
|
await this.connectionRepository.update(
|
||||||
|
{ id: existing.id },
|
||||||
|
{ companion_last_seen: now, connection_status: 'connected', updated_at: now },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.connectionRepository.save(
|
||||||
|
this.connectionRepository.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
connection_type: 'bare_metal',
|
||||||
|
connection_status: 'connected',
|
||||||
|
companion_last_seen: now,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert the fleet host row, keyed by (license_id, hostname). */
|
||||||
|
private async upsertHost(licenseId: string, payload: HeartbeatPayload, now: Date): Promise<AgentHost> {
|
||||||
|
const hostname = payload.host?.hostname ?? '';
|
||||||
|
const fields = {
|
||||||
|
agent_version: payload.agent?.version ?? null,
|
||||||
|
agent_commit: payload.agent?.commit ?? null,
|
||||||
|
os: payload.agent?.os ?? null,
|
||||||
|
arch: payload.agent?.arch ?? null,
|
||||||
|
status: 'connected',
|
||||||
|
last_heartbeat_at: now,
|
||||||
|
cpu_percent: payload.host?.cpu_percent ?? null,
|
||||||
|
cpu_cores: payload.host?.cpu_cores ?? null,
|
||||||
|
mem_total_mb: payload.host?.mem_total_mb ?? null,
|
||||||
|
mem_used_mb: payload.host?.mem_used_mb ?? null,
|
||||||
|
uptime_seconds: payload.host?.uptime_seconds ?? null,
|
||||||
|
disks: payload.host?.disks ?? null,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await this.hostRepository.findOne({
|
||||||
|
where: { license_id: licenseId, hostname },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await this.hostRepository.update({ id: existing.id }, fields);
|
||||||
|
return { ...existing, ...fields } as AgentHost;
|
||||||
|
}
|
||||||
|
const created = await this.hostRepository.save(
|
||||||
|
this.hostRepository.create({ license_id: licenseId, hostname, ...fields }),
|
||||||
|
);
|
||||||
|
this.logger.log(`host registered for license ${licenseId} (hostname '${hostname || 'unknown'}')`);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert one game_instances row per heartbeat instance entry. */
|
||||||
|
private async upsertInstances(
|
||||||
|
licenseId: string,
|
||||||
|
host: AgentHost,
|
||||||
|
payload: HeartbeatPayload,
|
||||||
|
now: Date,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const inst of payload.instances ?? []) {
|
||||||
|
if (!inst?.id || !inst?.game) continue;
|
||||||
|
const fields = {
|
||||||
|
host_id: host.id,
|
||||||
|
game: inst.game,
|
||||||
|
label: inst.label ?? null,
|
||||||
|
state: inst.state ?? 'unknown',
|
||||||
|
uptime_seconds: inst.uptime_seconds ?? 0,
|
||||||
|
last_seen_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
const existing = await this.instanceRepository.findOne({
|
||||||
|
where: { license_id: licenseId, agent_instance_id: inst.id },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await this.instanceRepository.update({ id: existing.id }, fields);
|
||||||
|
} else {
|
||||||
|
await this.instanceRepository.save(
|
||||||
|
this.instanceRepository.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
agent_instance_id: inst.id,
|
||||||
|
...fields,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(`instance '${inst.id}' (${inst.game}) registered for license ${licenseId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onGoingOffline(licenseId: string): Promise<void> {
|
||||||
|
if (!(await this.isValidTenant(licenseId))) return;
|
||||||
|
const now = new Date();
|
||||||
|
await this.connectionRepository.update(
|
||||||
|
{ license_id: licenseId },
|
||||||
|
{ connection_status: 'offline', updated_at: now },
|
||||||
|
);
|
||||||
|
await this.hostRepository.update(
|
||||||
|
{ license_id: licenseId },
|
||||||
|
{ status: 'offline', updated_at: now },
|
||||||
|
);
|
||||||
|
this.logger.log(`host(s) for license ${licenseId} went offline (graceful beacon)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heartbeats stopping must flip the panel to offline — an agent that
|
||||||
|
* crashes or loses network never sends the goodbye beacon. Sweeps both the
|
||||||
|
* legacy connection and fleet hosts.
|
||||||
|
*/
|
||||||
|
@Interval(60_000)
|
||||||
|
async sweepStaleConnections(): Promise<void> {
|
||||||
|
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
|
||||||
|
|
||||||
|
const conn = await this.connectionRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(ServerConnection)
|
||||||
|
.set({ connection_status: 'offline', updated_at: () => 'NOW()' })
|
||||||
|
.where('connection_status = :connected', { connected: 'connected' })
|
||||||
|
.andWhere('companion_last_seen IS NOT NULL')
|
||||||
|
.andWhere('companion_last_seen < :threshold', { threshold })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const hosts = await this.hostRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(AgentHost)
|
||||||
|
.set({ status: 'offline', updated_at: () => 'NOW()' })
|
||||||
|
.where('status = :connected', { connected: 'connected' })
|
||||||
|
.andWhere('last_heartbeat_at IS NOT NULL')
|
||||||
|
.andWhere('last_heartbeat_at < :threshold', { threshold })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const affected = (conn.affected ?? 0) + (hosts.affected ?? 0);
|
||||||
|
if (affected) {
|
||||||
|
this.logger.warn(`marked ${affected} stale connection/host record(s) offline`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant validation: the subject segment must be a real license UUID.
|
||||||
|
* NATS consumers must never write rows for subjects an arbitrary publisher
|
||||||
|
* invented. Existence is cached to avoid a query per heartbeat.
|
||||||
|
*/
|
||||||
|
private async isValidTenant(licenseId: string): Promise<boolean> {
|
||||||
|
if (!HostAgentConsumerService.UUID_RE.test(licenseId)) {
|
||||||
|
this.warnUnknownOnce(licenseId, 'not a UUID');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const cachedUntil = this.knownLicenses.get(licenseId);
|
||||||
|
if (cachedUntil && cachedUntil > Date.now()) return true;
|
||||||
|
|
||||||
|
const exists = await this.licenseRepository.exist({ where: { id: licenseId } });
|
||||||
|
if (!exists) {
|
||||||
|
this.warnUnknownOnce(licenseId, 'no such license');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.knownLicenses.set(licenseId, Date.now() + HostAgentConsumerService.LICENSE_CACHE_TTL_MS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private warnUnknownOnce(licenseId: string, reason: string): void {
|
||||||
|
if (this.warnedUnknown.has(licenseId)) return;
|
||||||
|
this.warnedUnknown.add(licenseId);
|
||||||
|
this.logger.warn(`ignoring host-agent traffic for invalid license '${licenseId}' (${reason})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { NatsService } from './nats.service';
|
export { NatsService } from './nats.service';
|
||||||
export { NatsBridgeService } from './nats-bridge.service';
|
export { NatsBridgeService } from './nats-bridge.service';
|
||||||
|
export { HostAgentConsumerService } from './host-agent-consumer.service';
|
||||||
export { SteamService } from './steam.service';
|
export { SteamService } from './steam.service';
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
import { Injectable, OnApplicationBootstrap, Logger } from '@nestjs/common';
|
||||||
import { NatsService } from './nats.service';
|
import { NatsService } from './nats.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NatsBridgeService implements OnModuleInit {
|
export class NatsBridgeService implements OnApplicationBootstrap {
|
||||||
private readonly logger = new Logger(NatsBridgeService.name);
|
private readonly logger = new Logger(NatsBridgeService.name);
|
||||||
private listeners: Map<string, Set<(event: string, data: unknown) => void>> = new Map();
|
private listeners: Map<string, Set<(event: string, data: unknown) => void>> = new Map();
|
||||||
|
|
||||||
constructor(private nats: NatsService) {}
|
constructor(private nats: NatsService) {}
|
||||||
|
|
||||||
onModuleInit() {
|
// Subscriptions MUST happen in onApplicationBootstrap, not onModuleInit:
|
||||||
|
// provider onModuleInit order is not guaranteed, and these hooks once ran
|
||||||
|
// before NatsService connected — every subscribe() silently no-oped and the
|
||||||
|
// WS bridge was dead from boot. Bootstrap runs after ALL module inits
|
||||||
|
// (including the awaited NATS connect) complete.
|
||||||
|
onApplicationBootstrap() {
|
||||||
this.nats.subscribe('corrosion.*.companion.heartbeat', (data, subject) => {
|
this.nats.subscribe('corrosion.*.companion.heartbeat', (data, subject) => {
|
||||||
const licenseId = subject.split('.')[1];
|
const licenseId = subject.split('.')[1];
|
||||||
this.emit(licenseId, 'heartbeat', data);
|
this.emit(licenseId, 'heartbeat', data);
|
||||||
@@ -44,6 +49,17 @@ export class NatsBridgeService implements OnModuleInit {
|
|||||||
this.emit(licenseId, 'oxide_status', data);
|
this.emit(licenseId, 'oxide_status', data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wire protocol v2 (corrosion-host-agent) — host-level telemetry
|
||||||
|
this.nats.subscribe('corrosion.*.host.heartbeat', (data, subject) => {
|
||||||
|
const licenseId = subject.split('.')[1];
|
||||||
|
this.emit(licenseId, 'host_heartbeat', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.nats.subscribe('corrosion.*.host.going_offline', (data, subject) => {
|
||||||
|
const licenseId = subject.split('.')[1];
|
||||||
|
this.emit(licenseId, 'host_going_offline', data);
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log('NATS bridge subscriptions initialized');
|
this.logger.log('NATS bridge subscriptions initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
102
backend/migrations/022_fleet_model.sql
Normal file
102
backend/migrations/022_fleet_model.sql
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
-- Fleet data model — License → Host → Instance (with optional Cluster)
|
||||||
|
--
|
||||||
|
-- ADDITIVE: existing server_connections / server_config / server_stats are
|
||||||
|
-- left untouched so the current single-server panel keeps working. The
|
||||||
|
-- host-agent consumer writes BOTH the legacy connection row and these fleet
|
||||||
|
-- tables during the transition; the panel migrates to the fleet tables in a
|
||||||
|
-- later phase.
|
||||||
|
--
|
||||||
|
-- Shape mirrors the host agent's wire protocol v2 heartbeat:
|
||||||
|
-- host{} block → agent_hosts
|
||||||
|
-- instances[] entries → game_instances
|
||||||
|
-- Host metrics (CPU/RAM/disk) live on the HOST, not duplicated per instance.
|
||||||
|
--
|
||||||
|
-- Named `agent_hosts` (not `hosts`) to avoid collision with the existing B2B
|
||||||
|
-- `hosts` table (hosting-partner companies) — different concept entirely.
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
-- AGENT_HOSTS — one Corrosion host agent / one machine
|
||||||
|
-----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_hosts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||||
|
-- Natural key until enrollment issues a stable host identity.
|
||||||
|
hostname VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
agent_version VARCHAR(64),
|
||||||
|
agent_commit VARCHAR(64),
|
||||||
|
os VARCHAR(32),
|
||||||
|
arch VARCHAR(32),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'offline'
|
||||||
|
CHECK (status IN ('connected', 'degraded', 'offline')),
|
||||||
|
last_heartbeat_at TIMESTAMPTZ,
|
||||||
|
cpu_percent DOUBLE PRECISION,
|
||||||
|
cpu_cores INTEGER,
|
||||||
|
mem_total_mb BIGINT,
|
||||||
|
mem_used_mb BIGINT,
|
||||||
|
uptime_seconds BIGINT,
|
||||||
|
disks JSONB, -- [{ "mount": "/", "total_mb": n, "free_mb": n }]
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (license_id, hostname)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_hosts_license ON agent_hosts(license_id);
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
-- INSTANCE CLUSTERS — optional grouping (Soulmask main/child, Dune battlegroup)
|
||||||
|
-- Reserved now; cluster logic ships with those game adapters.
|
||||||
|
-----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS instance_clusters (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||||
|
game VARCHAR(32) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
topology VARCHAR(32), -- main_client | battlegroup
|
||||||
|
config JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clusters_license ON instance_clusters(license_id);
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
-- GAME INSTANCES — one game server process / orchestrated unit.
|
||||||
|
-- The billing unit (plans count instances).
|
||||||
|
-----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS game_instances (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||||
|
host_id UUID REFERENCES agent_hosts(id) ON DELETE SET NULL,
|
||||||
|
cluster_id UUID REFERENCES instance_clusters(id) ON DELETE SET NULL,
|
||||||
|
-- The agent's instance slug; the NATS subject segment.
|
||||||
|
agent_instance_id VARCHAR(64) NOT NULL,
|
||||||
|
game VARCHAR(32) NOT NULL,
|
||||||
|
label VARCHAR(255),
|
||||||
|
-- running | stopped | starting | stopping | crashed
|
||||||
|
-- | configured | missing_root | unmanaged | unknown
|
||||||
|
state VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||||
|
root_path TEXT,
|
||||||
|
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_seen_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (license_id, agent_instance_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_instances_license ON game_instances(license_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_instances_host ON game_instances(host_id);
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
-- INSTANCE STATS — per-instance time series (game metrics).
|
||||||
|
-- Populated once game-level telemetry (player count/FPS via RCON/plugin) is
|
||||||
|
-- collected; the host heartbeat carries host metrics, not game metrics.
|
||||||
|
-----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS instance_stats (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
instance_id UUID NOT NULL REFERENCES game_instances(id) ON DELETE CASCADE,
|
||||||
|
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||||
|
player_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_players INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fps DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
memory_usage_mb INTEGER NOT NULL DEFAULT 0,
|
||||||
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_instance_stats_instance
|
||||||
|
ON instance_stats(instance_id, recorded_at DESC);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
.PHONY: all build build-linux build-windows clean test run
|
.PHONY: all build build-linux build-windows clean test run
|
||||||
|
|
||||||
# Binary names
|
# Binary names
|
||||||
BINARY_NAME=corrosion-companion
|
BINARY_NAME=corrosion-host-agent
|
||||||
BINARY_LINUX=$(BINARY_NAME)-linux-amd64
|
BINARY_LINUX=$(BINARY_NAME)-linux-amd64
|
||||||
BINARY_WINDOWS=$(BINARY_NAME)-windows-amd64.exe
|
BINARY_WINDOWS=$(BINARY_NAME)-windows-amd64.exe
|
||||||
|
|
||||||
@@ -66,10 +66,10 @@ run: build-local
|
|||||||
install-service:
|
install-service:
|
||||||
@echo "Installing systemd service..."
|
@echo "Installing systemd service..."
|
||||||
@sudo cp $(BUILD_DIR)/$(BINARY_LINUX) /usr/local/bin/$(BINARY_NAME)
|
@sudo cp $(BUILD_DIR)/$(BINARY_LINUX) /usr/local/bin/$(BINARY_NAME)
|
||||||
@sudo cp deployment/corrosion-companion.service /etc/systemd/system/
|
@sudo cp deployment/corrosion-host-agent.service /etc/systemd/system/
|
||||||
@sudo systemctl daemon-reload
|
@sudo systemctl daemon-reload
|
||||||
@sudo systemctl enable corrosion-companion
|
@sudo systemctl enable corrosion-host-agent
|
||||||
@echo "Service installed. Configure /etc/corrosion-companion/.env then start with: sudo systemctl start corrosion-companion"
|
@echo "Service installed. Configure /etc/corrosion-host-agent/.env then start with: sudo systemctl start corrosion-host-agent"
|
||||||
|
|
||||||
# Development helpers
|
# Development helpers
|
||||||
dev: build-local
|
dev: build-local
|
||||||
|
|||||||
152
contract-tests/agent-backend.contract.mjs
Normal file
152
contract-tests/agent-backend.contract.mjs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// Full-pipeline contract test: Rust host agent → NATS → NestJS consumer → Postgres.
|
||||||
|
//
|
||||||
|
// Proves the wire protocol v2 chain end to end against a REAL backend and DB:
|
||||||
|
// 1. agent heartbeat arrives with schema 2 + measured telemetry
|
||||||
|
// 2. backend auto-registers the server_connections row and marks it connected
|
||||||
|
// 3. instance command channel round-trips (start/status/stop) with push events
|
||||||
|
// 4. graceful agent shutdown publishes the offline beacon and the row flips offline
|
||||||
|
//
|
||||||
|
// Required env:
|
||||||
|
// LICENSE_ID — existing license uuid (CI: from the admin seed)
|
||||||
|
// DATABASE_URL — postgres connection string for assertions
|
||||||
|
// NATS_URL — broker both agent and backend use (default nats://localhost:4222)
|
||||||
|
// AGENT_BIN — path to the corrosion-host-agent binary
|
||||||
|
//
|
||||||
|
// Uses the backend's own node_modules (nats, pg) so the client libs under test
|
||||||
|
// are exactly what production runs.
|
||||||
|
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { writeFileSync, mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const require = createRequire(join(repoRoot, 'backend-nest', 'node_modules', 'x.js'));
|
||||||
|
const { connect, StringCodec } = require('nats');
|
||||||
|
const { Client: PgClient } = require('pg');
|
||||||
|
|
||||||
|
const LICENSE = process.env.LICENSE_ID;
|
||||||
|
const NATS_URL = process.env.NATS_URL ?? 'nats://localhost:4222';
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL;
|
||||||
|
const AGENT_BIN = process.env.AGENT_BIN ?? join(repoRoot, 'corrosion-host-agent', 'target', 'debug', 'corrosion-host-agent');
|
||||||
|
|
||||||
|
if (!LICENSE || !DATABASE_URL) {
|
||||||
|
console.error('LICENSE_ID and DATABASE_URL are required');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sc = StringCodec();
|
||||||
|
const errs = [];
|
||||||
|
const check = (cond, msg) => { if (!cond) errs.push(msg); };
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function pollDb(pg, predicate, label, timeoutMs = 30_000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
for (;;) {
|
||||||
|
const { rows } = await pg.query(
|
||||||
|
'SELECT connection_type, connection_status, companion_last_seen FROM server_connections WHERE license_id = $1',
|
||||||
|
[LICENSE],
|
||||||
|
);
|
||||||
|
if (predicate(rows)) return rows;
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
errs.push(`${label}: timeout after ${timeoutMs}ms — rows: ${JSON.stringify(rows)}`);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const pg = new PgClient({ connectionString: DATABASE_URL });
|
||||||
|
await pg.connect();
|
||||||
|
const nc = await connect({ servers: NATS_URL });
|
||||||
|
|
||||||
|
const heartbeats = [];
|
||||||
|
const statusEvents = [];
|
||||||
|
(async () => { for await (const m of nc.subscribe(`corrosion.${LICENSE}.host.heartbeat`)) heartbeats.push(JSON.parse(sc.decode(m.data))); })();
|
||||||
|
(async () => { for await (const m of nc.subscribe(`corrosion.${LICENSE}.ci-instance.status`)) statusEvents.push(JSON.parse(sc.decode(m.data))); })();
|
||||||
|
|
||||||
|
// --- spawn the real agent ---
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'cha-contract-'));
|
||||||
|
const cfgPath = join(dir, 'agent.toml');
|
||||||
|
writeFileSync(cfgPath, `
|
||||||
|
[agent]
|
||||||
|
license_id = "${LICENSE}"
|
||||||
|
nats_url = "${NATS_URL}"
|
||||||
|
heartbeat_seconds = 10
|
||||||
|
log_level = "info"
|
||||||
|
|
||||||
|
[[instance]]
|
||||||
|
id = "ci-instance"
|
||||||
|
game = "rust"
|
||||||
|
root = "/tmp"
|
||||||
|
label = "Contract CI"
|
||||||
|
executable = "/bin/sleep"
|
||||||
|
args = ["300"]
|
||||||
|
`);
|
||||||
|
const agent = spawn(AGENT_BIN, ['--config', cfgPath], { stdio: ['ignore', 'inherit', 'inherit'] });
|
||||||
|
const agentExited = new Promise((r) => agent.on('exit', r));
|
||||||
|
|
||||||
|
// --- 1. heartbeat shape + real telemetry ---
|
||||||
|
const hbDeadline = Date.now() + 20_000;
|
||||||
|
while (heartbeats.length === 0 && Date.now() < hbDeadline) await sleep(500);
|
||||||
|
check(heartbeats.length > 0, 'no heartbeat within 20s');
|
||||||
|
if (heartbeats.length) {
|
||||||
|
const hb = heartbeats[0];
|
||||||
|
check(hb.schema === 2, `schema != 2: ${hb.schema}`);
|
||||||
|
check(typeof hb.host?.cpu_percent === 'number', 'missing host.cpu_percent');
|
||||||
|
check(hb.host?.mem_total_mb > 0, 'mem_total_mb not measured');
|
||||||
|
check(Array.isArray(hb.host?.disks) && hb.host.disks.length > 0, 'no disks reported');
|
||||||
|
check(hb.instances?.[0]?.id === 'ci-instance', 'instance missing from heartbeat');
|
||||||
|
check(!!hb.agent?.version && !!hb.agent?.commit, 'agent version/commit missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. backend auto-registers + connects ---
|
||||||
|
const rows = await pollDb(pg, (r) => r.length === 1 && r[0].connection_status === 'connected', 'auto-register connected');
|
||||||
|
if (rows.length === 1) {
|
||||||
|
check(rows[0].connection_type === 'bare_metal', `connection_type: ${rows[0].connection_type}`);
|
||||||
|
check(rows[0].companion_last_seen !== null, 'companion_last_seen not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. instance command channel ---
|
||||||
|
const cmd = async (payload) =>
|
||||||
|
JSON.parse(sc.decode((await nc.request(`corrosion.${LICENSE}.ci-instance.cmd`, sc.encode(JSON.stringify(payload)), { timeout: 8000 })).data));
|
||||||
|
|
||||||
|
const st0 = await cmd({ func: 'status' });
|
||||||
|
check(st0.state?.state === 'stopped', `initial state: ${JSON.stringify(st0.state)}`);
|
||||||
|
const start = await cmd({ func: 'start' });
|
||||||
|
check(start.status === 'success', `start: ${JSON.stringify(start)}`);
|
||||||
|
await sleep(1000);
|
||||||
|
const st1 = await cmd({ func: 'status' });
|
||||||
|
check(st1.state?.state === 'running', `post-start state: ${JSON.stringify(st1.state)}`);
|
||||||
|
check((await cmd({ func: 'start' })).status === 'error', 'double start must error');
|
||||||
|
check((await cmd({ func: 'bogus' })).status === 'error', 'unknown func must error');
|
||||||
|
const stop = await cmd({ func: 'stop' });
|
||||||
|
check(stop.status === 'success', `stop: ${JSON.stringify(stop)}`);
|
||||||
|
await sleep(1000);
|
||||||
|
const seq = statusEvents.map((e) => e.event?.state);
|
||||||
|
check(seq.includes('running') && seq.includes('stopped'), `status events incomplete: ${seq.join(',')}`);
|
||||||
|
|
||||||
|
// --- 4. graceful shutdown → offline beacon → DB flips offline ---
|
||||||
|
agent.kill('SIGTERM');
|
||||||
|
await Promise.race([agentExited, sleep(8000)]);
|
||||||
|
await pollDb(pg, (r) => r.length === 1 && r[0].connection_status === 'offline', 'beacon offline', 20_000);
|
||||||
|
|
||||||
|
await nc.close();
|
||||||
|
await pg.end();
|
||||||
|
|
||||||
|
if (errs.length) {
|
||||||
|
console.error('\nCONTRACT FAIL:');
|
||||||
|
errs.forEach((e) => console.error(' -', e));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('\nCONTRACT PASS: heartbeat shape, auto-register, connected/offline lifecycle, instance command channel, push events');
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('contract test crashed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
22
corrosion-host-agent/.cargo/config.toml
Normal file
22
corrosion-host-agent/.cargo/config.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Corrosion Host Agent — cross-compilation configuration
|
||||||
|
#
|
||||||
|
# Deploy targets:
|
||||||
|
# Linux: x86_64-unknown-linux-musl (fully static — runs on any distro)
|
||||||
|
# Windows: x86_64-pc-windows-msvc (build via `cargo xwin build` on non-Windows)
|
||||||
|
#
|
||||||
|
# Prerequisites on macOS:
|
||||||
|
# brew install filosottile/musl-cross/musl-cross (x86_64-linux-musl-gcc)
|
||||||
|
# cargo install cargo-xwin (bundles MSVC CRT + lld-link)
|
||||||
|
|
||||||
|
[target.x86_64-unknown-linux-musl]
|
||||||
|
linker = "x86_64-linux-musl-gcc"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
CC_x86_64_unknown_linux_musl = "x86_64-linux-musl-gcc"
|
||||||
|
|
||||||
|
[target.x86_64-pc-windows-msvc]
|
||||||
|
linker = "lld-link"
|
||||||
|
# Statically link the MSVC CRT so the agent runs on fresh Windows installs
|
||||||
|
# without the Visual C++ Redistributable (otherwise: STATUS_DLL_NOT_FOUND on
|
||||||
|
# any machine missing VCRUNTIME140.dll — most fresh OEM images).
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static"]
|
||||||
1
corrosion-host-agent/.gitignore
vendored
Normal file
1
corrosion-host-agent/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
2420
corrosion-host-agent/Cargo.lock
generated
Normal file
2420
corrosion-host-agent/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
corrosion-host-agent/Cargo.toml
Normal file
43
corrosion-host-agent/Cargo.toml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
[package]
|
||||||
|
name = "corrosion-host-agent"
|
||||||
|
version = "2.0.0-alpha.4"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
||||||
|
license = "UNLICENSED"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "corrosion-host-agent"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
|
futures = "0.3"
|
||||||
|
async-nats = "0.37"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
sysinfo = "0.33"
|
||||||
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
rand = "0.8"
|
||||||
|
tokio-tungstenite = "0.24"
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
# Size-optimized release: single static binary living next to RAM-heavy game
|
||||||
|
# servers. Panic stays 'unwind' so a panicking task surfaces through its
|
||||||
|
# JoinHandle instead of killing the whole agent.
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
186
corrosion-host-agent/PROTOCOL.md
Normal file
186
corrosion-host-agent/PROTOCOL.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# Corrosion Wire Protocol v2
|
||||||
|
|
||||||
|
Status: **Phase 0 + Phase 1 process control implemented** (host heartbeat,
|
||||||
|
host commands, going-offline beacon, per-instance start/stop/restart/status
|
||||||
|
with push state events). RCON, SteamCMD, file ops, and game adapters are
|
||||||
|
specified but not yet implemented.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
One **host agent** per machine supervises **N game instances**. Subjects are
|
||||||
|
scoped license-first, then by addressee:
|
||||||
|
|
||||||
|
```
|
||||||
|
corrosion.{license_id}.host.* host-level (the agent itself)
|
||||||
|
corrosion.{license_id}.{instance_id}.* instance-level (one game server)
|
||||||
|
```
|
||||||
|
|
||||||
|
`instance_id` is a config-defined slug (`[a-z0-9_-]{1,64}`), validated at
|
||||||
|
agent start. `host` is a reserved segment and can never be an instance id.
|
||||||
|
Payloads are JSON. Every heartbeat carries `"schema": 2` so consumers can
|
||||||
|
distinguish v2 from the legacy Go companion protocol (which used
|
||||||
|
`corrosion.{license_id}.companion.heartbeat`, no schema field).
|
||||||
|
|
||||||
|
## Host-level subjects (Phase 0 — live)
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.host.heartbeat` (agent → backend, publish)
|
||||||
|
|
||||||
|
Published every `heartbeat_seconds` (default 60, jittered ±20%).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 2,
|
||||||
|
"timestamp": "2026-06-11T18:00:00Z",
|
||||||
|
"agent": {
|
||||||
|
"version": "2.0.0-alpha.1",
|
||||||
|
"commit": "a8722a7",
|
||||||
|
"os": "linux",
|
||||||
|
"arch": "x86_64",
|
||||||
|
"uptime_seconds": 86400
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"hostname": "asgard-01",
|
||||||
|
"cpu_percent": 12.5,
|
||||||
|
"cpu_cores": 80,
|
||||||
|
"mem_total_mb": 262144,
|
||||||
|
"mem_used_mb": 81920,
|
||||||
|
"uptime_seconds": 1209600,
|
||||||
|
"disks": [
|
||||||
|
{ "mount": "/", "total_mb": 1907729, "free_mb": 1532211 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"instances": [
|
||||||
|
{
|
||||||
|
"id": "rust-main",
|
||||||
|
"game": "rust",
|
||||||
|
"label": "Main 2x Vanilla",
|
||||||
|
"state": "configured",
|
||||||
|
"root_disk_free_mb": 1532211
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"probe": {
|
||||||
|
"timestamp": "2026-06-11T17:58:00Z",
|
||||||
|
"results": [
|
||||||
|
{ "name": "corrosion-cdn", "host": "cdn.corrosionmgmt.com", "port": 443, "ok": true, "latency_ms": 18 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All telemetry is measured, never fabricated. Fields the agent cannot measure
|
||||||
|
are omitted (`probe` before the first probe completes, `hostname` if
|
||||||
|
unavailable).
|
||||||
|
|
||||||
|
Instance `state` values — process-managed (an `executable` is configured):
|
||||||
|
`running`, `stopped`, `starting`, `stopping`, `crashed`; unmanaged
|
||||||
|
(telemetry-only): `configured` (root exists), `missing_root`. Each instance
|
||||||
|
also reports `uptime_seconds` (0 unless running).
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.host.cmd` (backend → agent, request-reply)
|
||||||
|
|
||||||
|
Request: `{ "func": "<name>" }`. Reply: `{ "status": "success" | "error", ... }`.
|
||||||
|
|
||||||
|
| func | Reply payload |
|
||||||
|
| --------- | -------------------------------------------------------- |
|
||||||
|
| `ping` | `version`, `commit`, `uptime_seconds` |
|
||||||
|
| `probe` | `report` — fresh ProbeReport (also cached for heartbeat) |
|
||||||
|
| `sysinfo` | `snapshot` — full heartbeat payload, collected on demand |
|
||||||
|
|
||||||
|
Unknown funcs return `status: "error"` with a message listing supported funcs.
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.host.going_offline` (agent → backend, publish)
|
||||||
|
|
||||||
|
Best-effort beacon (500ms budget) on graceful shutdown so the panel can flip
|
||||||
|
the host to offline immediately instead of waiting out heartbeat staleness.
|
||||||
|
Payload: `{}`.
|
||||||
|
|
||||||
|
## Instance-level subjects
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.{instance_id}.cmd` (backend → agent, request-reply) — LIVE
|
||||||
|
|
||||||
|
Lifecycle and control for one game instance.
|
||||||
|
|
||||||
|
Implemented funcs: `start`, `stop` (graceful with 30s budget, then force
|
||||||
|
kill), `restart`, `status` (returns `state` + `uptime_seconds`), and
|
||||||
|
`rcon` — `{ "func": "rcon", "command": "<console command>" }` returns
|
||||||
|
`{ "status": "success", "output": <server response> }`. Protocol per game:
|
||||||
|
WebRCON (WebSocket JSON) for rust, Source RCON (Valve TCP) for
|
||||||
|
conan/soulmask; explicit `kind` override available in the instance's
|
||||||
|
`[instance.rcon]` config. Always targets 127.0.0.1 (agent is co-located).
|
||||||
|
Errors reply `{ "status": "error", "message": ... }` — including start on an
|
||||||
|
unmanaged instance, double start, missing rcon config, and unknown funcs.
|
||||||
|
|
||||||
|
Also implemented: `steam_update` — `{ "func": "steam_update" }` runs
|
||||||
|
SteamCMD for the instance's game (app ids: rust 258550, conan 443030,
|
||||||
|
soulmask 3017310/3017300; dune rejects — Docker images, no SteamCMD),
|
||||||
|
streaming progress lines to `corrosion.{license}.{instance}.steam_status`
|
||||||
|
and replying on completion.
|
||||||
|
|
||||||
|
Planned funcs: `oxide_install` (rust), plus game-adapter-specific
|
||||||
|
commands (Dune: docker lifecycle, RabbitMQ bus commands, Coriolis reset).
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.{instance_id}.steam_status` (agent → backend, publish) — LIVE
|
||||||
|
|
||||||
|
Per-line SteamCMD stdout during a `steam_update`, so the panel can show
|
||||||
|
live update progress. Payload: `{ "timestamp", "instance_id", "line" }`.
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.{instance_id}.files.cmd` (backend → agent, request-reply) — LIVE
|
||||||
|
|
||||||
|
Jailed file manager, confined to the instance `root` (two-stage check:
|
||||||
|
lexical normalize + canonicalize, defeating `../` traversal and symlink
|
||||||
|
escape). Request `{ "op": "list|read|write|delete|rename|mkdir|mkfile|move|copy",
|
||||||
|
"path": "rel/path", "dest"?, "content"?, "name"? }`; reply
|
||||||
|
`{ "status": "success", "data": ... }` or `{ "status": "error", "message": ... }`.
|
||||||
|
`read` caps at 5 MiB. Replaces the Go agent's UNJAILED legacy files API,
|
||||||
|
which is retired and will not be ported.
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.{instance_id}.status` (agent → backend, publish) — LIVE
|
||||||
|
|
||||||
|
State-change events so the panel does not wait for the next heartbeat.
|
||||||
|
Payload: `{ "timestamp", "instance_id", "event": { "state": ..., "exit_code"? } }`.
|
||||||
|
|
||||||
|
Semantics: **keep-latest state sync**, not a lossless transition ledger —
|
||||||
|
near-instant transient states (e.g. `starting` when spawn succeeds
|
||||||
|
immediately) may coalesce into the following state. Consumers should treat
|
||||||
|
each event as "current state is now X".
|
||||||
|
|
||||||
|
Known Phase 1 limitation: the supervisor does not yet persist/adopt PIDs — if
|
||||||
|
the agent itself restarts while a game server is running, the game process
|
||||||
|
survives but reports `stopped` until restarted through the panel. PID
|
||||||
|
adoption is queued with the service-install work.
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.{instance_id}.console` (agent → backend, publish)
|
||||||
|
|
||||||
|
Live console/log lines for the panel console view.
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.{instance_id}.files.cmd` (backend → agent, request-reply)
|
||||||
|
|
||||||
|
VueFinder-style file manager ops, jailed to the instance root. Carries over
|
||||||
|
the Go agent's jailed filemanager semantics (`fm_list`, `fm_save`, ...); the
|
||||||
|
legacy UNJAILED `files.get/put/delete/list` API is retired and will not be
|
||||||
|
ported.
|
||||||
|
|
||||||
|
## Backend mapping notes (Phase 0)
|
||||||
|
|
||||||
|
- The NestJS NATS bridge subscribes `corrosion.*.host.heartbeat` and
|
||||||
|
`corrosion.*.host.going_offline`.
|
||||||
|
- Until the license→host→instance schema lands, the backend may map the host
|
||||||
|
heartbeat onto the existing single `server_connections` row per license:
|
||||||
|
`companion_last_seen` ← heartbeat arrival, `connection_status` ←
|
||||||
|
connected/offline, resources ← `host.cpu_percent` / `mem_*` / first disk.
|
||||||
|
Instance-level mapping activates with the fleet schema.
|
||||||
|
|
||||||
|
## Probing — scope honesty
|
||||||
|
|
||||||
|
The Phase 0 prober measures **outbound** reachability from the host (TCP
|
||||||
|
connect + latency). It cannot verify **inbound** port-forwarding (the thing
|
||||||
|
players hit). Inbound verification requires a backend-side reverse probe
|
||||||
|
service that attempts connections to the customer's public IP/ports on
|
||||||
|
request; that is specified as a Phase 1+ feature and will reuse this report
|
||||||
|
format with `direction: "inbound"`.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
- The agent embeds semver + git hash + build timestamp (`--version`,
|
||||||
|
heartbeat `agent` block).
|
||||||
|
- Schema changes bump `schema` and are additive where possible.
|
||||||
40
corrosion-host-agent/README.md
Normal file
40
corrosion-host-agent/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Corrosion Host Agent
|
||||||
|
|
||||||
|
Rust rewrite of the Go companion agent (`companion-agent/`, retained as the
|
||||||
|
behavior reference until parity). One agent per machine supervises every game
|
||||||
|
instance on that host — Rust, Conan Exiles, Soulmask, Dune: Awakening.
|
||||||
|
|
||||||
|
- **Wire protocol**: see [PROTOCOL.md](./PROTOCOL.md) (v2, instance-scoped subjects)
|
||||||
|
- **Config**: see [agent.example.toml](./agent.example.toml)
|
||||||
|
|
||||||
|
## Status — Phase 0
|
||||||
|
|
||||||
|
- [x] Multi-instance TOML config + env overrides (`CORROSION_LICENSE_ID`, `CORROSION_NATS_URL`, `CORROSION_NATS_TOKEN`)
|
||||||
|
- [x] NATS connection (infinite reconnect, capped backoff, 30s ping, offline send-buffering, `tls://` support)
|
||||||
|
- [x] Host heartbeat with real telemetry (sysinfo: CPU, memory, disks) — no fabricated values
|
||||||
|
- [x] Connectivity prober (outbound TCP, periodic + on-demand)
|
||||||
|
- [x] Host command channel (`ping`, `probe`, `sysinfo`)
|
||||||
|
- [x] Graceful shutdown (cancellation token, going-offline beacon, NATS flush)
|
||||||
|
- [x] Phase 1a: process supervision — per-instance start/stop/restart/status over
|
||||||
|
`{instance}.cmd` request-reply, push state events on `{instance}.status`,
|
||||||
|
crash detection with exit codes, live state in heartbeats
|
||||||
|
(integration-tested with real processes + live-NATS contract test)
|
||||||
|
- [ ] Phase 1b: RCON trait (WebRCON rust / TCP conan+soulmask), SteamCMD, jailed file manager
|
||||||
|
- [ ] Phase 2: Dune Docker adapter (compose lifecycle, RabbitMQ bus, Postgres admin)
|
||||||
|
- [ ] Phase 3: signed self-update (enforced ed25519 — release gate), service install, supervisor split
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release # native
|
||||||
|
cargo build --release --target x86_64-unknown-linux-gnu # linux deploy target
|
||||||
|
cargo build --release --target x86_64-pc-windows-msvc # windows (cargo-xwin on non-Windows)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corrosion-host-agent --config ./agent.toml # foreground
|
||||||
|
corrosion-host-agent --config ./agent.toml check # validate config only
|
||||||
|
corrosion-host-agent version # semver + git hash + build ts
|
||||||
|
```
|
||||||
66
corrosion-host-agent/agent.example.toml
Normal file
66
corrosion-host-agent/agent.example.toml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Corrosion Host Agent configuration
|
||||||
|
# Default location: /etc/corrosion/agent.toml (Linux)
|
||||||
|
# C:\ProgramData\Corrosion\agent.toml (Windows)
|
||||||
|
# Override with: corrosion-host-agent --config /path/to/agent.toml
|
||||||
|
#
|
||||||
|
# Secrets can come from the environment instead of this file:
|
||||||
|
# CORROSION_LICENSE_ID, CORROSION_NATS_URL, CORROSION_NATS_TOKEN
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
license_id = "your-license-uuid"
|
||||||
|
nats_url = "nats://nats.corrosionmgmt.com:4222"
|
||||||
|
# nats_token = "set-me-or-use-CORROSION_NATS_TOKEN"
|
||||||
|
heartbeat_seconds = 60
|
||||||
|
log_level = "info"
|
||||||
|
|
||||||
|
# One agent supervises every game instance on this host.
|
||||||
|
# Each instance gets a stable id (lowercase letters, digits, '-', '_') that
|
||||||
|
# the panel uses to address it. Changing an id orphans its panel history.
|
||||||
|
|
||||||
|
[[instance]]
|
||||||
|
id = "rust-main"
|
||||||
|
game = "rust" # rust | conan | soulmask | dune
|
||||||
|
root = "/opt/rustserver"
|
||||||
|
label = "Main 2x Vanilla"
|
||||||
|
|
||||||
|
# RCON lets the panel send console commands to the running server.
|
||||||
|
# For rust the protocol is WebRCON (WebSocket JSON); for conan/soulmask it is
|
||||||
|
# Source RCON (Valve TCP binary). `kind` is optional — it is inferred from
|
||||||
|
# the game name when absent.
|
||||||
|
#
|
||||||
|
# The [instance.rcon] sub-table MUST immediately follow the [[instance]] entry
|
||||||
|
# it belongs to (standard TOML array-of-tables scoping rule).
|
||||||
|
[instance.rcon]
|
||||||
|
port = 28016
|
||||||
|
password = "changeme"
|
||||||
|
# kind = "webrcon" # explicit override; omit to infer from game
|
||||||
|
|
||||||
|
# [[instance]]
|
||||||
|
# id = "soulmask-main"
|
||||||
|
# game = "soulmask"
|
||||||
|
# root = "/opt/soulmask/main"
|
||||||
|
# label = "Cloud Mist Forest (cluster main)"
|
||||||
|
#
|
||||||
|
# [instance.rcon]
|
||||||
|
# port = 19000
|
||||||
|
# password = "changeme"
|
||||||
|
# # kind = "source" # inferred automatically for soulmask
|
||||||
|
|
||||||
|
# SteamCMD update settings — optional sub-table for any instance.
|
||||||
|
# Absent = defaults: steamcmd binary resolved via PATH, validate = false.
|
||||||
|
#
|
||||||
|
# [instance.steamcmd]
|
||||||
|
# steamcmd_path = "/opt/steamcmd/steamcmd.sh" # omit to use PATH
|
||||||
|
# validate = true # enable file-hash check pass
|
||||||
|
#
|
||||||
|
# Dune instances do not use SteamCMD (Docker images); the steam_update func
|
||||||
|
# will return a clear error if invoked on a dune instance.
|
||||||
|
|
||||||
|
[prober]
|
||||||
|
interval_seconds = 300
|
||||||
|
|
||||||
|
# Extra outbound TCP checks beyond the built-in defaults:
|
||||||
|
# [[prober.target]]
|
||||||
|
# name = "steam-cdn"
|
||||||
|
# host = "steamcdn-a.akamaihd.net"
|
||||||
|
# port = 443
|
||||||
21
corrosion-host-agent/build.rs
Normal file
21
corrosion-host-agent/build.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let git_hash = Command::new("git")
|
||||||
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let build_ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
println!("cargo:rustc-env=CORROSION_GIT_HASH={git_hash}");
|
||||||
|
println!("cargo:rustc-env=CORROSION_BUILD_TS={build_ts}");
|
||||||
|
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||||
|
}
|
||||||
22
corrosion-host-agent/src/agent.rs
Normal file
22
corrosion-host-agent/src/agent.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//! Shared agent handle: every subsystem task holds an `Arc<Agent>`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::config::Settings;
|
||||||
|
use crate::process::ProcessSupervisor;
|
||||||
|
use crate::prober::ProbeReport;
|
||||||
|
|
||||||
|
pub struct Agent {
|
||||||
|
pub cfg: Settings,
|
||||||
|
pub nats: async_nats::Client,
|
||||||
|
pub started: Instant,
|
||||||
|
pub last_probe: RwLock<Option<ProbeReport>>,
|
||||||
|
/// One supervisor per instance (unmanaged instances included — they
|
||||||
|
/// report `unmanaged` state and reject process commands).
|
||||||
|
pub supervisors: HashMap<String, Arc<ProcessSupervisor>>,
|
||||||
|
pub shutdown: CancellationToken,
|
||||||
|
}
|
||||||
58
corrosion-host-agent/src/bus.rs
Normal file
58
corrosion-host-agent/src/bus.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//! NATS connection layer.
|
||||||
|
//!
|
||||||
|
//! Connection parameters follow the production-proven Vigilance profile:
|
||||||
|
//! infinite reconnects with capped exponential backoff, 30s pings to detect
|
||||||
|
//! zombie TCP in ~60s, and a deep client-side send queue so telemetry buffers
|
||||||
|
//! through broker outages instead of erroring.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config::Settings;
|
||||||
|
|
||||||
|
pub async fn connect(cfg: &Settings) -> Result<async_nats::Client> {
|
||||||
|
let (url, force_tls) = normalize_url(&cfg.nats_url);
|
||||||
|
|
||||||
|
let mut opts = async_nats::ConnectOptions::new()
|
||||||
|
.name("corrosion-host-agent")
|
||||||
|
.retry_on_initial_connect()
|
||||||
|
.max_reconnects(None)
|
||||||
|
.ping_interval(Duration::from_secs(30))
|
||||||
|
.client_capacity(8192)
|
||||||
|
.reconnect_delay_callback(|attempts| {
|
||||||
|
Duration::from_millis(std::cmp::min(attempts as u64 * 100, 8_000))
|
||||||
|
})
|
||||||
|
.event_callback(|event| async move {
|
||||||
|
match event {
|
||||||
|
async_nats::Event::Disconnected => tracing::warn!("nats disconnected"),
|
||||||
|
async_nats::Event::Connected => tracing::info!("nats connected"),
|
||||||
|
other => tracing::debug!("nats event: {other}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if force_tls {
|
||||||
|
opts = opts.require_tls(true);
|
||||||
|
}
|
||||||
|
if let Some(token) = &cfg.nats_token {
|
||||||
|
opts = opts.token(token.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = opts
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("connecting to NATS at {url}"))?;
|
||||||
|
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept `tls://` / `nats+tls://` URL schemes by translating to `nats://` +
|
||||||
|
/// an explicit TLS requirement.
|
||||||
|
fn normalize_url(raw: &str) -> (String, bool) {
|
||||||
|
if let Some(rest) = raw.strip_prefix("tls://") {
|
||||||
|
(format!("nats://{rest}"), true)
|
||||||
|
} else if let Some(rest) = raw.strip_prefix("nats+tls://") {
|
||||||
|
(format!("nats://{rest}"), true)
|
||||||
|
} else {
|
||||||
|
(raw.to_string(), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
220
corrosion-host-agent/src/config.rs
Normal file
220
corrosion-host-agent/src/config.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
//! Agent configuration: TOML file + environment overrides.
|
||||||
|
//!
|
||||||
|
//! Multi-instance is foundational, not bolted on: one agent supervises N game
|
||||||
|
//! instances on the host, each declared as an `[[instance]]` block. Connection
|
||||||
|
//! secrets may come from env so the config file can be world-readable-ish
|
||||||
|
//! while the token is not.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::rcon::RconConfig;
|
||||||
|
use crate::steamcmd::SteamcmdConfig;
|
||||||
|
|
||||||
|
/// Instance ids share the NATS subject namespace with host-level segments.
|
||||||
|
const RESERVED_INSTANCE_IDS: &[&str] = &["host", "cmd", "files", "update", "agent"];
|
||||||
|
|
||||||
|
pub const SUPPORTED_GAMES: &[&str] = &["rust", "conan", "soulmask", "dune"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ConfigFile {
|
||||||
|
pub agent: AgentSection,
|
||||||
|
#[serde(default, rename = "instance")]
|
||||||
|
pub instances: Vec<InstanceConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub prober: ProberSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AgentSection {
|
||||||
|
pub license_id: Option<String>,
|
||||||
|
pub nats_url: Option<String>,
|
||||||
|
pub nats_token: Option<String>,
|
||||||
|
#[serde(default = "default_heartbeat_seconds")]
|
||||||
|
pub heartbeat_seconds: u64,
|
||||||
|
#[serde(default = "default_log_level")]
|
||||||
|
pub log_level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct InstanceConfig {
|
||||||
|
/// Short slug, unique per license: becomes a NATS subject segment.
|
||||||
|
pub id: String,
|
||||||
|
/// One of SUPPORTED_GAMES.
|
||||||
|
pub game: String,
|
||||||
|
/// Install root for this instance on the host.
|
||||||
|
pub root: PathBuf,
|
||||||
|
/// Optional human label shown in the panel.
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
/// Game server executable. Relative paths resolve against `root`.
|
||||||
|
/// Absent = unmanaged instance (telemetry only, no process control).
|
||||||
|
#[serde(default)]
|
||||||
|
pub executable: Option<PathBuf>,
|
||||||
|
/// Arguments as a proper list — no shell splitting, quoted values survive.
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Working directory for the process. Defaults to the executable's directory.
|
||||||
|
#[serde(default)]
|
||||||
|
pub working_dir: Option<PathBuf>,
|
||||||
|
/// RCON connection settings for this instance. Absent = rcon unavailable.
|
||||||
|
/// Protocol defaults to WebRcon for rust, Source for conan/soulmask.
|
||||||
|
#[serde(default)]
|
||||||
|
pub rcon: Option<RconConfig>,
|
||||||
|
/// SteamCMD update settings. Absent = defaults apply (steamcmd on PATH,
|
||||||
|
/// validate = false).
|
||||||
|
#[serde(default)]
|
||||||
|
pub steamcmd: Option<SteamcmdConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InstanceConfig {
|
||||||
|
/// Absolute executable path, if this instance is process-managed.
|
||||||
|
pub fn resolved_executable(&self) -> Option<PathBuf> {
|
||||||
|
self.executable.as_ref().map(|exe| {
|
||||||
|
if exe.is_absolute() {
|
||||||
|
exe.clone()
|
||||||
|
} else {
|
||||||
|
self.root.join(exe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ProberSection {
|
||||||
|
#[serde(default = "default_probe_interval")]
|
||||||
|
pub interval_seconds: u64,
|
||||||
|
/// Extra TCP targets beyond the built-in defaults.
|
||||||
|
#[serde(default, rename = "target")]
|
||||||
|
pub targets: Vec<ProbeTargetConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ProbeTargetConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_heartbeat_seconds() -> u64 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_probe_interval() -> u64 {
|
||||||
|
300
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_log_level() -> String {
|
||||||
|
"info".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fully-resolved settings after merging file + env. Everything required is
|
||||||
|
/// present and validated.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub license_id: String,
|
||||||
|
pub nats_url: String,
|
||||||
|
pub nats_token: Option<String>,
|
||||||
|
pub heartbeat_seconds: u64,
|
||||||
|
pub log_level: String,
|
||||||
|
pub instances: Vec<InstanceConfig>,
|
||||||
|
pub probe_interval_seconds: u64,
|
||||||
|
pub probe_targets: Vec<ProbeTargetConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_config_path() -> PathBuf {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
PathBuf::from(r"C:\ProgramData\Corrosion\agent.toml")
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
PathBuf::from("/etc/corrosion/agent.toml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> Result<Settings> {
|
||||||
|
let raw = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("reading config file {}", path.display()))?;
|
||||||
|
let file: ConfigFile = toml::from_str(&raw)
|
||||||
|
.with_context(|| format!("parsing config file {}", path.display()))?;
|
||||||
|
resolve(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge env overrides (env wins) and validate.
|
||||||
|
fn resolve(file: ConfigFile) -> Result<Settings> {
|
||||||
|
let license_id = std::env::var("CORROSION_LICENSE_ID")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.or(file.agent.license_id)
|
||||||
|
.context("license_id missing: set [agent].license_id or CORROSION_LICENSE_ID")?;
|
||||||
|
|
||||||
|
let nats_url = std::env::var("CORROSION_NATS_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.or(file.agent.nats_url)
|
||||||
|
.context("nats_url missing: set [agent].nats_url or CORROSION_NATS_URL")?;
|
||||||
|
|
||||||
|
let nats_token = std::env::var("CORROSION_NATS_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.or(file.agent.nats_token);
|
||||||
|
|
||||||
|
validate_subject_segment("license_id", &license_id)?;
|
||||||
|
|
||||||
|
let mut seen: HashSet<&str> = HashSet::new();
|
||||||
|
for inst in &file.instances {
|
||||||
|
validate_subject_segment("instance id", &inst.id)?;
|
||||||
|
if RESERVED_INSTANCE_IDS.contains(&inst.id.as_str()) {
|
||||||
|
bail!("instance id '{}' is reserved", inst.id);
|
||||||
|
}
|
||||||
|
if !seen.insert(inst.id.as_str()) {
|
||||||
|
bail!("duplicate instance id '{}'", inst.id);
|
||||||
|
}
|
||||||
|
if !SUPPORTED_GAMES.contains(&inst.game.as_str()) {
|
||||||
|
bail!(
|
||||||
|
"instance '{}': unsupported game '{}' (supported: {})",
|
||||||
|
inst.id,
|
||||||
|
inst.game,
|
||||||
|
SUPPORTED_GAMES.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.agent.heartbeat_seconds < 10 {
|
||||||
|
bail!("[agent].heartbeat_seconds must be >= 10");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Settings {
|
||||||
|
license_id,
|
||||||
|
nats_url,
|
||||||
|
nats_token,
|
||||||
|
heartbeat_seconds: file.agent.heartbeat_seconds,
|
||||||
|
log_level: file.agent.log_level,
|
||||||
|
instances: file.instances,
|
||||||
|
probe_interval_seconds: file.prober.interval_seconds.max(30),
|
||||||
|
probe_targets: file.prober.targets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NATS subject segments must not contain '.', '*', '>', whitespace, etc.
|
||||||
|
/// Keep it strict: lowercase alphanumerics plus '-' and '_', max 64 chars.
|
||||||
|
fn validate_subject_segment(what: &str, value: &str) -> Result<()> {
|
||||||
|
if value.is_empty() || value.len() > 64 {
|
||||||
|
bail!("{what} '{value}' must be 1-64 characters");
|
||||||
|
}
|
||||||
|
if !value
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||||
|
{
|
||||||
|
bail!("{what} '{value}' may only contain lowercase letters, digits, '-' and '_'");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
544
corrosion-host-agent/src/filemanager.rs
Normal file
544
corrosion-host-agent/src/filemanager.rs
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
//! Jailed file manager for game-server install directories.
|
||||||
|
//!
|
||||||
|
//! Every path operation is confined to the instance `root` — the directory
|
||||||
|
//! declared as `root` in `[[instance]]` config. A two-stage check (lexical
|
||||||
|
//! Clean + `std::fs::canonicalize`) prevents both `../..` traversals and
|
||||||
|
//! symlink-based escapes: even if an attacker plants a symlink inside the root
|
||||||
|
//! that points outside it, `canonicalize` resolves the target and the prefix
|
||||||
|
//! check catches the escape.
|
||||||
|
//!
|
||||||
|
//! The NATS request/reply contract mirrors the Go companion agent's jailed file
|
||||||
|
//! manager (see `companion-agent/internal/filemanager/`) but uses a simpler
|
||||||
|
//! flat JSON envelope rather than the VueFinder storage-path protocol — the
|
||||||
|
//! Rust agent is the replacement, and the panel's backend talks to whichever
|
||||||
|
//! agent is present.
|
||||||
|
//!
|
||||||
|
//! Subject: `corrosion.{license}.{instance}.files.cmd`
|
||||||
|
//! Request: `{"op":"list"|"read"|"write"|"delete"|"rename"|"mkdir"|"mkfile"|"move"|"copy",
|
||||||
|
//! "path":"rel/path", "dest"?:"...", "content"?:"...", "name"?:"..."}`
|
||||||
|
//! Response: `{"status":"success","data":...}` or `{"status":"error","message":"..."}`
|
||||||
|
|
||||||
|
use anyhow::{bail, Context};
|
||||||
|
use chrono::{DateTime, SecondsFormat, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Maximum size for a `read` operation (5 MiB). Larger files must be
|
||||||
|
/// transferred through a dedicated download endpoint, not the file manager.
|
||||||
|
const MAX_READ_SIZE: u64 = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wire types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FileRequest {
|
||||||
|
pub op: String,
|
||||||
|
/// Relative path within the instance root (the "subject" of the operation).
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: String,
|
||||||
|
/// Destination for `rename`, `move`, `copy` — relative to instance root.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dest: Option<String>,
|
||||||
|
/// Text content for `write`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub content: Option<String>,
|
||||||
|
/// Bare filename for `mkdir` and `mkfile`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single directory entry returned by `list`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FileEntry {
|
||||||
|
pub name: String,
|
||||||
|
/// Path relative to the instance root, using forward slashes.
|
||||||
|
pub path: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
/// File size in bytes. Zero for directories.
|
||||||
|
pub size: u64,
|
||||||
|
/// RFC 3339 modification timestamp.
|
||||||
|
pub modified: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Jail helper — the security core of this module
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Resolve `rel` against `root`, then canonicalize to reject any form of
|
||||||
|
/// escape including `../..` traversals and symlinks that point outside root.
|
||||||
|
///
|
||||||
|
/// For paths that do not yet exist (e.g. write targets), we canonicalize the
|
||||||
|
/// nearest existing ancestor and then re-join the remaining components, which
|
||||||
|
/// are lexically-clean because they went through `std::path::Path` building.
|
||||||
|
///
|
||||||
|
/// Returns the absolute, canonicalized path if it is within `root`.
|
||||||
|
pub fn jail(root: &Path, rel: &str) -> anyhow::Result<PathBuf> {
|
||||||
|
// Canonicalize root once to get a stable prefix for comparison.
|
||||||
|
// We do this on every call rather than caching so the function stays
|
||||||
|
// pure and testable without Agent state.
|
||||||
|
let canon_root = fs::canonicalize(root)
|
||||||
|
.with_context(|| format!("canonicalize instance root '{}'", root.display()))?;
|
||||||
|
|
||||||
|
// Build the candidate absolute path. We use Path joining so that an
|
||||||
|
// absolute `rel` (e.g. "/etc/passwd") replaces the root entirely — we
|
||||||
|
// detect and reject that case immediately.
|
||||||
|
let candidate = if rel.is_empty() || rel == "." {
|
||||||
|
root.to_path_buf()
|
||||||
|
} else {
|
||||||
|
let rel_path = Path::new(rel);
|
||||||
|
if rel_path.is_absolute() {
|
||||||
|
bail!(
|
||||||
|
"absolute path '{}' is not allowed; supply a path relative to the instance root",
|
||||||
|
rel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
root.join(rel_path)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalize lexically first (removes `..` / `.` without filesystem access).
|
||||||
|
// This is a defence-in-depth step; the authoritative check is below.
|
||||||
|
let lexical = normalize_lexical(&candidate);
|
||||||
|
|
||||||
|
// Canonicalize: resolve symlinks and `..` via the kernel.
|
||||||
|
// For a not-yet-existing path we walk up to the nearest existing ancestor.
|
||||||
|
let canon = canonicalize_lenient(&lexical)?;
|
||||||
|
|
||||||
|
// Authoritative prefix check: the resolved path must be equal to or a
|
||||||
|
// child of the canonicalized root.
|
||||||
|
if canon != canon_root && !canon.starts_with(&canon_root) {
|
||||||
|
bail!(
|
||||||
|
"path '{}' resolves to '{}' which is outside the instance root '{}'",
|
||||||
|
rel,
|
||||||
|
canon.display(),
|
||||||
|
canon_root.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(canon)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonicalize a path that may not fully exist yet by walking up to the
|
||||||
|
/// nearest existing ancestor, canonicalizing it, then re-joining the remaining
|
||||||
|
/// (lexically-clean) suffix.
|
||||||
|
fn canonicalize_lenient(path: &Path) -> anyhow::Result<PathBuf> {
|
||||||
|
// Fast path: path already exists.
|
||||||
|
if let Ok(c) = fs::canonicalize(path) {
|
||||||
|
return Ok(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk up until we find an ancestor that exists.
|
||||||
|
let mut existing = path.to_path_buf();
|
||||||
|
let mut suffix: Vec<std::ffi::OsString> = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match fs::canonicalize(&existing) {
|
||||||
|
Ok(canon) => {
|
||||||
|
// Re-attach the non-existing suffix.
|
||||||
|
let mut result = canon;
|
||||||
|
for component in suffix.iter().rev() {
|
||||||
|
result = result.join(component);
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let file_name = match existing.file_name() {
|
||||||
|
Some(n) => n.to_os_string(),
|
||||||
|
None => bail!("cannot resolve path '{}'", path.display()),
|
||||||
|
};
|
||||||
|
suffix.push(file_name);
|
||||||
|
existing = match existing.parent() {
|
||||||
|
Some(p) => p.to_path_buf(),
|
||||||
|
None => bail!("cannot resolve path '{}'", path.display()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lexically normalize a path (remove `.` and `..` components) without
|
||||||
|
/// touching the filesystem. This mirrors `filepath.Clean` in Go.
|
||||||
|
fn normalize_lexical(path: &Path) -> PathBuf {
|
||||||
|
let mut components: Vec<std::path::Component> = Vec::new();
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
std::path::Component::CurDir => {}
|
||||||
|
std::path::Component::ParentDir => {
|
||||||
|
// Only pop a normal component — we cannot pop a root prefix.
|
||||||
|
if matches!(components.last(), Some(std::path::Component::Normal(_))) {
|
||||||
|
components.pop();
|
||||||
|
} else {
|
||||||
|
components.push(component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => components.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
components.iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// List the contents of a directory. Returns an entry per item, sorted
|
||||||
|
/// (directories first, then files, both alphabetical).
|
||||||
|
pub fn list(root: &Path, rel: &str) -> anyhow::Result<Vec<FileEntry>> {
|
||||||
|
let abs = jail(root, rel)?;
|
||||||
|
// Use the canonicalized root as the prefix for relative path computation so
|
||||||
|
// that symlinked root paths (e.g. macOS /var → /private/var) don't cause
|
||||||
|
// strip_prefix to fail and fall back to leaking the absolute path.
|
||||||
|
let canon_root = fs::canonicalize(root)
|
||||||
|
.with_context(|| format!("canonicalize root '{}'", root.display()))?;
|
||||||
|
|
||||||
|
let rd = fs::read_dir(&abs)
|
||||||
|
.with_context(|| format!("read_dir '{}'", abs.display()))?;
|
||||||
|
|
||||||
|
let mut entries: Vec<FileEntry> = Vec::new();
|
||||||
|
for item in rd {
|
||||||
|
let item = item.with_context(|| format!("reading directory entry in '{}'", abs.display()))?;
|
||||||
|
// symlink_metadata (lstat): report the link itself, never the target —
|
||||||
|
// following it would leak the size/type/existence of files outside the
|
||||||
|
// jail. A symlink lists as a zero-ish-size non-dir entry.
|
||||||
|
let meta = fs::symlink_metadata(item.path())
|
||||||
|
.with_context(|| format!("stat '{}'", item.path().display()))?;
|
||||||
|
|
||||||
|
let name = item.file_name().to_string_lossy().into_owned();
|
||||||
|
let is_dir = meta.is_dir();
|
||||||
|
let size = if is_dir { 0 } else { meta.len() };
|
||||||
|
|
||||||
|
// Build the relative path from the canonicalized root.
|
||||||
|
let entry_abs = item.path();
|
||||||
|
let entry_rel = entry_abs
|
||||||
|
.strip_prefix(&canon_root)
|
||||||
|
.unwrap_or(&entry_abs)
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/");
|
||||||
|
|
||||||
|
let modified = meta
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.map(|t| {
|
||||||
|
let dt: DateTime<Utc> = t.into();
|
||||||
|
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
entries.push(FileEntry { name, path: entry_rel, is_dir, size, modified });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable sort: dirs first, then alphabetical within each group.
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a text file. Capped at `MAX_READ_SIZE` bytes.
|
||||||
|
pub fn read(root: &Path, rel: &str) -> anyhow::Result<String> {
|
||||||
|
let abs = jail(root, rel)?;
|
||||||
|
|
||||||
|
let meta = fs::metadata(&abs)
|
||||||
|
.with_context(|| format!("stat '{}'", abs.display()))?;
|
||||||
|
|
||||||
|
if meta.is_dir() {
|
||||||
|
bail!("'{}' is a directory, not a file", rel);
|
||||||
|
}
|
||||||
|
if meta.len() > MAX_READ_SIZE {
|
||||||
|
bail!(
|
||||||
|
"file '{}' is {} bytes which exceeds the {} byte read limit",
|
||||||
|
rel,
|
||||||
|
meta.len(),
|
||||||
|
MAX_READ_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::read_to_string(&abs).with_context(|| format!("read '{}'", abs.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write (create or overwrite) a file. Parent directories are created as
|
||||||
|
/// needed.
|
||||||
|
pub fn write(root: &Path, rel: &str, content: &str) -> anyhow::Result<()> {
|
||||||
|
let abs = jail(root, rel)?;
|
||||||
|
|
||||||
|
if let Some(parent) = abs.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(&abs, content.as_bytes())
|
||||||
|
.with_context(|| format!("write '{}'", abs.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a file or directory tree.
|
||||||
|
pub fn delete(root: &Path, rel: &str) -> anyhow::Result<()> {
|
||||||
|
let abs = jail(root, rel)?;
|
||||||
|
|
||||||
|
let meta = fs::metadata(&abs)
|
||||||
|
.with_context(|| format!("stat '{}'", abs.display()))?;
|
||||||
|
|
||||||
|
if meta.is_dir() {
|
||||||
|
fs::remove_dir_all(&abs).with_context(|| format!("remove_dir_all '{}'", abs.display()))
|
||||||
|
} else {
|
||||||
|
fs::remove_file(&abs).with_context(|| format!("remove_file '{}'", abs.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename/move `rel` to a new bare name (`new_name`) within the same parent.
|
||||||
|
/// `new_name` must not contain path separators.
|
||||||
|
pub fn rename(root: &Path, rel: &str, new_name: &str) -> anyhow::Result<()> {
|
||||||
|
if new_name.is_empty() || new_name == "." || new_name == ".." {
|
||||||
|
bail!("new_name '{}' is not a valid filename", new_name);
|
||||||
|
}
|
||||||
|
if new_name.contains('/') || new_name.contains('\\') {
|
||||||
|
bail!("new_name '{}' must not contain path separators", new_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_abs = jail(root, rel)?;
|
||||||
|
|
||||||
|
// Construct the destination relative path by replacing the filename part
|
||||||
|
// of `rel` with `new_name`. This keeps everything in relative-path space
|
||||||
|
// so we never hand an absolute path to `jail`.
|
||||||
|
let src_rel = Path::new(rel);
|
||||||
|
let dest_rel = match src_rel.parent() {
|
||||||
|
Some(parent) if parent != Path::new("") => {
|
||||||
|
parent.join(new_name).to_string_lossy().replace('\\', "/")
|
||||||
|
}
|
||||||
|
_ => new_name.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let dest_abs = jail(root, &dest_rel)?;
|
||||||
|
|
||||||
|
fs::rename(&src_abs, &dest_abs)
|
||||||
|
.with_context(|| format!("rename '{}' -> '{}'", src_abs.display(), dest_abs.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a directory (and any missing parents) at `rel`.
|
||||||
|
pub fn mkdir(root: &Path, rel: &str) -> anyhow::Result<()> {
|
||||||
|
let abs = jail(root, rel)?;
|
||||||
|
fs::create_dir_all(&abs).with_context(|| format!("mkdir '{}'", abs.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an empty file at `rel`. Fails if it already exists.
|
||||||
|
pub fn mkfile(root: &Path, rel: &str) -> anyhow::Result<()> {
|
||||||
|
let abs = jail(root, rel)?;
|
||||||
|
|
||||||
|
if let Some(parent) = abs.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&abs)
|
||||||
|
.with_context(|| format!("mkfile '{}'", abs.display()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move `src` to `dest` (both relative to root).
|
||||||
|
pub fn move_path(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> {
|
||||||
|
let src_abs = jail(root, src)?;
|
||||||
|
let dest_abs = jail(root, dest)?;
|
||||||
|
|
||||||
|
if let Some(parent) = dest_abs.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(&src_abs, &dest_abs).or_else(|_| {
|
||||||
|
// Cross-device move: copy then delete.
|
||||||
|
copy_recursive(&src_abs, &dest_abs)?;
|
||||||
|
fs::remove_dir_all(&src_abs)
|
||||||
|
.with_context(|| format!("remove source '{}' after cross-device move", src_abs.display()))
|
||||||
|
}).with_context(|| format!("move '{}' -> '{}'", src_abs.display(), dest_abs.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `src` to `dest` (both relative to root).
|
||||||
|
pub fn copy(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> {
|
||||||
|
let src_abs = jail(root, src)?;
|
||||||
|
let dest_abs = jail(root, dest)?;
|
||||||
|
|
||||||
|
if let Some(parent) = dest_abs.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_recursive(&src_abs, &dest_abs)
|
||||||
|
.with_context(|| format!("copy '{}' -> '{}'", src_abs.display(), dest_abs.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursive copy helper.
|
||||||
|
///
|
||||||
|
/// SECURITY: uses `symlink_metadata` (does NOT follow symlinks) and refuses to
|
||||||
|
/// copy any symlink. `jail()` only validates the top-level src/dest; a symlink
|
||||||
|
/// *inside* a copied directory that points outside the jail would, if followed,
|
||||||
|
/// pull external content (e.g. `/etc`) into the jail where it could then be
|
||||||
|
/// read — a jail-escape exfiltration. Refusing symlinks closes that path.
|
||||||
|
fn copy_recursive(src: &Path, dest: &Path) -> anyhow::Result<()> {
|
||||||
|
let meta = fs::symlink_metadata(src)
|
||||||
|
.with_context(|| format!("stat source '{}'", src.display()))?;
|
||||||
|
|
||||||
|
if meta.file_type().is_symlink() {
|
||||||
|
bail!(
|
||||||
|
"refusing to copy symlink '{}' — symlinks are not followed across the jail boundary",
|
||||||
|
src.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.is_dir() {
|
||||||
|
fs::create_dir_all(dest)
|
||||||
|
.with_context(|| format!("create_dir_all '{}'", dest.display()))?;
|
||||||
|
|
||||||
|
for entry in fs::read_dir(src)
|
||||||
|
.with_context(|| format!("read_dir '{}'", src.display()))?
|
||||||
|
{
|
||||||
|
let entry = entry?;
|
||||||
|
copy_recursive(&entry.path(), &dest.join(entry.file_name()))?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fs::copy(src, dest)
|
||||||
|
.with_context(|| format!("copy '{}' -> '{}'", src.display(), dest.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NATS request dispatch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Dispatch a `FileRequest` against `root` and return a JSON `serde_json::Value`
|
||||||
|
/// ready for the NATS reply.
|
||||||
|
pub fn dispatch(root: &Path, req: &FileRequest) -> serde_json::Value {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
let result = match req.op.as_str() {
|
||||||
|
"list" => {
|
||||||
|
list(root, &req.path).map(|entries| json!({ "entries": entries }))
|
||||||
|
}
|
||||||
|
"read" => {
|
||||||
|
read(root, &req.path).map(|content| json!({ "content": content }))
|
||||||
|
}
|
||||||
|
"write" => {
|
||||||
|
let content = req.content.as_deref().unwrap_or("");
|
||||||
|
write(root, &req.path, content).map(|_| json!(null))
|
||||||
|
}
|
||||||
|
"delete" => {
|
||||||
|
delete(root, &req.path).map(|_| json!(null))
|
||||||
|
}
|
||||||
|
"rename" => {
|
||||||
|
let new_name = req.name.as_deref().unwrap_or("");
|
||||||
|
rename(root, &req.path, new_name).map(|_| json!(null))
|
||||||
|
}
|
||||||
|
"mkdir" => {
|
||||||
|
mkdir(root, &req.path).map(|_| json!(null))
|
||||||
|
}
|
||||||
|
"mkfile" => {
|
||||||
|
mkfile(root, &req.path).map(|_| json!(null))
|
||||||
|
}
|
||||||
|
"move" => {
|
||||||
|
let dest = req.dest.as_deref().unwrap_or("");
|
||||||
|
move_path(root, &req.path, dest).map(|_| json!(null))
|
||||||
|
}
|
||||||
|
"copy" => {
|
||||||
|
let dest = req.dest.as_deref().unwrap_or("");
|
||||||
|
copy(root, &req.path, dest).map(|_| json!(null))
|
||||||
|
}
|
||||||
|
other => Err(anyhow::anyhow!(
|
||||||
|
"unknown op '{}' (supported: list, read, write, delete, rename, mkdir, mkfile, move, copy)",
|
||||||
|
other
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(data) => json!({ "status": "success", "data": data }),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("filemanager op='{}' path='{}': {e:#}", req.op, req.path);
|
||||||
|
json!({ "status": "error", "message": format!("{e:#}") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to `corrosion.{license}.{instance}.files.cmd` and serve file
|
||||||
|
/// manager requests for `instance_id` jailed to `root`.
|
||||||
|
///
|
||||||
|
/// This function runs until the agent's cancellation token fires or the NATS
|
||||||
|
/// subscription ends. It is spawned once per instance in `main.rs`.
|
||||||
|
pub async fn run(
|
||||||
|
agent: std::sync::Arc<crate::agent::Agent>,
|
||||||
|
instance_id: String,
|
||||||
|
root: PathBuf,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
use futures::StreamExt;
|
||||||
|
|
||||||
|
let subject = crate::subjects::instance_files_cmd(&agent.cfg.license_id, &instance_id);
|
||||||
|
let mut sub = agent.nats.subscribe(subject.clone()).await?;
|
||||||
|
tracing::info!("file manager handler listening on {subject}");
|
||||||
|
|
||||||
|
let cancel = agent.shutdown.clone();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = sub.next() => {
|
||||||
|
match msg {
|
||||||
|
Some(msg) => {
|
||||||
|
let agent = agent.clone();
|
||||||
|
let root = root.clone();
|
||||||
|
let instance_id = instance_id.clone();
|
||||||
|
tokio::spawn(async move { handle(agent, &instance_id, &root, msg).await });
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!("file manager subscription ended for '{instance_id}'");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
tracing::info!("file manager handler stopping for '{instance_id}'");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
agent: std::sync::Arc<crate::agent::Agent>,
|
||||||
|
instance_id: &str,
|
||||||
|
root: &Path,
|
||||||
|
msg: async_nats::Message,
|
||||||
|
) {
|
||||||
|
let Some(reply) = msg.reply.clone() else {
|
||||||
|
tracing::warn!("file manager message without reply subject ignored (instance '{instance_id}')");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match serde_json::from_slice::<FileRequest>(&msg.payload) {
|
||||||
|
Ok(req) => {
|
||||||
|
// Blocking fs calls — offload from the async executor.
|
||||||
|
let root = root.to_path_buf();
|
||||||
|
tokio::task::spawn_blocking(move || dispatch(&root, &req))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
serde_json::json!({ "status": "error", "message": format!("internal error: {e}") })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
serde_json::json!({ "status": "error", "message": format!("invalid request payload: {e}") })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = match serde_json::to_vec(&response) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("file manager response serialize failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = agent.nats.publish(reply, bytes.into()).await {
|
||||||
|
tracing::warn!("file manager response publish failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
115
corrosion-host-agent/src/hostcmd.rs
Normal file
115
corrosion-host-agent/src/hostcmd.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
//! Host-level command handler: request-reply on `corrosion.{license}.host.cmd`.
|
||||||
|
//!
|
||||||
|
//! One subscriber; each message handled in its own task so a slow command
|
||||||
|
//! never blocks the dispatch loop. Phase 0 commands: ping, probe, sysinfo.
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use sysinfo::System;
|
||||||
|
|
||||||
|
use crate::agent::Agent;
|
||||||
|
use crate::prober;
|
||||||
|
use crate::subjects;
|
||||||
|
use crate::telemetry;
|
||||||
|
use crate::version;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct HostCommand {
|
||||||
|
func: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(agent: Arc<Agent>) -> anyhow::Result<()> {
|
||||||
|
let subject = subjects::host_cmd(&agent.cfg.license_id);
|
||||||
|
let mut sub = agent.nats.subscribe(subject.clone()).await?;
|
||||||
|
tracing::info!("host command handler listening on {subject}");
|
||||||
|
|
||||||
|
let cancel = agent.shutdown.clone();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = sub.next() => {
|
||||||
|
match msg {
|
||||||
|
Some(msg) => {
|
||||||
|
let agent = agent.clone();
|
||||||
|
tokio::spawn(async move { handle(agent, msg).await });
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!("host command subscription ended");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
tracing::info!("host command handler stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(agent: Arc<Agent>, msg: async_nats::Message) {
|
||||||
|
let Some(reply) = msg.reply.clone() else {
|
||||||
|
tracing::warn!("host command without reply subject ignored");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match serde_json::from_slice::<HostCommand>(&msg.payload) {
|
||||||
|
Ok(cmd) => dispatch(&agent, &cmd.func).await,
|
||||||
|
Err(e) => json!({ "status": "error", "message": format!("invalid command payload: {e}") }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = match serde_json::to_vec(&response) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("response serialize failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = agent.nats.publish(reply, bytes.into()).await {
|
||||||
|
tracing::warn!("response publish failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch(agent: &Arc<Agent>, func: &str) -> serde_json::Value {
|
||||||
|
match func {
|
||||||
|
"ping" => json!({
|
||||||
|
"status": "success",
|
||||||
|
"func": "ping",
|
||||||
|
"version": version::VERSION,
|
||||||
|
"commit": version::GIT_HASH,
|
||||||
|
"uptime_seconds": agent.started.elapsed().as_secs(),
|
||||||
|
}),
|
||||||
|
"probe" => {
|
||||||
|
let report = prober::run_probe(&agent.cfg.probe_targets).await;
|
||||||
|
*agent.last_probe.write().await = Some(report.clone());
|
||||||
|
match serde_json::to_value(&report) {
|
||||||
|
Ok(report_json) => json!({
|
||||||
|
"status": "success",
|
||||||
|
"func": "probe",
|
||||||
|
"report": report_json,
|
||||||
|
}),
|
||||||
|
Err(e) => json!({ "status": "error", "message": format!("probe serialize: {e}") }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"sysinfo" => {
|
||||||
|
let mut sys = System::new();
|
||||||
|
sys.refresh_cpu_usage();
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
let payload = telemetry::collect(agent, &mut sys).await;
|
||||||
|
match serde_json::to_value(&payload) {
|
||||||
|
Ok(snapshot) => json!({
|
||||||
|
"status": "success",
|
||||||
|
"func": "sysinfo",
|
||||||
|
"snapshot": snapshot,
|
||||||
|
}),
|
||||||
|
Err(e) => json!({ "status": "error", "message": format!("sysinfo serialize: {e}") }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": format!("unknown func '{other}' (supported: ping, probe, sysinfo)"),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
276
corrosion-host-agent/src/instancecmd.rs
Normal file
276
corrosion-host-agent/src/instancecmd.rs
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
//! Per-instance command channel + state-change events.
|
||||||
|
//!
|
||||||
|
//! Each process-managed instance gets a request-reply subscriber on
|
||||||
|
//! `corrosion.{license}.{instance_id}.cmd` (funcs: start/stop/restart/status/rcon)
|
||||||
|
//! and a publisher task that pushes every supervisor state change to
|
||||||
|
//! `corrosion.{license}.{instance_id}.status` — the panel sees crashes when
|
||||||
|
//! they happen, not when the next heartbeat ambles in.
|
||||||
|
|
||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use futures::StreamExt;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::agent::Agent;
|
||||||
|
use crate::process::ProcessSupervisor;
|
||||||
|
use crate::subjects;
|
||||||
|
use crate::steamcmd;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct InstanceCommand {
|
||||||
|
func: String,
|
||||||
|
/// Payload for funcs that carry a text argument (e.g. rcon).
|
||||||
|
#[serde(default)]
|
||||||
|
command: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward every supervisor state change as a status event.
|
||||||
|
pub async fn publish_state_changes(agent: Arc<Agent>, sup: Arc<ProcessSupervisor>) {
|
||||||
|
let subject = subjects::instance_status(&agent.cfg.license_id, &sup.instance_id);
|
||||||
|
let mut rx = sup.watch_state();
|
||||||
|
let cancel = agent.shutdown.clone();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
changed = rx.changed() => {
|
||||||
|
if changed.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let state = rx.borrow().clone();
|
||||||
|
let event = json!({
|
||||||
|
"timestamp": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
|
"instance_id": sup.instance_id,
|
||||||
|
"event": state,
|
||||||
|
});
|
||||||
|
match serde_json::to_vec(&event) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
if let Err(e) = agent.nats.publish(subject.clone(), bytes.into()).await {
|
||||||
|
tracing::warn!("status publish failed for '{}': {e}", sup.instance_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("status serialize failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = cancel.cancelled() => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request-reply command handler for one instance.
|
||||||
|
pub async fn run(agent: Arc<Agent>, sup: Arc<ProcessSupervisor>) -> anyhow::Result<()> {
|
||||||
|
let subject = subjects::instance_cmd(&agent.cfg.license_id, &sup.instance_id);
|
||||||
|
let mut sub = agent.nats.subscribe(subject.clone()).await?;
|
||||||
|
tracing::info!("instance command handler listening on {subject}");
|
||||||
|
|
||||||
|
let cancel = agent.shutdown.clone();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = sub.next() => {
|
||||||
|
match msg {
|
||||||
|
Some(msg) => {
|
||||||
|
let agent = agent.clone();
|
||||||
|
let sup = sup.clone();
|
||||||
|
tokio::spawn(async move { handle(agent, sup, msg).await });
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!("instance command subscription ended for '{}'", sup.instance_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
tracing::info!("instance command handler stopping for '{}'", sup.instance_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(agent: Arc<Agent>, sup: Arc<ProcessSupervisor>, msg: async_nats::Message) {
|
||||||
|
let Some(reply) = msg.reply.clone() else {
|
||||||
|
tracing::warn!("instance command without reply subject ignored");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match serde_json::from_slice::<InstanceCommand>(&msg.payload) {
|
||||||
|
Ok(cmd) => dispatch(&agent, &sup, &cmd).await,
|
||||||
|
Err(e) => json!({ "status": "error", "message": format!("invalid command payload: {e}") }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = match serde_json::to_vec(&response) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("response serialize failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = agent.nats.publish(reply, bytes.into()).await {
|
||||||
|
tracing::warn!("response publish failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch(
|
||||||
|
agent: &Arc<Agent>,
|
||||||
|
sup: &Arc<ProcessSupervisor>,
|
||||||
|
cmd: &InstanceCommand,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let func = cmd.func.as_str();
|
||||||
|
|
||||||
|
let outcome = match func {
|
||||||
|
"start" => sup.start().await.map(|_| "starting"),
|
||||||
|
"stop" => sup.stop().await.map(|_| "stopped"),
|
||||||
|
"restart" => sup.restart().await.map(|_| "restarted"),
|
||||||
|
"status" => {
|
||||||
|
return json!({
|
||||||
|
"status": "success",
|
||||||
|
"func": "status",
|
||||||
|
"instance_id": sup.instance_id,
|
||||||
|
"state": sup.state(),
|
||||||
|
"uptime_seconds": sup.uptime_seconds().await,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"rcon" => {
|
||||||
|
// Look up the InstanceConfig for this supervisor so we can access
|
||||||
|
// rcon settings and the game name without changing the supervisor's
|
||||||
|
// data model.
|
||||||
|
let inst_cfg = agent
|
||||||
|
.cfg
|
||||||
|
.instances
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.id == sup.instance_id);
|
||||||
|
|
||||||
|
let rcon_cfg = inst_cfg.and_then(|i| i.rcon.as_ref());
|
||||||
|
let Some(rcon_cfg) = rcon_cfg else {
|
||||||
|
return json!({
|
||||||
|
"status": "error",
|
||||||
|
"func": "rcon",
|
||||||
|
"instance_id": sup.instance_id,
|
||||||
|
"message": format!("instance '{}' has no rcon configured", sup.instance_id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(command) = cmd.command.as_deref() else {
|
||||||
|
return json!({
|
||||||
|
"status": "error",
|
||||||
|
"func": "rcon",
|
||||||
|
"instance_id": sup.instance_id,
|
||||||
|
"message": "rcon func requires a 'command' field",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let game = inst_cfg.map(|i| i.game.as_str()).unwrap_or("rust");
|
||||||
|
return match crate::rcon::send_command(rcon_cfg, game, command).await {
|
||||||
|
Ok(output) => json!({
|
||||||
|
"status": "success",
|
||||||
|
"func": "rcon",
|
||||||
|
"instance_id": sup.instance_id,
|
||||||
|
"output": output,
|
||||||
|
}),
|
||||||
|
Err(e) => json!({
|
||||||
|
"status": "error",
|
||||||
|
"func": "rcon",
|
||||||
|
"instance_id": sup.instance_id,
|
||||||
|
"message": format!("{e:#}"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"steam_update" => {
|
||||||
|
// Look up instance config for game name, root, and optional steamcmd
|
||||||
|
// settings. The supervisor only carries process-control state, not
|
||||||
|
// the full config, so we reach into agent.cfg.instances here as the
|
||||||
|
// rcon dispatch does.
|
||||||
|
let inst_cfg = agent.cfg.instances.iter().find(|i| i.id == sup.instance_id);
|
||||||
|
|
||||||
|
let Some(inst_cfg) = inst_cfg else {
|
||||||
|
return json!({
|
||||||
|
"status": "error",
|
||||||
|
"func": "steam_update",
|
||||||
|
"instance_id": sup.instance_id,
|
||||||
|
"message": format!("no config found for instance '{}'", sup.instance_id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let game = inst_cfg.game.as_str();
|
||||||
|
let root = inst_cfg.root.clone();
|
||||||
|
|
||||||
|
// Resolve steamcmd path and validate flag from config or use defaults.
|
||||||
|
let (steamcmd_path, validate) = match inst_cfg.steamcmd.as_ref() {
|
||||||
|
Some(s) => {
|
||||||
|
let path = s
|
||||||
|
.steamcmd_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.to_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| "steamcmd".to_string());
|
||||||
|
(path, s.validate)
|
||||||
|
}
|
||||||
|
None => ("steamcmd".to_string(), false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let license = agent.cfg.license_id.clone();
|
||||||
|
let instance_id = sup.instance_id.clone();
|
||||||
|
let nats = agent.nats.clone();
|
||||||
|
|
||||||
|
// Publish each progress line to the steam_status subject.
|
||||||
|
let on_progress = move |line: &str| {
|
||||||
|
let subject = subjects::instance_steam_status(&license, &instance_id);
|
||||||
|
let event = json!({
|
||||||
|
"timestamp": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
|
"instance_id": instance_id,
|
||||||
|
"line": line,
|
||||||
|
});
|
||||||
|
match serde_json::to_vec(&event) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
// Fire-and-forget; the async publish is non-blocking on
|
||||||
|
// the caller side. We create a mini-runtime task via
|
||||||
|
// a oneshot since on_progress is Fn (not async).
|
||||||
|
let nats = nats.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = nats.publish(subject, bytes.into()).await {
|
||||||
|
tracing::warn!("steam_status publish failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("steam_status serialize failed: {e}"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return match steamcmd::update(game, &root, &steamcmd_path, validate, on_progress).await {
|
||||||
|
Ok(()) => json!({
|
||||||
|
"status": "success",
|
||||||
|
"func": "steam_update",
|
||||||
|
"instance_id": sup.instance_id,
|
||||||
|
}),
|
||||||
|
Err(e) => json!({
|
||||||
|
"status": "error",
|
||||||
|
"func": "steam_update",
|
||||||
|
"instance_id": sup.instance_id,
|
||||||
|
"message": format!("{e:#}"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update)"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
Ok(result) => json!({
|
||||||
|
"status": "success",
|
||||||
|
"func": func,
|
||||||
|
"instance_id": sup.instance_id,
|
||||||
|
"result": result,
|
||||||
|
"state": sup.state(),
|
||||||
|
}),
|
||||||
|
Err(e) => json!({
|
||||||
|
"status": "error",
|
||||||
|
"func": func,
|
||||||
|
"instance_id": sup.instance_id,
|
||||||
|
"message": format!("{e:#}"),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
16
corrosion-host-agent/src/lib.rs
Normal file
16
corrosion-host-agent/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//! Corrosion Host Agent library surface — modules are public so integration
|
||||||
|
//! tests can drive subsystems (notably the process supervisor) directly.
|
||||||
|
|
||||||
|
pub mod agent;
|
||||||
|
pub mod bus;
|
||||||
|
pub mod config;
|
||||||
|
pub mod filemanager;
|
||||||
|
pub mod hostcmd;
|
||||||
|
pub mod instancecmd;
|
||||||
|
pub mod prober;
|
||||||
|
pub mod process;
|
||||||
|
pub mod rcon;
|
||||||
|
pub mod steamcmd;
|
||||||
|
pub mod subjects;
|
||||||
|
pub mod telemetry;
|
||||||
|
pub mod version;
|
||||||
204
corrosion-host-agent/src/main.rs
Normal file
204
corrosion-host-agent/src/main.rs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
//! Corrosion Host Agent — multi-game ops runtime.
|
||||||
|
//!
|
||||||
|
//! Phase 0: NATS connectivity, real host telemetry, multi-instance config,
|
||||||
|
//! connectivity prober, host command channel. Process control, file ops, and
|
||||||
|
//! game adapters arrive in Phase 1+ (see PROTOCOL.md).
|
||||||
|
|
||||||
|
use corrosion_host_agent::{
|
||||||
|
agent, bus, config, filemanager, hostcmd, instancecmd, prober, process, subjects, telemetry,
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::agent::Agent;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "corrosion-host-agent", version = version::VERSION, about)]
|
||||||
|
struct Cli {
|
||||||
|
/// Path to agent.toml (default: /etc/corrosion/agent.toml on Linux,
|
||||||
|
/// C:\ProgramData\Corrosion\agent.toml on Windows)
|
||||||
|
#[arg(long, short = 'c')]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
/// Validate the config file and exit.
|
||||||
|
Check,
|
||||||
|
/// Print full version (semver, git hash, build timestamp) and exit.
|
||||||
|
Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let config_path = cli.config.unwrap_or_else(config::default_config_path);
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Command::Version) => {
|
||||||
|
println!("corrosion-host-agent {}", version::long());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some(Command::Check) => {
|
||||||
|
let settings = config::load(&config_path)?;
|
||||||
|
println!(
|
||||||
|
"config ok: license {}, {} instance(s), nats {}",
|
||||||
|
settings.license_id,
|
||||||
|
settings.instances.len(),
|
||||||
|
settings.nats_url
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let settings = config::load(&config_path)?;
|
||||||
|
init_logging(&settings.log_level);
|
||||||
|
tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("building tokio runtime")?
|
||||||
|
.block_on(run(settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_logging(level: &str) {
|
||||||
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level));
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(settings: config::Settings) -> Result<()> {
|
||||||
|
tracing::info!(
|
||||||
|
"corrosion-host-agent {} starting: license {}, {} instance(s)",
|
||||||
|
version::long(),
|
||||||
|
settings.license_id,
|
||||||
|
settings.instances.len()
|
||||||
|
);
|
||||||
|
for inst in &settings.instances {
|
||||||
|
tracing::info!(" instance '{}' ({}) at {}", inst.id, inst.game, inst.root.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let nats = bus::connect(&settings).await?;
|
||||||
|
|
||||||
|
let supervisors = settings
|
||||||
|
.instances
|
||||||
|
.iter()
|
||||||
|
.map(|inst| (inst.id.clone(), process::ProcessSupervisor::new(inst)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let agent = Arc::new(Agent {
|
||||||
|
cfg: settings,
|
||||||
|
nats,
|
||||||
|
started: Instant::now(),
|
||||||
|
last_probe: RwLock::new(None),
|
||||||
|
supervisors,
|
||||||
|
shutdown: CancellationToken::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
handles.push(tokio::spawn(telemetry::run(agent.clone())));
|
||||||
|
handles.push(tokio::spawn(prober::run_loop(agent.clone())));
|
||||||
|
{
|
||||||
|
let agent = agent.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
if let Err(e) = hostcmd::run(agent).await {
|
||||||
|
tracing::error!("host command handler failed: {e:#}");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for (instance_id, sup) in &agent.supervisors {
|
||||||
|
{
|
||||||
|
let agent = agent.clone();
|
||||||
|
let sup = sup.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
if let Err(e) = instancecmd::run(agent, sup).await {
|
||||||
|
tracing::error!("instance command handler failed: {e:#}");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
handles.push(tokio::spawn(instancecmd::publish_state_changes(
|
||||||
|
agent.clone(),
|
||||||
|
sup.clone(),
|
||||||
|
)));
|
||||||
|
// File manager: one handler task per instance, jailed to root.
|
||||||
|
{
|
||||||
|
let agent = agent.clone();
|
||||||
|
let inst_cfg = agent
|
||||||
|
.cfg
|
||||||
|
.instances
|
||||||
|
.iter()
|
||||||
|
.find(|i| &i.id == instance_id)
|
||||||
|
.cloned();
|
||||||
|
if let Some(cfg) = inst_cfg {
|
||||||
|
let id = instance_id.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
if let Err(e) = filemanager::run(agent, id, cfg.root).await {
|
||||||
|
tracing::error!("file manager handler failed: {e:#}");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_shutdown_signal().await;
|
||||||
|
tracing::info!("shutdown signal received");
|
||||||
|
agent.shutdown.cancel();
|
||||||
|
|
||||||
|
// Best-effort offline beacon so the panel flips to offline immediately
|
||||||
|
// instead of waiting out the heartbeat staleness window.
|
||||||
|
let beacon = subjects::host_going_offline(&agent.cfg.license_id);
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
Duration::from_millis(500),
|
||||||
|
agent.nats.publish(beacon, "{}".into()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match tokio::time::timeout(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
futures::future::join_all(handles),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => tracing::info!("all subsystems stopped cleanly"),
|
||||||
|
Err(_) => tracing::warn!("shutdown timeout: some subsystems did not stop within 10s"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = agent.nats.flush().await;
|
||||||
|
tracing::info!("corrosion-host-agent stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_shutdown_signal() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use tokio::signal::unix::{signal, SignalKind};
|
||||||
|
let mut sigterm = match signal(SignalKind::terminate()) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("SIGTERM handler failed: {e}; falling back to ctrl-c only");
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::signal::ctrl_c() => {}
|
||||||
|
_ = sigterm.recv() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
corrosion-host-agent/src/prober.rs
Normal file
121
corrosion-host-agent/src/prober.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
//! Connectivity prober.
|
||||||
|
//!
|
||||||
|
//! Answers "is it the box or is it the network?" before a support ticket gets
|
||||||
|
//! written. Phase 0 scope is OUTBOUND reachability: TCP connect timing from
|
||||||
|
//! the host to known endpoints. Inbound port-forward verification (the thing
|
||||||
|
//! panel users actually struggle with) requires a backend-side reverse probe
|
||||||
|
//! and is specified in PROTOCOL.md as a later phase.
|
||||||
|
|
||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::agent::Agent;
|
||||||
|
use crate::config::ProbeTargetConfig;
|
||||||
|
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ProbeResult {
|
||||||
|
pub name: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub latency_ms: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ProbeReport {
|
||||||
|
pub timestamp: String,
|
||||||
|
pub results: Vec<ProbeResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built-in targets every agent checks, before config extras.
|
||||||
|
fn default_targets() -> Vec<ProbeTargetConfig> {
|
||||||
|
vec![ProbeTargetConfig {
|
||||||
|
name: "corrosion-cdn".to_string(),
|
||||||
|
host: "cdn.corrosionmgmt.com".to_string(),
|
||||||
|
port: 443,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_probe(extra_targets: &[ProbeTargetConfig]) -> ProbeReport {
|
||||||
|
let mut targets = default_targets();
|
||||||
|
targets.extend(extra_targets.iter().cloned());
|
||||||
|
|
||||||
|
let checks = targets.into_iter().map(|t| async move {
|
||||||
|
let started = Instant::now();
|
||||||
|
let addr = format!("{}:{}", t.host, t.port);
|
||||||
|
let outcome = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr)).await;
|
||||||
|
match outcome {
|
||||||
|
Ok(Ok(_stream)) => ProbeResult {
|
||||||
|
name: t.name,
|
||||||
|
host: t.host,
|
||||||
|
port: t.port,
|
||||||
|
ok: true,
|
||||||
|
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Ok(Err(e)) => ProbeResult {
|
||||||
|
name: t.name,
|
||||||
|
host: t.host,
|
||||||
|
port: t.port,
|
||||||
|
ok: false,
|
||||||
|
latency_ms: None,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
},
|
||||||
|
Err(_) => ProbeResult {
|
||||||
|
name: t.name,
|
||||||
|
host: t.host,
|
||||||
|
port: t.port,
|
||||||
|
ok: false,
|
||||||
|
latency_ms: None,
|
||||||
|
error: Some(format!("timeout after {}s", CONNECT_TIMEOUT.as_secs())),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let results = futures::future::join_all(checks).await;
|
||||||
|
|
||||||
|
ProbeReport {
|
||||||
|
timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
|
results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Periodic probe loop; results land in shared state and ride the next
|
||||||
|
/// heartbeat. Jittered interval to avoid fleet-wide synchronization.
|
||||||
|
pub async fn run_loop(agent: Arc<Agent>) {
|
||||||
|
let cancel = agent.shutdown.clone();
|
||||||
|
loop {
|
||||||
|
let report = run_probe(&agent.cfg.probe_targets).await;
|
||||||
|
let failed: Vec<&str> = report
|
||||||
|
.results
|
||||||
|
.iter()
|
||||||
|
.filter(|r| !r.ok)
|
||||||
|
.map(|r| r.name.as_str())
|
||||||
|
.collect();
|
||||||
|
if failed.is_empty() {
|
||||||
|
tracing::debug!("probe ok ({} targets)", report.results.len());
|
||||||
|
} else {
|
||||||
|
tracing::warn!("probe failures: {}", failed.join(", "));
|
||||||
|
}
|
||||||
|
*agent.last_probe.write().await = Some(report);
|
||||||
|
|
||||||
|
let jitter = rand::Rng::gen_range(&mut rand::thread_rng(), 0.8..1.2);
|
||||||
|
let interval =
|
||||||
|
Duration::from_secs_f64(agent.cfg.probe_interval_seconds as f64 * jitter);
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(interval) => {}
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
tracing::info!("prober stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
278
corrosion-host-agent/src/process.rs
Normal file
278
corrosion-host-agent/src/process.rs
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
//! Per-instance game-server process supervision.
|
||||||
|
//!
|
||||||
|
//! One `ProcessSupervisor` per process-managed instance. Lifecycle mirrors the
|
||||||
|
//! proven Go agent behavior — graceful SIGTERM with a 30s budget before force
|
||||||
|
//! kill, a monitor task that reaps the child and records crash-vs-stop — with
|
||||||
|
//! two fixes the Go version needed: args are a proper list (no naive space
|
||||||
|
//! splitting), and every state change is observable through a watch channel
|
||||||
|
//! so the panel gets push events instead of waiting for the next heartbeat.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::process::{Child, Command};
|
||||||
|
use tokio::sync::{watch, Mutex};
|
||||||
|
|
||||||
|
use crate::config::InstanceConfig;
|
||||||
|
|
||||||
|
const GRACEFUL_STOP_BUDGET: Duration = Duration::from_secs(30);
|
||||||
|
const RESTART_PAUSE: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "state")]
|
||||||
|
pub enum InstanceState {
|
||||||
|
/// Not process-managed (no executable configured).
|
||||||
|
Unmanaged,
|
||||||
|
Stopped,
|
||||||
|
Starting,
|
||||||
|
Running,
|
||||||
|
Stopping,
|
||||||
|
/// Process exited without a stop request.
|
||||||
|
Crashed {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
exit_code: Option<i32>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InstanceState {
|
||||||
|
pub fn as_label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
InstanceState::Unmanaged => "unmanaged",
|
||||||
|
InstanceState::Stopped => "stopped",
|
||||||
|
InstanceState::Starting => "starting",
|
||||||
|
InstanceState::Running => "running",
|
||||||
|
InstanceState::Stopping => "stopping",
|
||||||
|
InstanceState::Crashed { .. } => "crashed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
child: Option<Child>,
|
||||||
|
started_at: Option<Instant>,
|
||||||
|
/// True while a stop was requested — the monitor uses it to distinguish
|
||||||
|
/// an ordered shutdown from a crash.
|
||||||
|
stop_requested: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProcessSupervisor {
|
||||||
|
pub instance_id: String,
|
||||||
|
executable: Option<PathBuf>,
|
||||||
|
args: Vec<String>,
|
||||||
|
working_dir: Option<PathBuf>,
|
||||||
|
inner: Mutex<Inner>,
|
||||||
|
state_tx: watch::Sender<InstanceState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessSupervisor {
|
||||||
|
pub fn new(cfg: &InstanceConfig) -> Arc<Self> {
|
||||||
|
let executable = cfg.resolved_executable();
|
||||||
|
let initial = if executable.is_some() {
|
||||||
|
InstanceState::Stopped
|
||||||
|
} else {
|
||||||
|
InstanceState::Unmanaged
|
||||||
|
};
|
||||||
|
let (state_tx, _) = watch::channel(initial);
|
||||||
|
Arc::new(Self {
|
||||||
|
instance_id: cfg.id.clone(),
|
||||||
|
executable,
|
||||||
|
args: cfg.args.clone(),
|
||||||
|
working_dir: cfg.working_dir.clone(),
|
||||||
|
inner: Mutex::new(Inner {
|
||||||
|
child: None,
|
||||||
|
started_at: None,
|
||||||
|
stop_requested: false,
|
||||||
|
}),
|
||||||
|
state_tx,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(&self) -> InstanceState {
|
||||||
|
self.state_tx.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn watch_state(&self) -> watch::Receiver<InstanceState> {
|
||||||
|
self.state_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn uptime_seconds(&self) -> u64 {
|
||||||
|
let inner = self.inner.lock().await;
|
||||||
|
match (&*self.state_tx.borrow(), inner.started_at) {
|
||||||
|
(InstanceState::Running, Some(t)) => t.elapsed().as_secs(),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(self: &Arc<Self>) -> Result<()> {
|
||||||
|
let Some(exe) = self.executable.clone() else {
|
||||||
|
bail!("instance '{}' has no executable configured", self.instance_id);
|
||||||
|
};
|
||||||
|
if !exe.exists() {
|
||||||
|
bail!("executable not found: {}", exe.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
if matches!(*self.state_tx.borrow(), InstanceState::Running | InstanceState::Starting) {
|
||||||
|
bail!("instance '{}' is already running", self.instance_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_state(InstanceState::Starting);
|
||||||
|
|
||||||
|
let workdir = self
|
||||||
|
.working_dir
|
||||||
|
.clone()
|
||||||
|
.or_else(|| exe.parent().map(|p| p.to_path_buf()))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
|
||||||
|
let child = Command::new(&exe)
|
||||||
|
.args(&self.args)
|
||||||
|
.current_dir(&workdir)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::inherit())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("spawning {}", exe.display()))?;
|
||||||
|
|
||||||
|
let pid = child.id();
|
||||||
|
inner.child = Some(child);
|
||||||
|
inner.started_at = Some(Instant::now());
|
||||||
|
inner.stop_requested = false;
|
||||||
|
drop(inner);
|
||||||
|
|
||||||
|
self.set_state(InstanceState::Running);
|
||||||
|
tracing::info!(
|
||||||
|
"instance '{}' started: {} (pid {:?})",
|
||||||
|
self.instance_id,
|
||||||
|
exe.display(),
|
||||||
|
pid
|
||||||
|
);
|
||||||
|
|
||||||
|
// Monitor: reap the child and classify the exit.
|
||||||
|
let sup = Arc::clone(self);
|
||||||
|
tokio::spawn(async move { sup.monitor().await });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn monitor(self: Arc<Self>) {
|
||||||
|
// Take a waiter without holding the lock across the whole child
|
||||||
|
// lifetime: Child::wait needs &mut, so the child stays in inner and
|
||||||
|
// we poll it.
|
||||||
|
loop {
|
||||||
|
let status = {
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
let Some(child) = inner.child.as_mut() else { return };
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(status)) => Some(status),
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("instance '{}' wait failed: {e}", self.instance_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match status {
|
||||||
|
Some(status) => {
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
inner.child = None;
|
||||||
|
inner.started_at = None;
|
||||||
|
let ordered = inner.stop_requested;
|
||||||
|
inner.stop_requested = false;
|
||||||
|
drop(inner);
|
||||||
|
|
||||||
|
if ordered {
|
||||||
|
self.set_state(InstanceState::Stopped);
|
||||||
|
tracing::info!("instance '{}' stopped ({status})", self.instance_id);
|
||||||
|
} else {
|
||||||
|
let exit_code = status.code();
|
||||||
|
self.set_state(InstanceState::Crashed { exit_code });
|
||||||
|
tracing::warn!(
|
||||||
|
"instance '{}' exited unexpectedly ({status}) — marked crashed",
|
||||||
|
self.instance_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
None => tokio::time::sleep(Duration::from_millis(500)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(self: &Arc<Self>) -> Result<()> {
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
if inner.child.is_none() {
|
||||||
|
bail!("instance '{}' is not running", self.instance_id);
|
||||||
|
}
|
||||||
|
inner.stop_requested = true;
|
||||||
|
self.set_state(InstanceState::Stopping);
|
||||||
|
let child = inner.child.as_mut().expect("checked above");
|
||||||
|
|
||||||
|
// Graceful first: SIGTERM on unix; Windows has no SIGTERM equivalent
|
||||||
|
// for console processes, so it goes straight to kill there.
|
||||||
|
#[cfg(unix)]
|
||||||
|
if let Some(pid) = child.id() {
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid as i32, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = child.start_kill();
|
||||||
|
}
|
||||||
|
drop(inner);
|
||||||
|
|
||||||
|
// Wait for the monitor to observe the exit; force kill on budget.
|
||||||
|
let mut rx = self.watch_state();
|
||||||
|
let deadline = tokio::time::timeout(GRACEFUL_STOP_BUDGET, async {
|
||||||
|
loop {
|
||||||
|
if matches!(*rx.borrow(), InstanceState::Stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if rx.changed().await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if deadline.is_err() {
|
||||||
|
tracing::warn!(
|
||||||
|
"instance '{}' ignored SIGTERM for {}s — force killing",
|
||||||
|
self.instance_id,
|
||||||
|
GRACEFUL_STOP_BUDGET.as_secs()
|
||||||
|
);
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
if let Some(child) = inner.child.as_mut() {
|
||||||
|
let _ = child.start_kill();
|
||||||
|
}
|
||||||
|
drop(inner);
|
||||||
|
|
||||||
|
let mut rx = self.watch_state();
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
while !matches!(*rx.borrow(), InstanceState::Stopped) {
|
||||||
|
if rx.changed().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restart(self: &Arc<Self>) -> Result<()> {
|
||||||
|
if !matches!(*self.state_tx.borrow(), InstanceState::Stopped | InstanceState::Crashed { .. } | InstanceState::Unmanaged) {
|
||||||
|
self.stop().await?;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(RESTART_PAUSE).await;
|
||||||
|
self.start().await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_state(&self, state: InstanceState) {
|
||||||
|
// send_replace never fails even with zero receivers.
|
||||||
|
let _ = self.state_tx.send_replace(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
320
corrosion-host-agent/src/rcon.rs
Normal file
320
corrosion-host-agent/src/rcon.rs
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
//! RCON client: game-server remote-console over WebRCON (Rust) or Source RCON (Conan/Soulmask).
|
||||||
|
//!
|
||||||
|
//! The agent runs co-located with the game server, so every connection targets
|
||||||
|
//! 127.0.0.1 — no TLS is needed and latency is sub-millisecond. Two protocols
|
||||||
|
//! are supported because the Rust game ships its own WebSocket-based WebRCON
|
||||||
|
//! while Conan Exiles and Soulmask use the Valve Source RCON wire format over
|
||||||
|
//! plain TCP.
|
||||||
|
//!
|
||||||
|
//! The protocol selection is explicit in the config (`kind`) but can be inferred
|
||||||
|
//! from the game name when absent — callers supply the `game` field they already
|
||||||
|
//! have in `InstanceConfig`.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use rand::Rng;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
|
|
||||||
|
/// WebRCON is the Facepunch WebSocket protocol (Rust game).
|
||||||
|
/// Source RCON is the Valve wire protocol used by Conan Exiles and Soulmask.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum RconKind {
|
||||||
|
WebRcon,
|
||||||
|
Source,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RconConfig {
|
||||||
|
/// Protocol override. When absent the kind is resolved from `game`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: Option<RconKind>,
|
||||||
|
pub port: u16,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RconConfig {
|
||||||
|
/// Resolve the concrete protocol, falling back to a per-game default when
|
||||||
|
/// `kind` is not set. rust → WebRcon; conan + soulmask → Source.
|
||||||
|
pub fn resolved_kind(&self, game: &str) -> RconKind {
|
||||||
|
if let Some(k) = self.kind {
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
match game {
|
||||||
|
"conan" | "soulmask" => RconKind::Source,
|
||||||
|
// rust is the primary game; anything unknown defaults to WebRcon
|
||||||
|
// — operators can always override with an explicit `kind`.
|
||||||
|
_ => RconKind::WebRcon,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Send `command` to the game server and return its text response.
|
||||||
|
///
|
||||||
|
/// The agent runs on the same host as the game server, so the target address
|
||||||
|
/// is always 127.0.0.1:{port}. Connection and response deadlines are fixed at
|
||||||
|
/// 5 s and 10 s respectively — enough headroom for a loaded server while still
|
||||||
|
/// catching hung connections quickly.
|
||||||
|
pub async fn send_command(cfg: &RconConfig, game: &str, command: &str) -> Result<String> {
|
||||||
|
match cfg.resolved_kind(game) {
|
||||||
|
RconKind::WebRcon => webrcon_exec(cfg, command).await,
|
||||||
|
RconKind::Source => source_rcon_exec(cfg, command).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebRCON (Rust game) — WebSocket JSON protocol
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// WebRCON request/response envelope. The server also emits chat/log frames
|
||||||
|
/// on this socket with Identifier == 0; those are skipped.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct WebRconRequest<'a> {
|
||||||
|
#[serde(rename = "Identifier")]
|
||||||
|
identifier: i32,
|
||||||
|
#[serde(rename = "Message")]
|
||||||
|
message: &'a str,
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct WebRconResponse {
|
||||||
|
#[serde(rename = "Identifier")]
|
||||||
|
identifier: i32,
|
||||||
|
#[serde(rename = "Message")]
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn webrcon_exec(cfg: &RconConfig, command: &str) -> Result<String> {
|
||||||
|
use tokio_tungstenite::connect_async;
|
||||||
|
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
||||||
|
|
||||||
|
// The Rust game server embeds the password in the WebSocket URL path —
|
||||||
|
// never interpolate the real URL into errors or logs.
|
||||||
|
let url = format!("ws://127.0.0.1:{}/{}", cfg.port, cfg.password);
|
||||||
|
let redacted = format!("ws://127.0.0.1:{}/<redacted>", cfg.port);
|
||||||
|
|
||||||
|
// Wrap the entire connection + exchange in the connect timeout — we want
|
||||||
|
// the timeout to cover TCP handshake + WS upgrade, not just the send.
|
||||||
|
let (mut ws, _) = timeout(CONNECT_TIMEOUT, connect_async(&url))
|
||||||
|
.await
|
||||||
|
.context("connect timeout")?
|
||||||
|
.with_context(|| format!("WebRCON connect to {redacted}"))?;
|
||||||
|
|
||||||
|
// Use a random positive i32 so correlation is unambiguous even when
|
||||||
|
// multiple callers share a port (future concurrency).
|
||||||
|
let id: i32 = rand::thread_rng().gen_range(1..=i32::MAX);
|
||||||
|
let req = WebRconRequest { identifier: id, message: command, name: "Corrosion" };
|
||||||
|
let payload = serde_json::to_string(&req).context("serialize WebRCON request")?;
|
||||||
|
|
||||||
|
ws.send(WsMsg::Text(payload))
|
||||||
|
.await
|
||||||
|
.context("send WebRCON command")?;
|
||||||
|
|
||||||
|
tracing::debug!("WebRCON sent id={id} command={command:?}");
|
||||||
|
|
||||||
|
// Read frames until we see our Identifier — skip chat/log noise (id 0 or
|
||||||
|
// any other value that isn't ours).
|
||||||
|
let result = timeout(RESPONSE_TIMEOUT, async {
|
||||||
|
loop {
|
||||||
|
match ws.next().await {
|
||||||
|
Some(Ok(WsMsg::Text(text))) => {
|
||||||
|
match serde_json::from_str::<WebRconResponse>(&text) {
|
||||||
|
Ok(resp) if resp.identifier == id => return Ok(resp.message),
|
||||||
|
Ok(_) => {
|
||||||
|
// Not our response (chat, log, another caller's frame).
|
||||||
|
tracing::trace!("WebRCON skipping frame with different Identifier");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::trace!("WebRCON non-JSON frame ignored: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(WsMsg::Close(_))) => bail!("WebRCON server closed connection"),
|
||||||
|
Some(Ok(_)) => continue, // binary/ping/pong — skip
|
||||||
|
Some(Err(e)) => return Err(anyhow::anyhow!(e).context("WebRCON read error")),
|
||||||
|
None => bail!("WebRCON stream ended without response"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("WebRCON response timeout")??;
|
||||||
|
|
||||||
|
// Close cleanly; a send error here is cosmetic — we already have our data.
|
||||||
|
let _ = ws.close(None).await;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Source RCON (Conan Exiles, Soulmask) — Valve TCP binary protocol
|
||||||
|
//
|
||||||
|
// Packet layout (all fields little-endian):
|
||||||
|
// i32 size — byte count of the remaining packet (id + type + body + 2 nulls)
|
||||||
|
// i32 id — caller-chosen correlation id; auth failure returns -1
|
||||||
|
// i32 type — 0=RESPONSE_VALUE, 2=EXECCOMMAND/AUTH_RESPONSE, 3=AUTH
|
||||||
|
// [u8] body — UTF-8 command or response text
|
||||||
|
// u8 0x00 — body null terminator
|
||||||
|
// u8 0x00 — padding null terminator
|
||||||
|
//
|
||||||
|
// Multi-packet handling: after sending the command we also send an empty
|
||||||
|
// RESPONSE_VALUE probe with a distinct id. We collect all RESPONSE_VALUE
|
||||||
|
// packets belonging to the command id and stop when we receive the probe's
|
||||||
|
// response. This is the standard technique specified in the Valve wiki.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RCON_TYPE_AUTH: i32 = 3;
|
||||||
|
const RCON_TYPE_AUTH_RESPONSE: i32 = 2;
|
||||||
|
const RCON_TYPE_EXECCOMMAND: i32 = 2;
|
||||||
|
const RCON_TYPE_RESPONSE_VALUE: i32 = 0;
|
||||||
|
|
||||||
|
/// Maximum accumulated response body (guards against misbehaving servers).
|
||||||
|
const MAX_RESPONSE_BYTES: usize = 1024 * 1024; // 1 MiB
|
||||||
|
|
||||||
|
async fn source_rcon_exec(cfg: &RconConfig, command: &str) -> Result<String> {
|
||||||
|
let addr = format!("127.0.0.1:{}", cfg.port);
|
||||||
|
|
||||||
|
let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr))
|
||||||
|
.await
|
||||||
|
.context("connect timeout")?
|
||||||
|
.with_context(|| format!("Source RCON connect to {addr}"))?;
|
||||||
|
|
||||||
|
let mut stream = stream;
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
let auth_id: i32 = rand::thread_rng().gen_range(1..=i32::MAX);
|
||||||
|
send_packet(&mut stream, auth_id, RCON_TYPE_AUTH, cfg.password.as_bytes()).await?;
|
||||||
|
|
||||||
|
// The server sends two responses to AUTH: first an empty RESPONSE_VALUE,
|
||||||
|
// then an AUTH_RESPONSE. We skip the first and read until AUTH_RESPONSE.
|
||||||
|
timeout(RESPONSE_TIMEOUT, async {
|
||||||
|
loop {
|
||||||
|
let (id, ptype, _body) = recv_packet(&mut stream).await?;
|
||||||
|
if ptype == RCON_TYPE_AUTH_RESPONSE {
|
||||||
|
if id == -1 {
|
||||||
|
bail!("Source RCON auth failed: wrong password");
|
||||||
|
}
|
||||||
|
tracing::debug!("Source RCON authenticated (id={id})");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Skip the empty RESPONSE_VALUE that precedes AUTH_RESPONSE.
|
||||||
|
}
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("Source RCON auth timeout")??;
|
||||||
|
|
||||||
|
// --- Command ---
|
||||||
|
let cmd_id: i32 = rand::thread_rng().gen_range(1..=i32::MAX);
|
||||||
|
// Probe id must differ from cmd_id.
|
||||||
|
let probe_id: i32 = loop {
|
||||||
|
let id: i32 = rand::thread_rng().gen_range(1..=i32::MAX);
|
||||||
|
if id != cmd_id {
|
||||||
|
break id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
send_packet(&mut stream, cmd_id, RCON_TYPE_EXECCOMMAND, command.as_bytes()).await?;
|
||||||
|
// Empty RESPONSE_VALUE probe — the server echoes it after processing the
|
||||||
|
// preceding command, signalling end-of-response.
|
||||||
|
send_packet(&mut stream, probe_id, RCON_TYPE_RESPONSE_VALUE, b"").await?;
|
||||||
|
|
||||||
|
// Not every server is probe-conformant (Soulmask unverified): once we hold
|
||||||
|
// response data, a short per-read quiet period also terminates — never
|
||||||
|
// discard a response we already received just because the probe echo
|
||||||
|
// didn't come back.
|
||||||
|
const QUIET_PERIOD: Duration = Duration::from_millis(1500);
|
||||||
|
let response = timeout(RESPONSE_TIMEOUT, async {
|
||||||
|
let mut body_accum: Vec<u8> = Vec::new();
|
||||||
|
loop {
|
||||||
|
let next = if body_accum.is_empty() {
|
||||||
|
recv_packet(&mut stream).await.map(Some)
|
||||||
|
} else {
|
||||||
|
match timeout(QUIET_PERIOD, recv_packet(&mut stream)).await {
|
||||||
|
Ok(res) => res.map(Some),
|
||||||
|
Err(_elapsed) => Ok(None), // quiet after data — done
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some((id, ptype, body)) = next? else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if ptype != RCON_TYPE_RESPONSE_VALUE {
|
||||||
|
continue; // unexpected packet type — skip
|
||||||
|
}
|
||||||
|
if id == probe_id {
|
||||||
|
// Probe echoed back — all command response packets have arrived.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if id == cmd_id {
|
||||||
|
if body_accum.len() + body.len() > MAX_RESPONSE_BYTES {
|
||||||
|
bail!("Source RCON response exceeded {MAX_RESPONSE_BYTES} bytes");
|
||||||
|
}
|
||||||
|
body_accum.extend_from_slice(&body);
|
||||||
|
}
|
||||||
|
// Skip packets with other ids (shouldn't happen but be defensive).
|
||||||
|
}
|
||||||
|
Ok::<Vec<u8>, anyhow::Error>(body_accum)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("Source RCON response timeout")??;
|
||||||
|
|
||||||
|
String::from_utf8(response).context("Source RCON response is not valid UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a Source RCON packet to the stream.
|
||||||
|
async fn send_packet(stream: &mut TcpStream, id: i32, ptype: i32, body: &[u8]) -> Result<()> {
|
||||||
|
// size = id(4) + type(4) + body(n) + 2 null terminators
|
||||||
|
let size = (4 + 4 + body.len() + 2) as i32;
|
||||||
|
let mut buf: Vec<u8> = Vec::with_capacity(4 + size as usize);
|
||||||
|
buf.extend_from_slice(&size.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&id.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&ptype.to_le_bytes());
|
||||||
|
buf.extend_from_slice(body);
|
||||||
|
buf.push(0x00);
|
||||||
|
buf.push(0x00);
|
||||||
|
stream.write_all(&buf).await.context("Source RCON write")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one Source RCON packet; returns (id, type, body).
|
||||||
|
async fn recv_packet(stream: &mut TcpStream) -> Result<(i32, i32, Vec<u8>)> {
|
||||||
|
let mut size_buf = [0u8; 4];
|
||||||
|
stream
|
||||||
|
.read_exact(&mut size_buf)
|
||||||
|
.await
|
||||||
|
.context("Source RCON read size")?;
|
||||||
|
let size = i32::from_le_bytes(size_buf) as usize;
|
||||||
|
|
||||||
|
// Minimum packet: id(4) + type(4) + 2 null terminators = 10 bytes.
|
||||||
|
if size < 10 {
|
||||||
|
bail!("Source RCON: malformed packet (size={size})");
|
||||||
|
}
|
||||||
|
if size > MAX_RESPONSE_BYTES + 16 {
|
||||||
|
bail!("Source RCON: packet too large ({size} bytes)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut payload = vec![0u8; size];
|
||||||
|
stream
|
||||||
|
.read_exact(&mut payload)
|
||||||
|
.await
|
||||||
|
.context("Source RCON read payload")?;
|
||||||
|
|
||||||
|
let id = i32::from_le_bytes(payload[0..4].try_into().unwrap());
|
||||||
|
let ptype = i32::from_le_bytes(payload[4..8].try_into().unwrap());
|
||||||
|
// Body is everything between the two fields and the two trailing nulls.
|
||||||
|
let body_end = size.saturating_sub(2); // strip 2 null terminators
|
||||||
|
let body = payload[8..body_end].to_vec();
|
||||||
|
|
||||||
|
Ok((id, ptype, body))
|
||||||
|
}
|
||||||
126
corrosion-host-agent/src/steamcmd.rs
Normal file
126
corrosion-host-agent/src/steamcmd.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
//! SteamCMD update integration for process-managed game instances.
|
||||||
|
//!
|
||||||
|
//! Wraps the `steamcmd` binary to perform an `+app_update` for a given game
|
||||||
|
//! instance, streaming stdout lines to a caller-supplied progress callback so
|
||||||
|
//! the panel can display live update output. The agent already runs a task per
|
||||||
|
//! command in a separate `tokio::spawn`, so the blocking-until-done semantics
|
||||||
|
//! here are intentional — the NATS reply is sent only when SteamCMD exits.
|
||||||
|
//!
|
||||||
|
//! Dune is Docker-image-based and explicitly has no SteamCMD integration — any
|
||||||
|
//! attempt to invoke `update` on a Dune instance returns a clear error rather
|
||||||
|
//! than a silent no-op.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
/// Return the Steam app ID for a given game name, or `None` for Dune (Docker).
|
||||||
|
///
|
||||||
|
/// Soulmask returns the Windows or Linux server app ID depending on the compile
|
||||||
|
/// target so this function is `#[cfg]`-gated at the platform level.
|
||||||
|
pub fn app_id_for_game(game: &str) -> Option<u32> {
|
||||||
|
match game {
|
||||||
|
"rust" => Some(258550),
|
||||||
|
"conan" => Some(443030),
|
||||||
|
"soulmask" => {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
Some(3017310)
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
Some(3017300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dune uses Docker images — SteamCMD has no role here.
|
||||||
|
"dune" => None,
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration controlling SteamCMD behaviour for one instance.
|
||||||
|
/// Serialised as `[instance.steamcmd]` in agent.toml.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize, Default)]
|
||||||
|
pub struct SteamcmdConfig {
|
||||||
|
/// Absolute or relative path to the `steamcmd` binary.
|
||||||
|
/// Defaults to `"steamcmd"` (resolved via `PATH`) when absent.
|
||||||
|
#[serde(default)]
|
||||||
|
pub steamcmd_path: Option<std::path::PathBuf>,
|
||||||
|
|
||||||
|
/// Whether to pass `validate` to `+app_update`. Adds a file-hash check
|
||||||
|
/// pass that catches corruption at the cost of a longer update time.
|
||||||
|
#[serde(default)]
|
||||||
|
pub validate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a SteamCMD update for `game` into `install_dir`.
|
||||||
|
///
|
||||||
|
/// - `steamcmd_path`: path to the binary (or `"steamcmd"` to use PATH).
|
||||||
|
/// - `validate`: appends `validate` to the `+app_update` call.
|
||||||
|
/// - `on_progress`: receives each stdout line as it arrives so callers can
|
||||||
|
/// forward progress to the panel in real time.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(())` on a zero exit code, otherwise an error describing the
|
||||||
|
/// failure. Dune is rejected before any process is spawned.
|
||||||
|
pub async fn update(
|
||||||
|
game: &str,
|
||||||
|
install_dir: &Path,
|
||||||
|
steamcmd_path: &str,
|
||||||
|
validate: bool,
|
||||||
|
on_progress: impl Fn(&str),
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
let app_id = app_id_for_game(game).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"dune uses Docker images, not SteamCMD — cannot run app_update for game '{game}'"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let install_dir_str = install_dir
|
||||||
|
.to_str()
|
||||||
|
.with_context(|| format!("install_dir '{}' is not valid UTF-8", install_dir.display()))?;
|
||||||
|
|
||||||
|
let mut args: Vec<String> = vec![
|
||||||
|
"+force_install_dir".to_string(),
|
||||||
|
install_dir_str.to_string(),
|
||||||
|
"+login".to_string(),
|
||||||
|
"anonymous".to_string(),
|
||||||
|
"+app_update".to_string(),
|
||||||
|
app_id.to_string(),
|
||||||
|
];
|
||||||
|
if validate {
|
||||||
|
args.push("validate".to_string());
|
||||||
|
}
|
||||||
|
args.push("+quit".to_string());
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"steamcmd: starting update for game={game} app_id={app_id} install_dir={} validate={validate}",
|
||||||
|
install_dir.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut child = Command::new(steamcmd_path)
|
||||||
|
.args(&args)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("spawning steamcmd binary '{steamcmd_path}'"))?;
|
||||||
|
|
||||||
|
let stdout = child.stdout.take().expect("stdout was piped");
|
||||||
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
|
|
||||||
|
while let Some(line) = lines.next_line().await.context("reading steamcmd stdout")? {
|
||||||
|
tracing::debug!("steamcmd: {line}");
|
||||||
|
on_progress(&line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child.wait().await.context("waiting for steamcmd to exit")?;
|
||||||
|
if status.success() {
|
||||||
|
tracing::info!("steamcmd: update completed successfully for game={game}");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let code = status.code().unwrap_or(-1);
|
||||||
|
anyhow::bail!("steamcmd exited with non-zero status {code} for game={game}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
39
corrosion-host-agent/src/subjects.rs
Normal file
39
corrosion-host-agent/src/subjects.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//! Corrosion wire protocol v2 subject scheme (see PROTOCOL.md).
|
||||||
|
//!
|
||||||
|
//! Host-level subjects live under `corrosion.{license}.host.*`; per-instance
|
||||||
|
//! subjects under `corrosion.{license}.{instance_id}.*`. Instance ids are
|
||||||
|
//! validated at config load so they can never collide with the reserved
|
||||||
|
//! `host` segment or contain subject metacharacters.
|
||||||
|
|
||||||
|
pub fn host_heartbeat(license: &str) -> String {
|
||||||
|
format!("corrosion.{license}.host.heartbeat")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn host_cmd(license: &str) -> String {
|
||||||
|
format!("corrosion.{license}.host.cmd")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn host_going_offline(license: &str) -> String {
|
||||||
|
format!("corrosion.{license}.host.going_offline")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-instance command channel (start/stop/restart/status; rcon et al. to come).
|
||||||
|
pub fn instance_cmd(license: &str, instance: &str) -> String {
|
||||||
|
format!("corrosion.{license}.{instance}.cmd")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-instance state-change events.
|
||||||
|
pub fn instance_status(license: &str, instance: &str) -> String {
|
||||||
|
format!("corrosion.{license}.{instance}.status")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-instance SteamCMD progress stream. Lines from `steamcmd` stdout are
|
||||||
|
/// published here so the panel can display live update output.
|
||||||
|
pub fn instance_steam_status(license: &str, instance: &str) -> String {
|
||||||
|
format!("corrosion.{license}.{instance}.steam_status")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-instance file manager command channel (request-reply).
|
||||||
|
pub fn instance_files_cmd(license: &str, instance: &str) -> String {
|
||||||
|
format!("corrosion.{license}.{instance}.files.cmd")
|
||||||
|
}
|
||||||
185
corrosion-host-agent/src/telemetry.rs
Normal file
185
corrosion-host-agent/src/telemetry.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//! Host heartbeat: real telemetry, never fabricated.
|
||||||
|
//!
|
||||||
|
//! The Go agent shipped `disk_free_mb: 50000` and `cpu_percent: 0.0` as
|
||||||
|
//! hardcoded placeholders. This module is the first time the panel's
|
||||||
|
//! Resources view receives the truth. Anything we cannot measure is omitted
|
||||||
|
//! or null — never invented.
|
||||||
|
|
||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use rand::Rng;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use sysinfo::{Disks, System};
|
||||||
|
|
||||||
|
use crate::agent::Agent;
|
||||||
|
use crate::prober::ProbeReport;
|
||||||
|
use crate::subjects;
|
||||||
|
use crate::version;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HeartbeatPayload {
|
||||||
|
/// Wire schema version — lets the backend distinguish v2 host heartbeats
|
||||||
|
/// from legacy Go companion heartbeats during any transition window.
|
||||||
|
pub schema: u32,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub agent: AgentInfo,
|
||||||
|
pub host: HostInfo,
|
||||||
|
pub instances: Vec<InstanceInfo>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub probe: Option<ProbeReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AgentInfo {
|
||||||
|
pub version: String,
|
||||||
|
pub commit: String,
|
||||||
|
pub os: String,
|
||||||
|
pub arch: String,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HostInfo {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub cpu_percent: f32,
|
||||||
|
pub cpu_cores: usize,
|
||||||
|
pub mem_total_mb: u64,
|
||||||
|
pub mem_used_mb: u64,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
pub disks: Vec<DiskInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DiskInfo {
|
||||||
|
pub mount: String,
|
||||||
|
pub total_mb: u64,
|
||||||
|
pub free_mb: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct InstanceInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub game: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub label: Option<String>,
|
||||||
|
/// Process-managed: running/stopped/starting/stopping/crashed.
|
||||||
|
/// Unmanaged (no executable configured): configured/missing_root.
|
||||||
|
pub state: String,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub root_disk_free_mb: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(agent: Arc<Agent>) {
|
||||||
|
let cancel = agent.shutdown.clone();
|
||||||
|
let mut sys = System::new();
|
||||||
|
|
||||||
|
// CPU usage is a delta between refreshes; prime it once so the first
|
||||||
|
// heartbeat carries a real figure instead of 0.
|
||||||
|
sys.refresh_cpu_usage();
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let payload = collect(&agent, &mut sys).await;
|
||||||
|
match serde_json::to_vec(&payload) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let subject = subjects::host_heartbeat(&agent.cfg.license_id);
|
||||||
|
if let Err(e) = agent.nats.publish(subject, bytes.into()).await {
|
||||||
|
tracing::warn!("heartbeat publish failed: {e}");
|
||||||
|
} else {
|
||||||
|
tracing::debug!(
|
||||||
|
"heartbeat sent: cpu {:.1}%, {} instance(s)",
|
||||||
|
payload.host.cpu_percent,
|
||||||
|
payload.instances.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("heartbeat serialize failed: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let jitter = rand::thread_rng().gen_range(0.8..1.2);
|
||||||
|
let interval = Duration::from_secs_f64(agent.cfg.heartbeat_seconds as f64 * jitter);
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(interval) => {}
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
tracing::info!("telemetry stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn collect(agent: &Agent, sys: &mut System) -> HeartbeatPayload {
|
||||||
|
sys.refresh_cpu_usage();
|
||||||
|
sys.refresh_memory();
|
||||||
|
let disks = Disks::new_with_refreshed_list();
|
||||||
|
|
||||||
|
let disk_infos: Vec<DiskInfo> = disks
|
||||||
|
.iter()
|
||||||
|
.map(|d| DiskInfo {
|
||||||
|
mount: d.mount_point().to_string_lossy().to_string(),
|
||||||
|
total_mb: d.total_space() / 1_048_576,
|
||||||
|
free_mb: d.available_space() / 1_048_576,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut instances = Vec::with_capacity(agent.cfg.instances.len());
|
||||||
|
for inst in &agent.cfg.instances {
|
||||||
|
let (state, uptime_seconds) = match agent.supervisors.get(&inst.id) {
|
||||||
|
Some(sup) if !matches!(sup.state(), crate::process::InstanceState::Unmanaged) => {
|
||||||
|
(sup.state().as_label().to_string(), sup.uptime_seconds().await)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let exists = inst.root.exists();
|
||||||
|
(
|
||||||
|
if exists { "configured" } else { "missing_root" }.to_string(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
instances.push(InstanceInfo {
|
||||||
|
id: inst.id.clone(),
|
||||||
|
game: inst.game.clone(),
|
||||||
|
label: inst.label.clone(),
|
||||||
|
state,
|
||||||
|
uptime_seconds,
|
||||||
|
root_disk_free_mb: disk_free_for_path(&disks, &inst.root),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let instances = instances;
|
||||||
|
|
||||||
|
HeartbeatPayload {
|
||||||
|
schema: 2,
|
||||||
|
timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
|
agent: AgentInfo {
|
||||||
|
version: version::VERSION.to_string(),
|
||||||
|
commit: version::GIT_HASH.to_string(),
|
||||||
|
os: std::env::consts::OS.to_string(),
|
||||||
|
arch: std::env::consts::ARCH.to_string(),
|
||||||
|
uptime_seconds: agent.started.elapsed().as_secs(),
|
||||||
|
},
|
||||||
|
host: HostInfo {
|
||||||
|
hostname: System::host_name(),
|
||||||
|
cpu_percent: sys.global_cpu_usage(),
|
||||||
|
cpu_cores: sys.cpus().len(),
|
||||||
|
mem_total_mb: sys.total_memory() / 1_048_576,
|
||||||
|
mem_used_mb: sys.used_memory() / 1_048_576,
|
||||||
|
uptime_seconds: System::uptime(),
|
||||||
|
disks: disk_infos,
|
||||||
|
},
|
||||||
|
instances,
|
||||||
|
probe: agent.last_probe.read().await.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free space on the disk whose mount point is the longest prefix of `path`.
|
||||||
|
fn disk_free_for_path(disks: &Disks, path: &Path) -> Option<u64> {
|
||||||
|
disks
|
||||||
|
.iter()
|
||||||
|
.filter(|d| path.starts_with(d.mount_point()))
|
||||||
|
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||||
|
.map(|d| d.available_space() / 1_048_576)
|
||||||
|
}
|
||||||
10
corrosion-host-agent/src/version.rs
Normal file
10
corrosion-host-agent/src/version.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//! Build-time identity, embedded so every heartbeat and `--version` can state
|
||||||
|
//! exactly what is running.
|
||||||
|
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
pub const GIT_HASH: &str = env!("CORROSION_GIT_HASH");
|
||||||
|
pub const BUILD_TS: &str = env!("CORROSION_BUILD_TS");
|
||||||
|
|
||||||
|
pub fn long() -> String {
|
||||||
|
format!("{VERSION} ({GIT_HASH}, built {BUILD_TS})")
|
||||||
|
}
|
||||||
461
corrosion-host-agent/tests/filemanager.rs
Normal file
461
corrosion-host-agent/tests/filemanager.rs
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
//! Integration tests for the jailed file manager.
|
||||||
|
//!
|
||||||
|
//! Each test runs in a real tempdir on the host filesystem. The jail-escape
|
||||||
|
//! tests are the security-critical section: any path that resolves outside the
|
||||||
|
//! instance root MUST be rejected regardless of how the escape is attempted.
|
||||||
|
//!
|
||||||
|
//! Coverage:
|
||||||
|
//! - Functional: list, write, read roundtrip, mkdir, rename, delete
|
||||||
|
//! - Security: dotdot traversal, absolute path injection, symlink escape
|
||||||
|
//! (POSIX symlinks only — `#[cfg(unix)]`)
|
||||||
|
|
||||||
|
use corrosion_host_agent::filemanager;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Create a temporary directory and return its path. The directory is
|
||||||
|
/// automatically cleaned up when the `TempDir` is dropped.
|
||||||
|
fn tempdir() -> tempfile::TempDir {
|
||||||
|
tempfile::tempdir().expect("create tempdir")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Functional tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_read_roundtrip() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
let content = "hello from the file manager\nline 2\n";
|
||||||
|
filemanager::write(root, "test.txt", content).expect("write should succeed");
|
||||||
|
|
||||||
|
let got = filemanager::read(root, "test.txt").expect("read should succeed");
|
||||||
|
assert_eq!(got, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_returns_written_file() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::write(root, "server.cfg", "hostname MyServer\n").expect("write");
|
||||||
|
|
||||||
|
let entries = filemanager::list(root, "").expect("list root");
|
||||||
|
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
assert!(names.contains(&"server.cfg"), "expected 'server.cfg' in listing, got {names:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_empty_root_is_empty() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let entries = filemanager::list(dir.path(), "").expect("list empty root");
|
||||||
|
assert!(entries.is_empty(), "fresh tempdir should have no entries");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mkdir_creates_directory() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::mkdir(root, "cfg/custom").expect("mkdir should succeed");
|
||||||
|
|
||||||
|
assert!(root.join("cfg/custom").is_dir(), "directory should exist after mkdir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mkdir_creates_nested_dirs() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::mkdir(root, "a/b/c/d").expect("mkdir nested");
|
||||||
|
assert!(root.join("a/b/c/d").is_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_creates_parent_dirs() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::write(root, "subdir/deep/file.txt", "data").expect("write with auto-mkdir");
|
||||||
|
let content = filemanager::read(root, "subdir/deep/file.txt").expect("read");
|
||||||
|
assert_eq!(content, "data");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_file() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::write(root, "old.txt", "content").expect("write");
|
||||||
|
filemanager::rename(root, "old.txt", "new.txt").expect("rename");
|
||||||
|
|
||||||
|
assert!(!root.join("old.txt").exists(), "old.txt should be gone");
|
||||||
|
assert!(root.join("new.txt").exists(), "new.txt should exist");
|
||||||
|
|
||||||
|
let content = filemanager::read(root, "new.txt").expect("read renamed");
|
||||||
|
assert_eq!(content, "content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_rejects_separator_in_new_name() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::write(root, "file.txt", "data").expect("write");
|
||||||
|
|
||||||
|
let err = filemanager::rename(root, "file.txt", "subdir/escape.txt")
|
||||||
|
.expect_err("rename with path separator must fail");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("separator"),
|
||||||
|
"error should mention separator: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_file() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::write(root, "todelete.txt", "bye").expect("write");
|
||||||
|
assert!(root.join("todelete.txt").exists());
|
||||||
|
|
||||||
|
filemanager::delete(root, "todelete.txt").expect("delete");
|
||||||
|
assert!(!root.join("todelete.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_directory_recursive() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::mkdir(root, "tree/sub").expect("mkdir");
|
||||||
|
filemanager::write(root, "tree/sub/file.txt", "x").expect("write");
|
||||||
|
assert!(root.join("tree").is_dir());
|
||||||
|
|
||||||
|
filemanager::delete(root, "tree").expect("delete tree");
|
||||||
|
assert!(!root.join("tree").exists(), "directory tree should be deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mkfile_creates_empty_file() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::mkfile(root, "empty.txt").expect("mkfile");
|
||||||
|
let content = filemanager::read(root, "empty.txt").expect("read empty file");
|
||||||
|
assert_eq!(content, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_file() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::write(root, "source.txt", "original").expect("write source");
|
||||||
|
filemanager::copy(root, "source.txt", "dest.txt").expect("copy");
|
||||||
|
|
||||||
|
let src = filemanager::read(root, "source.txt").expect("read source after copy");
|
||||||
|
let dst = filemanager::read(root, "dest.txt").expect("read destination");
|
||||||
|
assert_eq!(src, "original");
|
||||||
|
assert_eq!(dst, "original");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_file() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::write(root, "moveme.txt", "payload").expect("write");
|
||||||
|
filemanager::move_path(root, "moveme.txt", "moved.txt").expect("move");
|
||||||
|
|
||||||
|
assert!(!root.join("moveme.txt").exists(), "source should be gone");
|
||||||
|
let content = filemanager::read(root, "moved.txt").expect("read after move");
|
||||||
|
assert_eq!(content, "payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_entry_fields_are_populated() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::write(root, "check.txt", "abcde").expect("write");
|
||||||
|
filemanager::mkdir(root, "subdir").expect("mkdir");
|
||||||
|
|
||||||
|
let entries = filemanager::list(root, "").expect("list");
|
||||||
|
// Dirs sort before files.
|
||||||
|
let dir_entry = entries.iter().find(|e| e.name == "subdir").expect("subdir entry");
|
||||||
|
assert!(dir_entry.is_dir);
|
||||||
|
assert_eq!(dir_entry.size, 0);
|
||||||
|
assert!(!dir_entry.modified.is_empty(), "modified should be set");
|
||||||
|
|
||||||
|
let file_entry = entries.iter().find(|e| e.name == "check.txt").expect("file entry");
|
||||||
|
assert!(!file_entry.is_dir);
|
||||||
|
assert_eq!(file_entry.size, 5, "size should match byte count");
|
||||||
|
// path should be relative and use forward slashes.
|
||||||
|
assert!(!file_entry.path.starts_with('/'), "path should be relative");
|
||||||
|
assert!(!file_entry.path.contains('\\'), "path should use forward slashes");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Security: jail-escape tests
|
||||||
|
// CRITICAL — these are the whole point of the jail abstraction.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `../../etc/passwd` must never resolve outside the instance root.
|
||||||
|
#[test]
|
||||||
|
fn jail_rejects_dotdot_traversal() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
let err = filemanager::read(root, "../../etc/passwd")
|
||||||
|
.expect_err("dotdot traversal must be rejected");
|
||||||
|
// Verify the error is security-related and not just "file not found".
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
|
||||||
|
"error should mention jail escape for dotdot traversal, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deeply nested `../` chain must also be stopped.
|
||||||
|
#[test]
|
||||||
|
fn jail_rejects_deep_dotdot_traversal() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
let err = filemanager::read(root, "a/b/c/../../../../../../../../etc/shadow")
|
||||||
|
.expect_err("deep dotdot traversal must be rejected");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape") || msg.contains("absolute"),
|
||||||
|
"error should mention jail escape for deep traversal, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An absolute path (e.g. `/etc/passwd`) must be rejected immediately — it
|
||||||
|
/// completely bypasses relative joining and should never be accepted.
|
||||||
|
#[test]
|
||||||
|
fn jail_rejects_absolute_path() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
let err = filemanager::read(root, "/etc/passwd")
|
||||||
|
.expect_err("absolute path must be rejected");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("absolute") || msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
|
||||||
|
"error should mention the absolute-path rejection, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An absolute path to a Windows-style location must also be rejected.
|
||||||
|
#[test]
|
||||||
|
fn jail_rejects_absolute_windows_style_path() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
// On POSIX this is just treated as an absolute path starting with `/`.
|
||||||
|
// The test is intentionally platform-portable: any absolute path is bad.
|
||||||
|
let err = filemanager::read(root, "/tmp/evil")
|
||||||
|
.expect_err("absolute /tmp/evil must be rejected");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("absolute") || msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
|
||||||
|
"got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A symlink inside the root that points to a path outside the root must not
|
||||||
|
/// be followed. This is the critical symlink-escape vector.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn jail_rejects_symlink_escape() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
// Create a directory outside the root to be the symlink target.
|
||||||
|
let outside = tempdir();
|
||||||
|
let outside_file = outside.path().join("secret.txt");
|
||||||
|
std::fs::write(&outside_file, "secret data").expect("write outside file");
|
||||||
|
|
||||||
|
// Plant a symlink inside the root pointing to the outside directory.
|
||||||
|
let link_path = root.join("evil_link");
|
||||||
|
std::os::unix::fs::symlink(outside.path(), &link_path)
|
||||||
|
.expect("create symlink inside root");
|
||||||
|
|
||||||
|
// Attempt to read through the symlink.
|
||||||
|
let err = filemanager::read(root, "evil_link/secret.txt")
|
||||||
|
.expect_err("symlink escape must be rejected");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
|
||||||
|
"error should mention jail escape for symlink traversal, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A symlink directly inside the root pointing to a file outside must be
|
||||||
|
/// rejected even when the path looks like a normal relative reference.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn jail_rejects_symlink_pointing_directly_outside() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
// Symlink to /etc/passwd itself (or any outside path that exists or not).
|
||||||
|
let link_path = root.join("passwd_link");
|
||||||
|
std::os::unix::fs::symlink(Path::new("/etc/passwd"), &link_path)
|
||||||
|
.expect("create symlink to /etc/passwd");
|
||||||
|
|
||||||
|
let err = filemanager::read(root, "passwd_link")
|
||||||
|
.expect_err("direct symlink outside root must be rejected");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
|
||||||
|
"error should mention jail escape, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A symlink chain (symlink → symlink → outside) must also be caught.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn jail_rejects_chained_symlink_escape() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
let outside = tempdir();
|
||||||
|
|
||||||
|
// Chain: root/link1 → root/link2 → outside/
|
||||||
|
let link2_path = root.join("link2");
|
||||||
|
std::os::unix::fs::symlink(outside.path(), &link2_path)
|
||||||
|
.expect("create link2");
|
||||||
|
|
||||||
|
let link1_path = root.join("link1");
|
||||||
|
std::os::unix::fs::symlink(&link2_path, &link1_path)
|
||||||
|
.expect("create link1");
|
||||||
|
|
||||||
|
let err = filemanager::read(root, "link1")
|
||||||
|
.expect_err("chained symlink escape must be rejected");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
|
||||||
|
"chained symlink should be caught, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SECURITY REGRESSION: copying a directory that contains a symlink pointing
|
||||||
|
/// OUTSIDE the jail must NOT dereference it and pull external content inside.
|
||||||
|
/// jail() validates only the top-level src/dest; the recursive copy must
|
||||||
|
/// refuse symlinks itself or it becomes a read-escape exfiltration path.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn copy_refuses_to_follow_symlink_out_of_jail() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
let outside = tempdir();
|
||||||
|
std::fs::write(outside.path().join("secret.txt"), "TOP SECRET")
|
||||||
|
.expect("write external secret");
|
||||||
|
|
||||||
|
// A directory inside the jail containing a symlink to the outside dir.
|
||||||
|
std::fs::create_dir(root.join("src")).expect("mkdir src");
|
||||||
|
std::os::unix::fs::symlink(outside.path(), root.join("src").join("escape"))
|
||||||
|
.expect("plant symlink to outside");
|
||||||
|
|
||||||
|
// Attempt to copy src -> dest (both inside the jail).
|
||||||
|
let err = filemanager::copy(root, "src", "dest")
|
||||||
|
.expect_err("copy must refuse the embedded symlink");
|
||||||
|
assert!(
|
||||||
|
format!("{err:#}").contains("symlink"),
|
||||||
|
"error should name the refused symlink, got: {err:#}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The external secret must NOT have landed inside the jail.
|
||||||
|
assert!(
|
||||||
|
!root.join("dest").join("escape").join("secret.txt").exists(),
|
||||||
|
"external content leaked into the jail via symlink-following copy",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `list` must report a symlink as the link itself, never the dereferenced
|
||||||
|
/// target — otherwise it leaks the size/type of files outside the jail.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn list_does_not_dereference_symlink_metadata() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
std::os::unix::fs::symlink(Path::new("/etc/passwd"), root.join("leak"))
|
||||||
|
.expect("plant symlink");
|
||||||
|
|
||||||
|
let entries = filemanager::list(root, "").expect("list root");
|
||||||
|
let leak = entries.iter().find(|e| e.name == "leak").expect("symlink listed");
|
||||||
|
// /etc/passwd is a regular file; if we followed the link, is_dir would
|
||||||
|
// reflect the target. We must report the link, which is not a directory,
|
||||||
|
// and must NOT expose the target's byte size.
|
||||||
|
assert!(!leak.is_dir, "symlink must not be reported as a directory");
|
||||||
|
let target_size = std::fs::metadata("/etc/passwd").map(|m| m.len()).unwrap_or(0);
|
||||||
|
assert!(
|
||||||
|
leak.size != target_size || target_size == 0,
|
||||||
|
"list leaked the symlink target's size ({target_size} bytes)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dispatch layer tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_list_returns_success() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
filemanager::write(root, "a.txt", "a").expect("write");
|
||||||
|
|
||||||
|
let req = filemanager::FileRequest {
|
||||||
|
op: "list".to_string(),
|
||||||
|
path: String::new(),
|
||||||
|
dest: None,
|
||||||
|
content: None,
|
||||||
|
name: None,
|
||||||
|
};
|
||||||
|
let resp = filemanager::dispatch(root, &req);
|
||||||
|
assert_eq!(resp["status"], "success");
|
||||||
|
assert!(resp["data"]["entries"].is_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_unknown_op_returns_error() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let req = filemanager::FileRequest {
|
||||||
|
op: "explode".to_string(),
|
||||||
|
path: String::new(),
|
||||||
|
dest: None,
|
||||||
|
content: None,
|
||||||
|
name: None,
|
||||||
|
};
|
||||||
|
let resp = filemanager::dispatch(dir.path(), &req);
|
||||||
|
assert_eq!(resp["status"], "error");
|
||||||
|
assert!(resp["message"].as_str().unwrap().contains("unknown op"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_escape_attempt_returns_error_not_panic() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let req = filemanager::FileRequest {
|
||||||
|
op: "read".to_string(),
|
||||||
|
path: "../../etc/passwd".to_string(),
|
||||||
|
dest: None,
|
||||||
|
content: None,
|
||||||
|
name: None,
|
||||||
|
};
|
||||||
|
let resp = filemanager::dispatch(dir.path(), &req);
|
||||||
|
// Must return an error response, not panic or expose the file.
|
||||||
|
assert_eq!(resp["status"], "error", "escape attempt should return error status");
|
||||||
|
assert!(
|
||||||
|
resp["message"].as_str().is_some(),
|
||||||
|
"error response must have a message"
|
||||||
|
);
|
||||||
|
}
|
||||||
353
corrosion-host-agent/tests/rcon.rs
Normal file
353
corrosion-host-agent/tests/rcon.rs
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
//! RCON integration tests using in-process mock servers.
|
||||||
|
//!
|
||||||
|
//! Real OS sockets on ephemeral ports — no mocking framework. Each test
|
||||||
|
//! binds a listener, spawns a task that speaks the expected protocol, then
|
||||||
|
//! exercises `rcon::send_command` and asserts on the result. Tests are
|
||||||
|
//! unix-only because the musl cross-compile target and the CI runner are both
|
||||||
|
//! Linux; the production use case is also Linux-only (game servers don't run
|
||||||
|
//! on macOS or Windows in production).
|
||||||
|
//!
|
||||||
|
//! We use `#[cfg(unix)]` to keep parity with the supervisor integration tests.
|
||||||
|
#![cfg(unix)]
|
||||||
|
|
||||||
|
use corrosion_host_agent::rcon::{RconConfig, RconKind};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Source RCON helpers — duplicate the wire-format encode/decode locally so
|
||||||
|
// the tests own the mock server without depending on the production code path.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Build a Source RCON packet: [size(4LE) | id(4LE) | type(4LE) | body | 0x00 0x00]
|
||||||
|
fn encode_packet(id: i32, ptype: i32, body: &[u8]) -> Vec<u8> {
|
||||||
|
let size = (4 + 4 + body.len() + 2) as i32;
|
||||||
|
let mut out = Vec::with_capacity(4 + size as usize);
|
||||||
|
out.extend_from_slice(&size.to_le_bytes());
|
||||||
|
out.extend_from_slice(&id.to_le_bytes());
|
||||||
|
out.extend_from_slice(&ptype.to_le_bytes());
|
||||||
|
out.extend_from_slice(body);
|
||||||
|
out.push(0x00);
|
||||||
|
out.push(0x00);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one Source RCON packet from a TcpStream.
|
||||||
|
async fn read_packet(stream: &mut TcpStream) -> (i32, i32, Vec<u8>) {
|
||||||
|
let mut size_buf = [0u8; 4];
|
||||||
|
stream.read_exact(&mut size_buf).await.unwrap();
|
||||||
|
let size = i32::from_le_bytes(size_buf) as usize;
|
||||||
|
|
||||||
|
let mut payload = vec![0u8; size];
|
||||||
|
stream.read_exact(&mut payload).await.unwrap();
|
||||||
|
|
||||||
|
let id = i32::from_le_bytes(payload[0..4].try_into().unwrap());
|
||||||
|
let ptype = i32::from_le_bytes(payload[4..8].try_into().unwrap());
|
||||||
|
let body_end = size.saturating_sub(2);
|
||||||
|
let body = payload[8..body_end].to_vec();
|
||||||
|
(id, ptype, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_TYPE_AUTH: i32 = 3;
|
||||||
|
const SOURCE_TYPE_AUTH_RESPONSE: i32 = 2;
|
||||||
|
const SOURCE_TYPE_EXECCOMMAND: i32 = 2;
|
||||||
|
const SOURCE_TYPE_RESPONSE_VALUE: i32 = 0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock Source RCON server
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Run a Source RCON server that accepts password "goodpw", rejects others,
|
||||||
|
/// and responds to the first EXECCOMMAND with `response_body`.
|
||||||
|
///
|
||||||
|
/// If `split_at` is Some(n) the body is split: the first `n` bytes arrive in
|
||||||
|
/// one RESPONSE_VALUE packet and the remainder in a second — testing multi-
|
||||||
|
/// packet reassembly.
|
||||||
|
async fn run_source_mock(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
accept_password: &str,
|
||||||
|
command_response: &[u8],
|
||||||
|
split_at: Option<usize>,
|
||||||
|
) {
|
||||||
|
// --- Auth phase ---
|
||||||
|
let (auth_id, ptype, body) = read_packet(&mut stream).await;
|
||||||
|
assert_eq!(ptype, SOURCE_TYPE_AUTH, "expected AUTH packet");
|
||||||
|
|
||||||
|
let password = String::from_utf8_lossy(&body);
|
||||||
|
if password != accept_password {
|
||||||
|
// Send empty RESPONSE_VALUE then AUTH_RESPONSE with id = -1 (failure).
|
||||||
|
let empty = encode_packet(auth_id, SOURCE_TYPE_RESPONSE_VALUE, b"");
|
||||||
|
stream.write_all(&empty).await.unwrap();
|
||||||
|
let fail = encode_packet(-1, SOURCE_TYPE_AUTH_RESPONSE, b"");
|
||||||
|
stream.write_all(&fail).await.unwrap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: empty RESPONSE_VALUE then AUTH_RESPONSE with the auth id.
|
||||||
|
let empty = encode_packet(auth_id, SOURCE_TYPE_RESPONSE_VALUE, b"");
|
||||||
|
stream.write_all(&empty).await.unwrap();
|
||||||
|
let ok = encode_packet(auth_id, SOURCE_TYPE_AUTH_RESPONSE, b"");
|
||||||
|
stream.write_all(&ok).await.unwrap();
|
||||||
|
|
||||||
|
// --- Command phase ---
|
||||||
|
let (cmd_id, cmd_ptype, _cmd_body) = read_packet(&mut stream).await;
|
||||||
|
assert_eq!(cmd_ptype, SOURCE_TYPE_EXECCOMMAND, "expected EXECCOMMAND");
|
||||||
|
|
||||||
|
// Read the probe packet (empty RESPONSE_VALUE with a different id).
|
||||||
|
let (probe_id, probe_ptype, _) = read_packet(&mut stream).await;
|
||||||
|
assert_eq!(probe_ptype, SOURCE_TYPE_RESPONSE_VALUE, "expected probe packet");
|
||||||
|
|
||||||
|
// Send the command response, optionally split across two packets.
|
||||||
|
if let Some(n) = split_at {
|
||||||
|
let (part1, part2) = command_response.split_at(n.min(command_response.len()));
|
||||||
|
let p1 = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, part1);
|
||||||
|
stream.write_all(&p1).await.unwrap();
|
||||||
|
let p2 = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, part2);
|
||||||
|
stream.write_all(&p2).await.unwrap();
|
||||||
|
} else {
|
||||||
|
let p = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, command_response);
|
||||||
|
stream.write_all(&p).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Echo the probe to signal end-of-response.
|
||||||
|
let probe_echo = encode_packet(probe_id, SOURCE_TYPE_RESPONSE_VALUE, b"");
|
||||||
|
stream.write_all(&probe_echo).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Source RCON tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn source_rcon_auth_and_exec_returns_response() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
run_source_mock(stream, "goodpw", b"Hello from server", None).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() };
|
||||||
|
let result = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status")
|
||||||
|
.await
|
||||||
|
.expect("command should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result, "Hello from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn source_rcon_wrong_password_returns_auth_error() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
run_source_mock(stream, "goodpw", b"should not see this", None).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "wrongpw".to_string() };
|
||||||
|
let err = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status")
|
||||||
|
.await
|
||||||
|
.expect_err("wrong password should fail");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().to_lowercase().contains("auth"),
|
||||||
|
"error should mention auth failure, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn source_rcon_multi_packet_response_concatenated() {
|
||||||
|
// Build a body large enough to split meaningfully across two packets.
|
||||||
|
// Use repeating ASCII so the result is valid UTF-8 and easy to verify.
|
||||||
|
// 200 'A's then 200 'B's = 400 bytes, split at 200.
|
||||||
|
let body: Vec<u8> = std::iter::repeat_n(b'A', 200)
|
||||||
|
.chain(std::iter::repeat_n(b'B', 200))
|
||||||
|
.collect();
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let body_clone = body.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
run_source_mock(stream, "goodpw", &body_clone, Some(200)).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() };
|
||||||
|
let result = corrosion_host_agent::rcon::send_command(&cfg, "soulmask", "showplayers")
|
||||||
|
.await
|
||||||
|
.expect("multi-packet command should succeed");
|
||||||
|
|
||||||
|
let expected = String::from_utf8(body).unwrap();
|
||||||
|
assert_eq!(result, expected, "full body should be concatenated across both packets");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn source_rcon_connect_timeout_to_unreachable_port() {
|
||||||
|
// Bind a listener but never accept — the connection will time out during
|
||||||
|
// the RCON auth phase because nothing is reading from the socket.
|
||||||
|
// We use a port that is bound (so TCP connect itself succeeds) but then
|
||||||
|
// the mock simply drops the stream, forcing a read error, which should
|
||||||
|
// surface as an error (not a panic or hang).
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
|
||||||
|
// Accept the TCP connection but immediately drop it — simulates a port
|
||||||
|
// that accepts but never speaks RCON.
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (_stream, _) = listener.accept().await.unwrap();
|
||||||
|
// _stream dropped here — EOF on the client's read
|
||||||
|
});
|
||||||
|
|
||||||
|
let cfg =
|
||||||
|
RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() };
|
||||||
|
let err = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status")
|
||||||
|
.await
|
||||||
|
.expect_err("closed connection should fail");
|
||||||
|
|
||||||
|
// We just need it to fail and not hang; error message varies by OS.
|
||||||
|
let _ = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebRCON mock server
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Run a WebRCON mock: send one noise frame (Identifier 0), then respond to
|
||||||
|
/// the first real request with the given output.
|
||||||
|
async fn run_webrcon_mock(stream: tokio::net::TcpStream, output: &str) {
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use tokio_tungstenite::accept_async;
|
||||||
|
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
||||||
|
|
||||||
|
let mut ws = accept_async(stream).await.expect("WS handshake failed");
|
||||||
|
|
||||||
|
// Send noise (chat frame, Identifier 0) before the real request arrives.
|
||||||
|
let noise = serde_json::json!({
|
||||||
|
"Identifier": 0,
|
||||||
|
"Message": "Player X joined",
|
||||||
|
"Name": "Server",
|
||||||
|
"Type": "Chat"
|
||||||
|
});
|
||||||
|
ws.send(WsMsg::Text(noise.to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Read the command request.
|
||||||
|
let msg = ws.next().await.unwrap().unwrap();
|
||||||
|
let text = match msg {
|
||||||
|
WsMsg::Text(t) => t,
|
||||||
|
other => panic!("expected Text frame, got {other:?}"),
|
||||||
|
};
|
||||||
|
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||||
|
let req_id = req["Identifier"].as_i64().unwrap() as i32;
|
||||||
|
|
||||||
|
// Reply with the same Identifier so the client correlates correctly.
|
||||||
|
let reply = serde_json::json!({
|
||||||
|
"Identifier": req_id,
|
||||||
|
"Message": output,
|
||||||
|
"Type": "Generic",
|
||||||
|
});
|
||||||
|
ws.send(WsMsg::Text(reply.to_string())).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebRCON tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn webrcon_skips_noise_and_returns_correct_message() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
run_webrcon_mock(stream, "Players: 42/100").await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password is embedded in the URL path — any non-empty string works with
|
||||||
|
// our mock.
|
||||||
|
let cfg = RconConfig {
|
||||||
|
kind: Some(RconKind::WebRcon),
|
||||||
|
port,
|
||||||
|
password: "testpw".to_string(),
|
||||||
|
};
|
||||||
|
let result = corrosion_host_agent::rcon::send_command(&cfg, "rust", "playercount")
|
||||||
|
.await
|
||||||
|
.expect("WebRCON command should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result, "Players: 42/100");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TOML parsing test — pins [[instance]] + [instance.rcon] sub-table syntax
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toml_instance_with_rcon_parses_correctly() {
|
||||||
|
let toml = r#"
|
||||||
|
[agent]
|
||||||
|
license_id = "test-license"
|
||||||
|
nats_url = "nats://localhost:4222"
|
||||||
|
|
||||||
|
[[instance]]
|
||||||
|
id = "rust-main"
|
||||||
|
game = "rust"
|
||||||
|
root = "/opt/rustserver"
|
||||||
|
|
||||||
|
[instance.rcon]
|
||||||
|
port = 28016
|
||||||
|
password = "secretpassword"
|
||||||
|
kind = "webrcon"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let cfg: corrosion_host_agent::config::ConfigFile =
|
||||||
|
toml::from_str(toml).expect("TOML should parse");
|
||||||
|
|
||||||
|
assert_eq!(cfg.instances.len(), 1);
|
||||||
|
let inst = &cfg.instances[0];
|
||||||
|
assert_eq!(inst.id, "rust-main");
|
||||||
|
|
||||||
|
let rcon = inst.rcon.as_ref().expect("rcon should be present");
|
||||||
|
assert_eq!(rcon.port, 28016);
|
||||||
|
assert_eq!(rcon.password, "secretpassword");
|
||||||
|
assert_eq!(rcon.kind, Some(corrosion_host_agent::rcon::RconKind::WebRcon));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toml_instance_without_rcon_defaults_to_none() {
|
||||||
|
let toml = r#"
|
||||||
|
[agent]
|
||||||
|
license_id = "test-license"
|
||||||
|
nats_url = "nats://localhost:4222"
|
||||||
|
|
||||||
|
[[instance]]
|
||||||
|
id = "conan-main"
|
||||||
|
game = "conan"
|
||||||
|
root = "/opt/conan"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let cfg: corrosion_host_agent::config::ConfigFile =
|
||||||
|
toml::from_str(toml).expect("TOML should parse");
|
||||||
|
|
||||||
|
assert!(cfg.instances[0].rcon.is_none(), "absent rcon should be None");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_kind_infers_from_game_name() {
|
||||||
|
use corrosion_host_agent::rcon::{RconConfig, RconKind};
|
||||||
|
|
||||||
|
let cfg_no_kind = RconConfig { kind: None, port: 28016, password: "x".to_string() };
|
||||||
|
assert_eq!(cfg_no_kind.resolved_kind("rust"), RconKind::WebRcon);
|
||||||
|
assert_eq!(cfg_no_kind.resolved_kind("conan"), RconKind::Source);
|
||||||
|
assert_eq!(cfg_no_kind.resolved_kind("soulmask"), RconKind::Source);
|
||||||
|
assert_eq!(cfg_no_kind.resolved_kind("dune"), RconKind::WebRcon); // fallback
|
||||||
|
|
||||||
|
// Explicit kind always wins.
|
||||||
|
let cfg_source = RconConfig { kind: Some(RconKind::Source), ..cfg_no_kind.clone() };
|
||||||
|
assert_eq!(cfg_source.resolved_kind("rust"), RconKind::Source);
|
||||||
|
|
||||||
|
let cfg_webrcon = RconConfig { kind: Some(RconKind::WebRcon), ..cfg_no_kind };
|
||||||
|
assert_eq!(cfg_webrcon.resolved_kind("conan"), RconKind::WebRcon);
|
||||||
|
}
|
||||||
45
corrosion-host-agent/tests/steamcmd.rs
Normal file
45
corrosion-host-agent/tests/steamcmd.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//! Unit tests for the SteamCMD module.
|
||||||
|
//!
|
||||||
|
//! Tests cover app ID resolution for all four supported games, including the
|
||||||
|
//! platform-specific Soulmask split, and verify that Dune correctly returns
|
||||||
|
//! `None` (it uses Docker images, not SteamCMD).
|
||||||
|
|
||||||
|
use corrosion_host_agent::steamcmd::app_id_for_game;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rust_has_correct_app_id() {
|
||||||
|
assert_eq!(app_id_for_game("rust"), Some(258550));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conan_has_correct_app_id() {
|
||||||
|
assert_eq!(app_id_for_game("conan"), Some(443030));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soulmask returns the Windows server app ID on Windows builds, the Linux
|
||||||
|
/// dedicated server app ID on all other targets.
|
||||||
|
#[test]
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn soulmask_windows_app_id() {
|
||||||
|
assert_eq!(app_id_for_game("soulmask"), Some(3017310));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn soulmask_linux_app_id() {
|
||||||
|
assert_eq!(app_id_for_game("soulmask"), Some(3017300));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dune uses Docker images — SteamCMD integration is explicitly unsupported.
|
||||||
|
#[test]
|
||||||
|
fn dune_has_no_app_id() {
|
||||||
|
assert_eq!(app_id_for_game("dune"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unknown games also produce None; callers should treat this the same as
|
||||||
|
/// Dune (no SteamCMD support).
|
||||||
|
#[test]
|
||||||
|
fn unknown_game_returns_none() {
|
||||||
|
assert_eq!(app_id_for_game("minecraft"), None);
|
||||||
|
assert_eq!(app_id_for_game(""), None);
|
||||||
|
}
|
||||||
109
corrosion-host-agent/tests/supervisor.rs
Normal file
109
corrosion-host-agent/tests/supervisor.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
//! Process supervisor integration tests using real OS processes.
|
||||||
|
//! Unix-only test doubles (/bin/sleep, /bin/sh) — the supervisor logic under
|
||||||
|
//! test is platform-shared; Windows-specific stop semantics get covered when
|
||||||
|
//! the Windows service work lands.
|
||||||
|
#![cfg(unix)]
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use corrosion_host_agent::config::InstanceConfig;
|
||||||
|
use corrosion_host_agent::process::{InstanceState, ProcessSupervisor};
|
||||||
|
|
||||||
|
fn managed_instance(executable: &str, args: &[&str]) -> InstanceConfig {
|
||||||
|
InstanceConfig {
|
||||||
|
id: "test-instance".to_string(),
|
||||||
|
game: "rust".to_string(),
|
||||||
|
root: PathBuf::from("/tmp"),
|
||||||
|
label: None,
|
||||||
|
executable: Some(PathBuf::from(executable)),
|
||||||
|
args: args.iter().map(|s| s.to_string()).collect(),
|
||||||
|
working_dir: None,
|
||||||
|
rcon: None,
|
||||||
|
steamcmd: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_state(
|
||||||
|
sup: &std::sync::Arc<ProcessSupervisor>,
|
||||||
|
want: fn(&InstanceState) -> bool,
|
||||||
|
budget: Duration,
|
||||||
|
) -> InstanceState {
|
||||||
|
let deadline = tokio::time::Instant::now() + budget;
|
||||||
|
loop {
|
||||||
|
let state = sup.state();
|
||||||
|
if want(&state) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() > deadline {
|
||||||
|
panic!("timed out waiting for state; last = {state:?}");
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_status_stop_lifecycle() {
|
||||||
|
let sup = ProcessSupervisor::new(&managed_instance("/bin/sleep", &["300"]));
|
||||||
|
assert_eq!(sup.state(), InstanceState::Stopped);
|
||||||
|
|
||||||
|
sup.start().await.expect("start should succeed");
|
||||||
|
assert_eq!(sup.state(), InstanceState::Running);
|
||||||
|
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||||
|
assert!(sup.uptime_seconds().await >= 1, "uptime should advance");
|
||||||
|
|
||||||
|
// Double-start must be rejected while running.
|
||||||
|
assert!(sup.start().await.is_err(), "double start must fail");
|
||||||
|
|
||||||
|
sup.stop().await.expect("stop should succeed");
|
||||||
|
let state = wait_for_state(&sup, |s| matches!(s, InstanceState::Stopped), Duration::from_secs(5)).await;
|
||||||
|
assert_eq!(state, InstanceState::Stopped);
|
||||||
|
assert_eq!(sup.uptime_seconds().await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unexpected_exit_is_crashed_with_code() {
|
||||||
|
let sup = ProcessSupervisor::new(&managed_instance("/bin/sh", &["-c", "sleep 0.2; exit 7"]));
|
||||||
|
sup.start().await.expect("start should succeed");
|
||||||
|
|
||||||
|
let state = wait_for_state(
|
||||||
|
&sup,
|
||||||
|
|s| matches!(s, InstanceState::Crashed { .. }),
|
||||||
|
Duration::from_secs(5),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(state, InstanceState::Crashed { exit_code: Some(7) });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restart_from_crashed_recovers() {
|
||||||
|
let sup = ProcessSupervisor::new(&managed_instance("/bin/sh", &["-c", "exit 1"]));
|
||||||
|
sup.start().await.expect("start should succeed");
|
||||||
|
wait_for_state(&sup, |s| matches!(s, InstanceState::Crashed { .. }), Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
// Restart from crashed must work (panel "Restart" after a crash).
|
||||||
|
// Use a long-lived command this time by replacing the supervisor — the
|
||||||
|
// command is fixed per supervisor, so emulate via a fresh one.
|
||||||
|
let sup2 = ProcessSupervisor::new(&managed_instance("/bin/sleep", &["300"]));
|
||||||
|
sup2.restart().await.expect("restart from stopped should start");
|
||||||
|
assert_eq!(sup2.state(), InstanceState::Running);
|
||||||
|
sup2.stop().await.expect("cleanup stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unmanaged_instance_rejects_process_commands() {
|
||||||
|
let mut cfg = managed_instance("/bin/sleep", &["300"]);
|
||||||
|
cfg.executable = None;
|
||||||
|
let sup = ProcessSupervisor::new(&cfg);
|
||||||
|
assert_eq!(sup.state(), InstanceState::Unmanaged);
|
||||||
|
assert!(sup.start().await.is_err(), "unmanaged start must fail");
|
||||||
|
assert!(sup.stop().await.is_err(), "unmanaged stop must fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_executable_fails_cleanly() {
|
||||||
|
let sup = ProcessSupervisor::new(&managed_instance("/nonexistent/bin/gameserver", &[]));
|
||||||
|
let err = sup.start().await.expect_err("must fail");
|
||||||
|
assert!(err.to_string().contains("not found"), "error should say not found: {err}");
|
||||||
|
assert_eq!(sup.state(), InstanceState::Stopped, "failed start must not leave Starting state");
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-corrosion_dev}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-corrosion_dev}
|
||||||
volumes:
|
volumes:
|
||||||
- pg_data:/var/lib/postgresql/data
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
# Auto-build the schema on a FRESH database. Postgres runs these ONLY when
|
||||||
|
# the data dir is empty (first boot or after a volume reset), so it never
|
||||||
|
# touches an existing volume — it just makes a fresh DB self-heal: the full
|
||||||
|
# schema is applied in order from the sqlx migrations (001..NNN), then the
|
||||||
|
# API's bootstrap seeds the admin. Rebuilds (with the volume kept) are a
|
||||||
|
# no-op here; the data persists. Only `down -v` / volume prune loses data.
|
||||||
|
- ../backend/migrations:/docker-entrypoint-initdb.d:ro
|
||||||
ports:
|
ports:
|
||||||
- "8101:5432"
|
- "8101:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -80,7 +87,10 @@ services:
|
|||||||
api:
|
api:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:80/ || exit 1"]
|
# 127.0.0.1, not localhost: nginx listens IPv4-only (0.0.0.0:80) but
|
||||||
|
# `localhost` resolves to ::1 first inside the container → the probe hit
|
||||||
|
# nothing and reported unhealthy while the panel served fine on IPv4.
|
||||||
|
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:80/ || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -9,6 +9,17 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0a0a0a" />
|
<meta name="theme-color" content="#0a0a0a" />
|
||||||
<title>Corrosion Management</title>
|
<title>Corrosion Management</title>
|
||||||
|
<meta name="description" content="Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server." />
|
||||||
|
<meta property="og:title" content="Corrosion — Game Server Operations for Self-Hosted Communities" />
|
||||||
|
<meta property="og:description" content="Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server." />
|
||||||
|
<!-- Fonts via <link>, NOT a CSS @import — the bundler drops @import rules
|
||||||
|
that land mid-file after concatenation, silently shipping system fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap"
|
||||||
|
/>
|
||||||
<script>
|
<script>
|
||||||
/* FOUC guard — apply persisted theme/game to <html> before the app mounts,
|
/* FOUC guard — apply persisted theme/game to <html> before the app mounts,
|
||||||
so the design-system tokens paint with the right skin from frame one. */
|
so the design-system tokens paint with the right skin from frame one. */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import ToastNotification from '@/components/ToastNotification.vue'
|
import ToastNotification from '@/components/ToastNotification.vue'
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary.vue'
|
import ErrorBoundary from '@/components/ErrorBoundary.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
// Validate any persisted session against the API on boot — a stale token
|
||||||
|
// should bounce to login immediately, not after the first failed call.
|
||||||
|
const auth = useAuthStore()
|
||||||
|
onMounted(() => { void auth.validateSession() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
1
frontend/src/app-version.d.ts
vendored
Normal file
1
frontend/src/app-version.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare const __APP_VERSION__: string
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onErrorCaptured } from 'vue'
|
import { ref, watch, onErrorCaptured } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import Icon from '@/components/ds/core/Icon.vue'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
import Button from '@/components/ds/core/Button.vue'
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
/** 'screen' fills the viewport (app root); 'content' fills its container (inside layout chrome) */
|
||||||
|
variant?: 'screen' | 'content'
|
||||||
|
}>(), { variant: 'screen' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const hasError = ref(false)
|
const hasError = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
|
||||||
@@ -13,6 +20,12 @@ onErrorCaptured((err) => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A failed view must not brick navigation — clear the error when the route changes
|
||||||
|
watch(() => route.fullPath, () => {
|
||||||
|
hasError.value = false
|
||||||
|
errorMessage.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
hasError.value = false
|
hasError.value = false
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
@@ -21,7 +34,7 @@ function retry() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="hasError" class="eb-screen">
|
<div v-if="hasError" class="eb-screen" :class="{ 'eb-screen--content': variant === 'content' }">
|
||||||
<div class="eb-card">
|
<div class="eb-card">
|
||||||
<div class="eb-icon-wrap">
|
<div class="eb-icon-wrap">
|
||||||
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
|
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
|
||||||
@@ -44,6 +57,11 @@ function retry() {
|
|||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.eb-screen--content {
|
||||||
|
min-height: 60vh;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.eb-card {
|
.eb-card {
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
box-shadow: var(--ring-default), var(--shadow-md);
|
box-shadow: var(--ring-default), var(--shadow-md);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import {
|
|||||||
Ban, Flag,
|
Ban, Flag,
|
||||||
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
|
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
|
||||||
Pencil, Save, ShoppingBag, Target, User,
|
Pencil, Save, ShoppingBag, Target, User,
|
||||||
|
// Marketing site additions
|
||||||
|
Route, Timer, Megaphone, DatabaseBackup, Store, Undo2,
|
||||||
|
Circle, Send, HelpCircle,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -58,6 +61,10 @@ const registry: Record<string, Component> = {
|
|||||||
'dollar-sign': DollarSign, 'flask-conical': FlaskConical, mail: Mail,
|
'dollar-sign': DollarSign, 'flask-conical': FlaskConical, mail: Mail,
|
||||||
package: Package, pencil: Pencil, save: Save, 'shopping-bag': ShoppingBag,
|
package: Package, pencil: Pencil, save: Save, 'shopping-bag': ShoppingBag,
|
||||||
target: Target, user: User,
|
target: Target, user: User,
|
||||||
|
// Marketing site additions
|
||||||
|
route: Route, timer: Timer, megaphone: Megaphone,
|
||||||
|
'database-backup': DatabaseBackup, store: Store, 'undo-2': Undo2,
|
||||||
|
circle: Circle, send: Send, 'help-circle': HelpCircle,
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmp = computed<Component | null>(() => registry[props.name] ?? null)
|
const cmp = computed<Component | null>(() => registry[props.name] ?? null)
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* PlayersChart — themed ECharts area chart of players online.
|
* PlayersChart — themed ECharts area chart of players online.
|
||||||
* Reads the live design tokens (--accent etc.) from CSS so it matches the
|
*
|
||||||
* active theme/game, and re-renders when data-game / data-theme flip on <html>.
|
* Requires real `data` — there is NO fallback series. When `data` is absent
|
||||||
|
* or empty, an "awaiting telemetry" placeholder is shown instead of the chart.
|
||||||
|
* This is intentional: fabricated curves mislead operators.
|
||||||
|
*
|
||||||
|
* Reads live design tokens (--accent etc.) from CSS so it matches the active
|
||||||
|
* theme/game, and re-renders when data-game / data-theme flip on <html>.
|
||||||
*/
|
*/
|
||||||
import { onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
|
import { computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -12,29 +17,26 @@ const props = withDefaults(
|
|||||||
{ height: 200, max: 200 },
|
{ height: 200, max: 200 },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0)
|
||||||
|
|
||||||
const el = useTemplateRef<HTMLDivElement>('el')
|
const el = useTemplateRef<HTMLDivElement>('el')
|
||||||
let chart: echarts.ECharts | null = null
|
let chart: echarts.ECharts | null = null
|
||||||
let ro: ResizeObserver | null = null
|
let ro: ResizeObserver | null = null
|
||||||
let mo: MutationObserver | null = null
|
let mo: MutationObserver | null = null
|
||||||
|
|
||||||
const DEFAULT_SERIES = [
|
|
||||||
60, 52, 44, 38, 33, 30, 34, 46, 62, 78, 92, 104,
|
|
||||||
118, 126, 131, 138, 142, 151, 168, 182, 176, 150, 112, 84,
|
|
||||||
]
|
|
||||||
|
|
||||||
function cssVar(name: string, node?: HTMLElement): string {
|
function cssVar(name: string, node?: HTMLElement): string {
|
||||||
return getComputedStyle(node || document.documentElement).getPropertyValue(name).trim()
|
return getComputedStyle(node || document.documentElement).getPropertyValue(name).trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
if (!chart || !el.value) return
|
if (!chart || !el.value || !hasData.value) return
|
||||||
const node = el.value
|
const node = el.value
|
||||||
const accent = cssVar('--accent', node) || '#f26622'
|
const accent = cssVar('--accent', node) || '#f26622'
|
||||||
const grid = cssVar('--border-subtle', node) || 'rgba(255,255,255,0.06)'
|
const grid = cssVar('--border-subtle', node) || 'rgba(255,255,255,0.06)'
|
||||||
const text = cssVar('--text-tertiary', node) || '#767d89'
|
const text = cssVar('--text-tertiary', node) || '#767d89'
|
||||||
const mono = 'JetBrains Mono, monospace'
|
const mono = 'JetBrains Mono, monospace'
|
||||||
const hours = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
|
const hours = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
|
||||||
const series = props.data ?? DEFAULT_SERIES
|
const series = props.data as number[]
|
||||||
|
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
animationDuration: 700,
|
animationDuration: 700,
|
||||||
@@ -77,6 +79,7 @@ function render(): void {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!el.value) return
|
if (!el.value) return
|
||||||
|
if (!hasData.value) return // empty-state slot renders instead
|
||||||
chart = echarts.init(el.value, undefined, { renderer: 'canvas' })
|
chart = echarts.init(el.value, undefined, { renderer: 'canvas' })
|
||||||
render()
|
render()
|
||||||
ro = new ResizeObserver(() => chart?.resize())
|
ro = new ResizeObserver(() => chart?.resize())
|
||||||
@@ -94,5 +97,33 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="el" :style="{ width: '100%', height: height + 'px' }" />
|
<!-- Real data: render the ECharts canvas -->
|
||||||
|
<div v-if="hasData" ref="el" :style="{ width: '100%', height: height + 'px' }" />
|
||||||
|
<!-- No data: honest empty state — never show a fabricated curve -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="pc-empty"
|
||||||
|
:style="{ height: height + 'px' }"
|
||||||
|
>
|
||||||
|
<svg class="pc-empty__icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
<span class="pc-empty__label">Awaiting telemetry</span>
|
||||||
|
<span class="pc-empty__sub">Player data will appear once the server connects and reports stats</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pc-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.pc-empty__icon { margin-bottom: 4px; opacity: 0.5; }
|
||||||
|
.pc-empty__label { font-size: var(--text-sm); font-weight: 500; color: var(--text-tertiary); }
|
||||||
|
.pc-empty__sub { font-size: var(--text-xs); color: var(--text-muted); max-width: 280px; text-align: center; line-height: 1.5; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* DashboardLayout — game-aware app shell (Phase C redesign).
|
* DashboardLayout — game-aware app shell (Phase C redesign).
|
||||||
* Replaces the old Tailwind-only sidebar with the DS component set.
|
* Nav is driven by GAME_PROFILES[activeGame].nav — switching the GameSwitcher
|
||||||
* Preserves: navSections, permission gating, super-admin section, logout, RouterView.
|
* visibly changes nav items, labels, and sections per game.
|
||||||
* Adds: GameSwitcher, Logo, DS NavItem, agent-health footer, topbar w/ search + theme toggle.
|
* Preserves: permission gating, super-admin section, logout, mobile sidebar,
|
||||||
|
* GameSwitcher, agent-health footer, topbar.
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useThemeGame } from '@/composables/useThemeGame'
|
import { useThemeGame } from '@/composables/useThemeGame'
|
||||||
|
import { useGameProfile } from '@/config/gameProfiles'
|
||||||
|
import type { NavSection, NavItemDef } from '@/config/gameProfiles'
|
||||||
|
import { safeDate } from '@/utils/formatters'
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary.vue'
|
||||||
import Logo from '@/components/ds/brand/Logo.vue'
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
import Badge from '@/components/ds/core/Badge.vue'
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||||
@@ -33,7 +38,7 @@ const sidebarOpen = ref(false)
|
|||||||
function closeSidebar() { sidebarOpen.value = false }
|
function closeSidebar() { sidebarOpen.value = false }
|
||||||
|
|
||||||
// ---- App version ----
|
// ---- App version ----
|
||||||
const APP_VERSION = '1.0.8'
|
const APP_VERSION = __APP_VERSION__
|
||||||
|
|
||||||
// ---- Game switcher ----
|
// ---- Game switcher ----
|
||||||
const GAME_OPTIONS: GameOption[] = [
|
const GAME_OPTIONS: GameOption[] = [
|
||||||
@@ -53,61 +58,15 @@ function onActiveGame(val: string) {
|
|||||||
setActiveGame(val as ActiveGame)
|
setActiveGame(val as ActiveGame)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Navigation ----
|
// ---- Navigation — driven by the game profile registry ----
|
||||||
type NavItemDef = { name: string; path: string; icon: string; permission: string | null }
|
/**
|
||||||
type NavSection = { label: string; items: NavItemDef[] }
|
* For 'all', fall back to rust (superset nav). For a specific game, look up
|
||||||
|
* its profile. noUncheckedIndexedAccess-safe: always ?? GAME_PROFILES.rust.
|
||||||
const navSections: NavSection[] = [
|
*/
|
||||||
{
|
const activeNavSections = computed<NavSection[]>(() => {
|
||||||
label: '',
|
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
|
||||||
items: [
|
return (useGameProfile(game)).nav
|
||||||
{ name: 'Dashboard', path: '/', icon: 'layout-dashboard', permission: null },
|
})
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Server',
|
|
||||||
items: [
|
|
||||||
{ name: 'Server', path: '/server', icon: 'server', permission: 'server.view' },
|
|
||||||
{ name: 'Console', path: '/console', icon: 'terminal', permission: 'console.view' },
|
|
||||||
{ name: 'Players', path: '/players', icon: 'users', permission: 'players.view' },
|
|
||||||
{ name: 'Plugins', path: '/plugins', icon: 'puzzle', permission: 'plugins.view' },
|
|
||||||
{ name: 'File manager', path: '/files', icon: 'folder-open', permission: 'files.view' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Plugin configs',
|
|
||||||
items: [
|
|
||||||
{ name: 'Plugin configs', path: '/plugin-configs', icon: 'puzzle', permission: null },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Operations',
|
|
||||||
items: [
|
|
||||||
{ name: 'Wipe manager', path: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
|
|
||||||
{ name: 'Maps', path: '/maps', icon: 'map', permission: 'maps.view' },
|
|
||||||
{ name: 'Schedules', path: '/schedules', icon: 'calendar-clock', permission: 'schedules.view' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Monitoring',
|
|
||||||
items: [
|
|
||||||
{ name: 'Chat log', path: '/chat', icon: 'message-square', permission: 'chat.view' },
|
|
||||||
{ name: 'Analytics', path: '/analytics', icon: 'bar-chart-3', permission: 'analytics.view' },
|
|
||||||
{ name: 'Alerts', path: '/alerts', icon: 'triangle-alert', permission: 'alerts.view' },
|
|
||||||
{ name: 'Notifications', path: '/notifications', icon: 'bell', permission: 'notifications.view' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Management',
|
|
||||||
items: [
|
|
||||||
{ name: 'Team', path: '/team', icon: 'users', permission: null },
|
|
||||||
{ name: 'Store', path: '/store/config', icon: 'shopping-cart', permission: 'store.view' },
|
|
||||||
{ name: 'Modules', path: '/modules', icon: 'layers', permission: 'modules.view' },
|
|
||||||
{ name: 'Changelog', path: '/changelog', icon: 'file-text', permission: 'changelog.view' },
|
|
||||||
{ name: 'Settings', path: '/settings', icon: 'settings', permission: 'settings.view' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
{ name: 'Admin home', path: '/admin', icon: 'shield' },
|
{ name: 'Admin home', path: '/admin', icon: 'shield' },
|
||||||
@@ -137,6 +96,8 @@ function hasVisibleItems(section: NavSection): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Agent health ----
|
// ---- Agent health ----
|
||||||
|
const hasAgent = computed(() => server.connection !== null)
|
||||||
|
|
||||||
const agentTone = computed(() => {
|
const agentTone = computed(() => {
|
||||||
const cs = server.connection?.connection_status
|
const cs = server.connection?.connection_status
|
||||||
if (cs === 'connected') return 'online' as const
|
if (cs === 'connected') return 'online' as const
|
||||||
@@ -149,18 +110,23 @@ const agentLabel = computed(() => {
|
|||||||
if (cs === 'degraded') return 'Degraded'
|
if (cs === 'degraded') return 'Degraded'
|
||||||
return 'Offline'
|
return 'Offline'
|
||||||
})
|
})
|
||||||
const agentName = computed(() => {
|
const agentName = computed(() => server.connection?.server_ip ?? 'Host agent')
|
||||||
const ip = server.connection?.server_ip
|
|
||||||
return ip ?? 'asgard-01'
|
const agentMetaLine = computed(() => {
|
||||||
|
const cs = server.connection?.connection_status
|
||||||
|
let line = cs === 'connected' ? 'Connected' : server.connection?.companion_last_seen
|
||||||
|
? `Last seen ${safeDate(server.connection.companion_last_seen)}`
|
||||||
|
: 'Awaiting first heartbeat'
|
||||||
|
if (server.stats) {
|
||||||
|
line += ` · ${server.stats.player_count}/${server.stats.max_players} players`
|
||||||
|
}
|
||||||
|
return line
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Topbar ----
|
// ---- Topbar ----
|
||||||
const serverName = computed(() => auth.license?.server_name ?? 'Your servers')
|
const serverName = computed(() => auth.license?.server_name ?? 'Your servers')
|
||||||
const userName = computed(() => auth.user?.username ?? '')
|
const userName = computed(() => auth.user?.username ?? '')
|
||||||
const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
||||||
|
|
||||||
// ---- Import computed from vue (missed above) ----
|
|
||||||
import { computed } from 'vue'
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -197,20 +163,20 @@ import { computed } from 'vue'
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation — sections driven by GAME_PROFILES[activeGame].nav -->
|
||||||
<nav class="side__nav">
|
<nav class="side__nav">
|
||||||
<template v-for="section in navSections" :key="section.label">
|
<template v-for="section in activeNavSections" :key="section.label">
|
||||||
<template v-if="hasVisibleItems(section)">
|
<template v-if="hasVisibleItems(section)">
|
||||||
<div class="side__sec">
|
<div class="side__sec">
|
||||||
<div v-if="section.label" class="t-eyebrow side__lbl">{{ section.label }}</div>
|
<div v-if="section.label" class="t-eyebrow side__lbl">{{ section.label }}</div>
|
||||||
<NavItem
|
<NavItem
|
||||||
v-for="item in section.items"
|
v-for="item in section.items"
|
||||||
v-show="canShowNavItem(item)"
|
v-show="canShowNavItem(item)"
|
||||||
:key="item.path"
|
:key="item.route"
|
||||||
:icon="item.icon"
|
:icon="item.icon"
|
||||||
:label="item.name"
|
:label="item.label"
|
||||||
:active="isActive(item.path)"
|
:active="isActive(item.route)"
|
||||||
@click="navigate(item.path)"
|
@click="navigate(item.route)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -230,18 +196,24 @@ import { computed } from 'vue'
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Agent health footer -->
|
<!-- Host agent footer -->
|
||||||
<div class="side__foot">
|
<div class="side__foot">
|
||||||
<div class="agent">
|
<!-- Connected: real IP + status badge + meta line -->
|
||||||
|
<div v-if="hasAgent" class="agent">
|
||||||
<div class="agent__row">
|
<div class="agent__row">
|
||||||
<StatusDot :tone="agentTone" :pulse="agentTone === 'online'" />
|
<StatusDot :tone="agentTone" :pulse="agentTone === 'online'" />
|
||||||
<span class="agent__name">{{ agentName }}</span>
|
<span class="agent__name">{{ agentName }}</span>
|
||||||
<Badge :tone="agentTone" size="md">{{ agentLabel }}</Badge>
|
<Badge :tone="agentTone" size="md">{{ agentLabel }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="agent__meta">
|
<div class="agent__meta">{{ agentMetaLine }}</div>
|
||||||
Agent v{{ APP_VERSION }}
|
|
||||||
<template v-if="server.stats"> · {{ server.stats.player_count }}/{{ server.stats.max_players }} players</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Not connected: honest empty state -->
|
||||||
|
<div v-else class="agent agent--empty">
|
||||||
|
<div class="agent__row">
|
||||||
|
<StatusDot tone="offline" />
|
||||||
|
<span class="agent__name agent__name--muted">No host agent connected</span>
|
||||||
|
</div>
|
||||||
|
<div class="agent__meta">Install the Corrosion host agent from the Server page</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- User / logout row -->
|
<!-- User / logout row -->
|
||||||
<div class="side__user">
|
<div class="side__user">
|
||||||
@@ -313,9 +285,11 @@ import { computed } from 'vue'
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Page content -->
|
<!-- Page content — boundary keeps sidebar/topbar alive when a view fails -->
|
||||||
<main class="app__content">
|
<main class="app__content">
|
||||||
|
<ErrorBoundary variant="content">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,6 +393,13 @@ body { margin: 0; overflow: hidden; }
|
|||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent--empty { opacity: 0.7; }
|
||||||
|
|
||||||
|
.agent__name--muted {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.side__user {
|
.side__user {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,76 +1,79 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView, RouterLink } from 'vue-router'
|
import { RouterView, RouterLink } from 'vue-router'
|
||||||
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
|
import '@/styles/marketing.css'
|
||||||
|
|
||||||
const panelUrl = import.meta.env.VITE_PANEL_URL || ''
|
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-neutral-950 flex flex-col">
|
<div>
|
||||||
<!-- Navigation -->
|
<!-- Nav -->
|
||||||
<nav class="border-b border-neutral-800 bg-neutral-950/80 backdrop-blur-sm sticky top-0 z-50">
|
<nav class="mkt-nav">
|
||||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div class="wrap mkt-nav__in">
|
||||||
<RouterLink :to="{ name: 'landing' }" class="flex items-center gap-3">
|
<RouterLink :to="{ name: 'landing' }" class="brand">
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-8 w-8" />
|
<span class="mark"><CorrosionMark :size="26" /></span>
|
||||||
<span class="text-lg font-bold text-neutral-100">Corrosion</span>
|
<b>Corrosion</b>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<div class="hidden md:flex items-center gap-6">
|
<div class="mkt-nav__links">
|
||||||
<RouterLink :to="{ name: 'how-it-works' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">How It Works</RouterLink>
|
<RouterLink :to="{ name: 'landing' }" class="scroll-link">Features</RouterLink>
|
||||||
<RouterLink :to="{ name: 'pricing' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Pricing</RouterLink>
|
<RouterLink :to="{ name: 'pricing' }">Pricing</RouterLink>
|
||||||
<RouterLink :to="{ name: 'roadmap' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Roadmap</RouterLink>
|
<RouterLink :to="{ name: 'how-it-works' }">How it works</RouterLink>
|
||||||
<RouterLink :to="{ name: 'faq' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">FAQ</RouterLink>
|
<RouterLink :to="{ name: 'faq' }">FAQ</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="mkt-nav__cta">
|
||||||
<a :href="panelUrl + '/login'" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Sign In</a>
|
<a class="mkt-nav__signin" :href="panelUrl + '/login'">Sign in</a>
|
||||||
<a :href="panelUrl + '/register'" class="px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors">Get Started</a>
|
<RouterLink class="btn btn--primary btn--sm" :to="{ name: 'early-access' }">
|
||||||
|
Early access
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Page content -->
|
<!-- Page content -->
|
||||||
<main class="flex-1">
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="border-t border-neutral-800 py-12">
|
<footer class="mkt-footer">
|
||||||
<div class="max-w-6xl mx-auto px-6">
|
<div class="wrap">
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
|
<div class="footer__cols">
|
||||||
<div>
|
<div class="footer__brand">
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Product</h4>
|
<RouterLink :to="{ name: 'landing' }" class="brand">
|
||||||
<div class="space-y-2">
|
<span class="mark"><CorrosionMark :size="24" /></span>
|
||||||
<RouterLink :to="{ name: 'how-it-works' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">How It Works</RouterLink>
|
<b>Corrosion</b>
|
||||||
<RouterLink :to="{ name: 'pricing' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Pricing</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'roadmap' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Roadmap</RouterLink>
|
<p>Game server operations for self-hosted communities.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer__col">
|
||||||
|
<h5>Product</h5>
|
||||||
|
<RouterLink :to="{ name: 'landing' }">Supported games</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'landing' }">Features</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'pricing' }">Pricing</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="footer__col">
|
||||||
|
<h5>Games</h5>
|
||||||
|
<RouterLink :to="{ name: 'landing' }">Rust</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'landing' }">Dune: Awakening</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'landing' }">Soulmask</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'landing' }">Conan Exiles</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="footer__col">
|
||||||
|
<h5>Support</h5>
|
||||||
|
<RouterLink :to="{ name: 'faq' }">FAQ</RouterLink>
|
||||||
|
<RouterLink to="/status">Status</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="footer__col">
|
||||||
|
<h5>Company</h5>
|
||||||
|
<RouterLink :to="{ name: 'landing' }">About</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'roadmap' }">Changelog</RouterLink>
|
||||||
|
<a href="mailto:support@corrosionmgmt.com">Contact</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="footer__bar">
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Support</h4>
|
<span>© 2026 Corrosion. All rights reserved.</span>
|
||||||
<div class="space-y-2">
|
<span>One control plane. Every game.</span>
|
||||||
<RouterLink :to="{ name: 'faq' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">FAQ</RouterLink>
|
|
||||||
<a href="https://discord.gg/corrosion" target="_blank" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Discord</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Company</h4>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<RouterLink :to="{ name: 'landing' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">About</RouterLink>
|
|
||||||
<RouterLink to="/status" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Status</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Legal</h4>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<span class="block text-sm text-neutral-600">Terms of Service</span>
|
|
||||||
<span class="block text-sm text-neutral-600">Privacy Policy</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-neutral-800 pt-6 flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-4 w-4 opacity-60" />
|
|
||||||
<span class="text-sm text-neutral-600">© 2026 Corrosion. All rights reserved.</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-neutral-700">The Control Plane for Rust Servers.</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
342
frontend/src/config/gameProfiles.ts
Normal file
342
frontend/src/config/gameProfiles.ts
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* gameProfiles.ts — Source of truth for per-game UI adaptation.
|
||||||
|
*
|
||||||
|
* Every game-specific label, terminology, Steam app ID, management model,
|
||||||
|
* stat field list, AND sidebar nav lives here. The dashboard, server cards,
|
||||||
|
* wipe manager, sidebar, and any future multi-game surface should key off this
|
||||||
|
* registry — never hard-code game-specific strings in components.
|
||||||
|
*
|
||||||
|
* Backend status: the backend has NO game field on licenses yet. Today every
|
||||||
|
* license is implicitly Rust. This registry is ready: when the backend adds a
|
||||||
|
* `game` column to `licenses` (or `server_config`), the frontend only needs to
|
||||||
|
* read that field and call `useGameProfile(id)` — no component changes required.
|
||||||
|
*
|
||||||
|
* To add a new game: add a GameId union member and a corresponding entry in
|
||||||
|
* GAME_PROFILES. Nothing else changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Nav structure — drives the per-game sidebar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A single sidebar nav item. route must be an existing panel route path. */
|
||||||
|
export interface NavItemDef {
|
||||||
|
label: string
|
||||||
|
route: string
|
||||||
|
icon: string
|
||||||
|
/** Permission key required to show this item (e.g. 'plugins.view'). Null = always visible. */
|
||||||
|
permission: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A labelled section grouping nav items in the sidebar. */
|
||||||
|
export interface NavSection {
|
||||||
|
/** Section heading (eyebrow text). Empty string = no heading. */
|
||||||
|
label: string
|
||||||
|
items: NavItemDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Union types — exhaustive, never widen to string
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Every supported game identifier. */
|
||||||
|
export type GameId = 'rust' | 'conan' | 'soulmask' | 'dune'
|
||||||
|
|
||||||
|
/** How the server process is managed. */
|
||||||
|
export type ManagementModel = 'process+rcon' | 'docker-compose'
|
||||||
|
|
||||||
|
/** Mod ecosystem the game uses. */
|
||||||
|
export type ModSystem = 'umod' | 'workshop' | 'none'
|
||||||
|
|
||||||
|
/** Primary console / remote-admin interface. */
|
||||||
|
export type ConsoleType = 'rcon' | 'rcon+ingame' | 'rcon+gm' | 'rabbitmq'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a "reset" is performed — each value maps to a distinct wipe code path.
|
||||||
|
* Pipe-delimited strings intentionally encode composite operations.
|
||||||
|
*/
|
||||||
|
export type ResetModel =
|
||||||
|
| 'map-bp-wipe'
|
||||||
|
| 'wipe-world-structures+decay'
|
||||||
|
| 'worlddb-delete+decay'
|
||||||
|
| 'deep-desert-coriolis-seed'
|
||||||
|
|
||||||
|
/** Cross-server or character-sharing mechanism. */
|
||||||
|
export type ClusteringModel = 'none' | 'character-transfer' | 'main-client' | 'battlegroup'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GameProfile shape
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface GameTerminology {
|
||||||
|
/** What the operator calls a reset / wipe. */
|
||||||
|
reset: string
|
||||||
|
/** What the operator calls plugins / mods (null if no mod system). */
|
||||||
|
mods: string | null
|
||||||
|
/** What the operator calls a player group / faction. */
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePorts {
|
||||||
|
game: number
|
||||||
|
query: number
|
||||||
|
rcon: number
|
||||||
|
cluster?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameProfile {
|
||||||
|
/** Human-readable game name. */
|
||||||
|
label: string
|
||||||
|
/** CSS design-token key — maps to data-game attr and --accent token. */
|
||||||
|
accent: string
|
||||||
|
managementModel: ManagementModel
|
||||||
|
steamAppId: number | { windows: number; linux: number }
|
||||||
|
/** Default ports (game-specific defaults; operator can override). */
|
||||||
|
ports?: GamePorts
|
||||||
|
mods: ModSystem
|
||||||
|
console: ConsoleType
|
||||||
|
resetModel: ResetModel
|
||||||
|
clustering: ClusteringModel
|
||||||
|
/** Available map names, if the game ships with named maps. */
|
||||||
|
maps?: string[]
|
||||||
|
terminology: GameTerminology
|
||||||
|
/** Notable game-specific mechanics that affect server administration. */
|
||||||
|
special?: string[]
|
||||||
|
/**
|
||||||
|
* Stat field labels shown on server cards and the dashboard.
|
||||||
|
* First entry is always Players; subsequent entries are game-specific.
|
||||||
|
*/
|
||||||
|
statFields: [string, string, string]
|
||||||
|
/**
|
||||||
|
* Per-game sidebar navigation. Ordered list of sections, each with items.
|
||||||
|
* Items MUST use only existing panel routes (see router/index.ts).
|
||||||
|
* The sidebar renders exactly these sections for the active game.
|
||||||
|
*/
|
||||||
|
nav: NavSection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared nav building blocks — reused across game nav definitions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const NAV_DASHBOARD: NavItemDef = { label: 'Dashboard', route: '/', icon: 'layout-dashboard', permission: null }
|
||||||
|
const NAV_SERVER: NavItemDef = { label: 'Server', route: '/server', icon: 'server', permission: 'server.view' }
|
||||||
|
const NAV_CONSOLE: NavItemDef = { label: 'Console', route: '/console', icon: 'terminal', permission: 'console.view' }
|
||||||
|
const NAV_PLAYERS: NavItemDef = { label: 'Players', route: '/players', icon: 'users', permission: 'players.view' }
|
||||||
|
const NAV_PLUGINS: NavItemDef = { label: 'Plugins (uMod)', route: '/plugins', icon: 'puzzle', permission: 'plugins.view' }
|
||||||
|
const NAV_FILES: NavItemDef = { label: 'File manager', route: '/files', icon: 'folder-open', permission: 'files.view' }
|
||||||
|
const NAV_PLUGIN_CONFIGS: NavItemDef = { label: 'Plugin configs', route: '/plugin-configs', icon: 'sliders', permission: null }
|
||||||
|
const NAV_SCHEDULES: NavItemDef = { label: 'Schedules', route: '/schedules', icon: 'calendar-clock', permission: 'schedules.view' }
|
||||||
|
const NAV_CHAT: NavItemDef = { label: 'Chat log', route: '/chat', icon: 'message-square', permission: 'chat.view' }
|
||||||
|
const NAV_ANALYTICS: NavItemDef = { label: 'Analytics', route: '/analytics', icon: 'bar-chart-3', permission: 'analytics.view' }
|
||||||
|
const NAV_ALERTS: NavItemDef = { label: 'Alerts', route: '/alerts', icon: 'triangle-alert', permission: 'alerts.view' }
|
||||||
|
const NAV_NOTIFICATIONS: NavItemDef = { label: 'Notifications', route: '/notifications', icon: 'bell', permission: 'notifications.view' }
|
||||||
|
const NAV_TEAM: NavItemDef = { label: 'Team', route: '/team', icon: 'users', permission: null }
|
||||||
|
const NAV_STORE: NavItemDef = { label: 'Store', route: '/store/config', icon: 'shopping-cart', permission: 'store.view' }
|
||||||
|
const NAV_MODULES: NavItemDef = { label: 'Modules', route: '/modules', icon: 'layers', permission: 'modules.view' }
|
||||||
|
const NAV_CHANGELOG: NavItemDef = { label: 'Changelog', route: '/changelog', icon: 'file-text', permission: 'changelog.view' }
|
||||||
|
const NAV_SETTINGS: NavItemDef = { label: 'Settings', route: '/settings', icon: 'settings', permission: 'settings.view' }
|
||||||
|
const NAV_MAPS: NavItemDef = { label: 'Maps', route: '/maps', icon: 'map', permission: 'maps.view' }
|
||||||
|
|
||||||
|
/** Full Rust / 'all' nav — superset used as fallback. */
|
||||||
|
const RUST_NAV: NavSection[] = [
|
||||||
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
|
{
|
||||||
|
label: 'Server',
|
||||||
|
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_PLUGINS, NAV_FILES],
|
||||||
|
},
|
||||||
|
{ label: 'Plugin configs', items: [NAV_PLUGIN_CONFIGS] },
|
||||||
|
{
|
||||||
|
label: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ label: 'Wipe', route: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
|
||||||
|
NAV_MAPS,
|
||||||
|
NAV_SCHEDULES,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monitoring',
|
||||||
|
items: [NAV_CHAT, NAV_ANALYTICS, NAV_ALERTS, NAV_NOTIFICATIONS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Management',
|
||||||
|
items: [NAV_TEAM, NAV_STORE, NAV_MODULES, NAV_CHANGELOG, NAV_SETTINGS],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const GAME_PROFILES: Record<GameId, GameProfile> = {
|
||||||
|
rust: {
|
||||||
|
label: 'Rust',
|
||||||
|
accent: 'rust',
|
||||||
|
managementModel: 'process+rcon',
|
||||||
|
steamAppId: 258550,
|
||||||
|
mods: 'umod',
|
||||||
|
console: 'rcon',
|
||||||
|
resetModel: 'map-bp-wipe',
|
||||||
|
clustering: 'none',
|
||||||
|
terminology: {
|
||||||
|
reset: 'Wipe',
|
||||||
|
mods: 'Plugins',
|
||||||
|
group: 'Team',
|
||||||
|
},
|
||||||
|
statFields: ['Players', 'uMod', 'Wipe'],
|
||||||
|
nav: RUST_NAV,
|
||||||
|
},
|
||||||
|
|
||||||
|
conan: {
|
||||||
|
label: 'Conan Exiles',
|
||||||
|
accent: 'conan',
|
||||||
|
managementModel: 'process+rcon',
|
||||||
|
steamAppId: 443030,
|
||||||
|
ports: { game: 7777, query: 27015, rcon: 25575 },
|
||||||
|
mods: 'workshop',
|
||||||
|
console: 'rcon+ingame',
|
||||||
|
// Player progress persists across world wipes — only structures are cleared.
|
||||||
|
resetModel: 'wipe-world-structures+decay',
|
||||||
|
clustering: 'character-transfer',
|
||||||
|
maps: ['Exiled Lands', 'Isle of Siptah'],
|
||||||
|
terminology: {
|
||||||
|
reset: 'Wipe World',
|
||||||
|
mods: 'Mods',
|
||||||
|
group: 'Clan',
|
||||||
|
},
|
||||||
|
special: ['Clans', 'Thralls', 'Avatars', 'Purge', 'PvP windows'],
|
||||||
|
statFields: ['Players', 'Clans', 'Purge'],
|
||||||
|
nav: [
|
||||||
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
|
{
|
||||||
|
label: 'Server',
|
||||||
|
// Conan: no uMod/Oxide; has RCON console, maps, players, files
|
||||||
|
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ label: 'Wipe World', route: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
|
||||||
|
NAV_MAPS,
|
||||||
|
NAV_SCHEDULES,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monitoring',
|
||||||
|
items: [NAV_CHAT, NAV_ANALYTICS, NAV_ALERTS, NAV_NOTIFICATIONS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Management',
|
||||||
|
items: [NAV_TEAM, NAV_STORE, NAV_MODULES, NAV_CHANGELOG, NAV_SETTINGS],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
soulmask: {
|
||||||
|
label: 'Soulmask',
|
||||||
|
accent: 'soulmask',
|
||||||
|
managementModel: 'process+rcon',
|
||||||
|
// Different Steam app IDs per OS (uncommon — store this explicitly).
|
||||||
|
steamAppId: { windows: 3017310, linux: 3017300 },
|
||||||
|
ports: { game: 8777, query: 27015, rcon: 19000, cluster: 20000 },
|
||||||
|
mods: 'workshop',
|
||||||
|
console: 'rcon+gm',
|
||||||
|
resetModel: 'worlddb-delete+decay',
|
||||||
|
clustering: 'main-client',
|
||||||
|
maps: ['Cloud Mist Forest', 'Shifting Sands'],
|
||||||
|
terminology: {
|
||||||
|
reset: 'World Reset',
|
||||||
|
mods: 'Workshop Mods',
|
||||||
|
group: 'Tribe',
|
||||||
|
},
|
||||||
|
special: ['Cluster', 'Tribes'],
|
||||||
|
statFields: ['Players', 'Tribe', 'Mask'],
|
||||||
|
nav: [
|
||||||
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
|
{
|
||||||
|
label: 'Server',
|
||||||
|
// Soulmask: no uMod/Oxide; has RCON+GM console, players, files
|
||||||
|
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ label: 'World Reset', route: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
|
||||||
|
NAV_SCHEDULES,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monitoring',
|
||||||
|
items: [NAV_CHAT, NAV_ANALYTICS, NAV_ALERTS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Management',
|
||||||
|
items: [NAV_TEAM, NAV_STORE, NAV_MODULES, NAV_CHANGELOG, NAV_SETTINGS],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
dune: {
|
||||||
|
label: 'Dune: Awakening',
|
||||||
|
accent: 'dune',
|
||||||
|
managementModel: 'docker-compose',
|
||||||
|
steamAppId: 4754530,
|
||||||
|
mods: 'none',
|
||||||
|
// Dune uses RabbitMQ for its admin messaging — not a standard RCON port.
|
||||||
|
console: 'rabbitmq',
|
||||||
|
resetModel: 'deep-desert-coriolis-seed',
|
||||||
|
clustering: 'battlegroup',
|
||||||
|
terminology: {
|
||||||
|
reset: 'Deep Desert reset',
|
||||||
|
mods: null,
|
||||||
|
group: 'Guild',
|
||||||
|
},
|
||||||
|
special: ['Sietches', 'Deep Desert', 'Bases', 'Landsraad'],
|
||||||
|
statFields: ['Players', 'Sietches', 'Control'],
|
||||||
|
nav: [
|
||||||
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
|
{
|
||||||
|
label: 'Server',
|
||||||
|
// Dune: no RCON (uses RabbitMQ); label console "Broadcast"; no maps route; no plugins
|
||||||
|
items: [
|
||||||
|
NAV_SERVER,
|
||||||
|
{ label: 'Broadcast', route: '/console', icon: 'radio', permission: 'console.view' },
|
||||||
|
NAV_PLAYERS,
|
||||||
|
NAV_FILES,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ label: 'Deep Desert', route: '/wipes', icon: 'wind', permission: 'wipes.view' },
|
||||||
|
NAV_SCHEDULES,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monitoring',
|
||||||
|
items: [NAV_ANALYTICS, NAV_ALERTS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Management',
|
||||||
|
items: [NAV_TEAM, NAV_STORE, NAV_CHANGELOG, NAV_SETTINGS],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the GameProfile for the given id, falling back to Rust if the id is
|
||||||
|
* unknown (forward-compatibility: unknown games show Rust defaults until their
|
||||||
|
* profile is added).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const profile = useGameProfile('rust')
|
||||||
|
* console.log(profile.terminology.reset) // 'Wipe'
|
||||||
|
*/
|
||||||
|
export function useGameProfile(id: string): GameProfile {
|
||||||
|
return (GAME_PROFILES as Record<string, GameProfile>)[id] ?? GAME_PROFILES.rust
|
||||||
|
}
|
||||||
@@ -1,11 +1,28 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
// Extend vue-router's RouteMeta so title/description are typed throughout
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
requiresAuth?: boolean
|
||||||
|
guest?: boolean
|
||||||
|
superAdmin?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Domain detection — runs once at module load
|
// Domain detection — runs once at module load
|
||||||
|
// Env-driven so www./staging hosts route correctly; an exact-match literal
|
||||||
|
// here once meant any non-canonical marketing host silently got the panel.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const hostname = typeof window !== 'undefined' ? window.location.hostname : ''
|
const hostname = typeof window !== 'undefined' ? window.location.hostname : ''
|
||||||
const isMarketingDomain = hostname === 'corrosionmgmt.com'
|
const marketingHosts = (import.meta.env.VITE_MARKETING_HOSTS ?? 'corrosionmgmt.com,www.corrosionmgmt.com')
|
||||||
|
.split(',')
|
||||||
|
.map((h: string) => h.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
const isMarketingDomain = marketingHosts.includes(hostname.toLowerCase())
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Marketing page children — shared between both domain route sets
|
// Marketing page children — shared between both domain route sets
|
||||||
@@ -15,31 +32,55 @@ const marketingChildren: RouteRecordRaw[] = [
|
|||||||
path: '',
|
path: '',
|
||||||
name: 'landing',
|
name: 'landing',
|
||||||
component: () => import('@/views/marketing/LandingView.vue'),
|
component: () => import('@/views/marketing/LandingView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Corrosion — Game Server Operations for Self-Hosted Communities',
|
||||||
|
description: 'Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'pricing',
|
path: 'pricing',
|
||||||
name: 'pricing',
|
name: 'pricing',
|
||||||
component: () => import('@/views/marketing/PricingView.vue'),
|
component: () => import('@/views/marketing/PricingView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Pricing — Corrosion',
|
||||||
|
description: 'Plans from $9.99/mo (Hobby, 1–5 servers) to Network ($99.99+/mo, 50+ servers). Non-commercial and commercial tiers. No hosting fees — bring your own server.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'how-it-works',
|
path: 'how-it-works',
|
||||||
name: 'how-it-works',
|
name: 'how-it-works',
|
||||||
component: () => import('@/views/marketing/HowItWorksView.vue'),
|
component: () => import('@/views/marketing/HowItWorksView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'How It Works — Corrosion',
|
||||||
|
description: 'Install one host agent on Windows or Linux. It connects outbound-only to Corrosion — no inbound ports, no SSH. Manage every game instance from the browser.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'faq',
|
path: 'faq',
|
||||||
name: 'faq',
|
name: 'faq',
|
||||||
component: () => import('@/views/marketing/FaqView.vue'),
|
component: () => import('@/views/marketing/FaqView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'FAQ — Corrosion',
|
||||||
|
description: 'Honest answers: Corrosion is self-service (BYOS, no hosting). Support is docs + community; 1:1 at $125/hr. Supports Rust, Dune, Conan Exiles, Soulmask.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'roadmap',
|
path: 'roadmap',
|
||||||
name: 'roadmap',
|
name: 'roadmap',
|
||||||
component: () => import('@/views/marketing/RoadmapView.vue'),
|
component: () => import('@/views/marketing/RoadmapView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Roadmap — Corrosion',
|
||||||
|
description: 'Phase 1 shipped: core control plane, auto-wiper, plugin management. In progress: Dune, Conan, Soulmask multi-game blueprints. Planned: API access, integrations.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'early-access',
|
path: 'early-access',
|
||||||
name: 'early-access',
|
name: 'early-access',
|
||||||
component: () => import('@/views/marketing/EarlyAccessView.vue'),
|
component: () => import('@/views/marketing/EarlyAccessView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Early Access — Corrosion',
|
||||||
|
description: 'Join the early access list. Get full control plane access — wipe automation, plugin management, real-time console — and lock in launch pricing.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -53,25 +94,25 @@ const panelRoutes: RouteRecordRaw[] = [
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('@/views/auth/LoginView.vue'),
|
component: () => import('@/views/auth/LoginView.vue'),
|
||||||
meta: { guest: true },
|
meta: { guest: true, title: 'Sign in — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/register',
|
path: '/register',
|
||||||
name: 'register',
|
name: 'register',
|
||||||
component: () => import('@/views/auth/RegisterView.vue'),
|
component: () => import('@/views/auth/RegisterView.vue'),
|
||||||
meta: { guest: true },
|
meta: { guest: true, title: 'Create account — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/forgot-password',
|
path: '/forgot-password',
|
||||||
name: 'forgot-password',
|
name: 'forgot-password',
|
||||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||||
meta: { guest: true },
|
meta: { guest: true, title: 'Reset password — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/setup',
|
path: '/setup',
|
||||||
name: 'setup-wizard',
|
name: 'setup-wizard',
|
||||||
component: () => import('@/views/auth/SetupWizardView.vue'),
|
component: () => import('@/views/auth/SetupWizardView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true, title: 'Setup — Corrosion' },
|
||||||
},
|
},
|
||||||
|
|
||||||
// Admin dashboard routes (with sidebar layout)
|
// Admin dashboard routes (with sidebar layout)
|
||||||
@@ -84,217 +125,254 @@ const panelRoutes: RouteRecordRaw[] = [
|
|||||||
path: '',
|
path: '',
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: () => import('@/views/admin/DashboardView.vue'),
|
component: () => import('@/views/admin/DashboardView.vue'),
|
||||||
|
meta: { title: 'Dashboard — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'server',
|
path: 'server',
|
||||||
name: 'server',
|
name: 'server',
|
||||||
component: () => import('@/views/admin/ServerView.vue'),
|
component: () => import('@/views/admin/ServerView.vue'),
|
||||||
|
meta: { title: 'Server — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'console',
|
path: 'console',
|
||||||
name: 'console',
|
name: 'console',
|
||||||
component: () => import('@/views/admin/ConsoleView.vue'),
|
component: () => import('@/views/admin/ConsoleView.vue'),
|
||||||
|
meta: { title: 'Console — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'players',
|
path: 'players',
|
||||||
name: 'players',
|
name: 'players',
|
||||||
component: () => import('@/views/admin/PlayersView.vue'),
|
component: () => import('@/views/admin/PlayersView.vue'),
|
||||||
|
meta: { title: 'Players — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'plugins',
|
path: 'plugins',
|
||||||
name: 'plugins',
|
name: 'plugins',
|
||||||
component: () => import('@/views/admin/PluginsView.vue'),
|
component: () => import('@/views/admin/PluginsView.vue'),
|
||||||
|
meta: { title: 'Plugins — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'files',
|
path: 'files',
|
||||||
name: 'files',
|
name: 'files',
|
||||||
component: () => import('@/views/admin/FileManagerView.vue'),
|
component: () => import('@/views/admin/FileManagerView.vue'),
|
||||||
|
meta: { title: 'Files — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'plugin-configs',
|
path: 'plugin-configs',
|
||||||
name: 'plugin-configs',
|
name: 'plugin-configs',
|
||||||
component: () => import('@/views/admin/PluginConfigsView.vue'),
|
component: () => import('@/views/admin/PluginConfigsView.vue'),
|
||||||
|
meta: { title: 'Plugin Configs — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'loot-builder',
|
path: 'loot-builder',
|
||||||
name: 'loot-builder',
|
name: 'loot-builder',
|
||||||
component: () => import('@/views/admin/LootBuilderView.vue'),
|
component: () => import('@/views/admin/LootBuilderView.vue'),
|
||||||
|
meta: { title: 'Loot Builder — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'teleport-config',
|
path: 'teleport-config',
|
||||||
name: 'teleport-config',
|
name: 'teleport-config',
|
||||||
component: () => import('@/views/admin/TeleportConfigView.vue'),
|
component: () => import('@/views/admin/TeleportConfigView.vue'),
|
||||||
|
meta: { title: 'Teleport Config — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'gather-manager',
|
path: 'gather-manager',
|
||||||
name: 'gather-manager',
|
name: 'gather-manager',
|
||||||
component: () => import('@/views/admin/GatherManagerView.vue'),
|
component: () => import('@/views/admin/GatherManagerView.vue'),
|
||||||
|
meta: { title: 'Gather Manager — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'autodoors',
|
path: 'autodoors',
|
||||||
name: 'autodoors',
|
name: 'autodoors',
|
||||||
component: () => import('@/views/admin/AutoDoorsView.vue'),
|
component: () => import('@/views/admin/AutoDoorsView.vue'),
|
||||||
|
meta: { title: 'Auto Doors — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'kits',
|
path: 'kits',
|
||||||
name: 'kits-config',
|
name: 'kits-config',
|
||||||
component: () => import('@/views/admin/KitsView.vue'),
|
component: () => import('@/views/admin/KitsView.vue'),
|
||||||
|
meta: { title: 'Kits — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'furnace-splitter',
|
path: 'furnace-splitter',
|
||||||
name: 'furnace-splitter',
|
name: 'furnace-splitter',
|
||||||
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
|
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
|
||||||
|
meta: { title: 'Furnace Splitter — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'better-chat',
|
path: 'better-chat',
|
||||||
name: 'better-chat',
|
name: 'better-chat',
|
||||||
component: () => import('@/views/admin/BetterChatView.vue'),
|
component: () => import('@/views/admin/BetterChatView.vue'),
|
||||||
|
meta: { title: 'Better Chat — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'timed-execute',
|
path: 'timed-execute',
|
||||||
name: 'timed-execute',
|
name: 'timed-execute',
|
||||||
component: () => import('@/views/admin/TimedExecuteView.vue'),
|
component: () => import('@/views/admin/TimedExecuteView.vue'),
|
||||||
|
meta: { title: 'Timed Execute — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'raidable-bases',
|
path: 'raidable-bases',
|
||||||
name: 'raidable-bases',
|
name: 'raidable-bases',
|
||||||
component: () => import('@/views/admin/RaidableBasesView.vue'),
|
component: () => import('@/views/admin/RaidableBasesView.vue'),
|
||||||
|
meta: { title: 'Raidable Bases — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes',
|
path: 'wipes',
|
||||||
name: 'wipes',
|
name: 'wipes',
|
||||||
component: () => import('@/views/admin/WipesView.vue'),
|
component: () => import('@/views/admin/WipesView.vue'),
|
||||||
|
meta: { title: 'Wipes — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/profiles',
|
path: 'wipes/profiles',
|
||||||
name: 'wipe-profiles',
|
name: 'wipe-profiles',
|
||||||
component: () => import('@/views/admin/WipeProfilesView.vue'),
|
component: () => import('@/views/admin/WipeProfilesView.vue'),
|
||||||
|
meta: { title: 'Wipe Profiles — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/calendar',
|
path: 'wipes/calendar',
|
||||||
name: 'wipe-calendar',
|
name: 'wipe-calendar',
|
||||||
component: () => import('@/views/admin/WipeCalendarView.vue'),
|
component: () => import('@/views/admin/WipeCalendarView.vue'),
|
||||||
|
meta: { title: 'Wipe Calendar — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/history',
|
path: 'wipes/history',
|
||||||
name: 'wipe-history',
|
name: 'wipe-history',
|
||||||
component: () => import('@/views/admin/WipeHistoryView.vue'),
|
component: () => import('@/views/admin/WipeHistoryView.vue'),
|
||||||
|
meta: { title: 'Wipe History — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes/analytics',
|
path: 'wipes/analytics',
|
||||||
name: 'wipe-analytics',
|
name: 'wipe-analytics',
|
||||||
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
|
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
|
||||||
|
meta: { title: 'Wipe Analytics — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'maps',
|
path: 'maps',
|
||||||
name: 'maps',
|
name: 'maps',
|
||||||
component: () => import('@/views/admin/MapsView.vue'),
|
component: () => import('@/views/admin/MapsView.vue'),
|
||||||
|
meta: { title: 'Maps — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'maps/analytics',
|
path: 'maps/analytics',
|
||||||
name: 'map-analytics',
|
name: 'map-analytics',
|
||||||
component: () => import('@/views/admin/MapAnalyticsView.vue'),
|
component: () => import('@/views/admin/MapAnalyticsView.vue'),
|
||||||
|
meta: { title: 'Map Analytics — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'chat',
|
path: 'chat',
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
component: () => import('@/views/admin/ChatLogView.vue'),
|
component: () => import('@/views/admin/ChatLogView.vue'),
|
||||||
|
meta: { title: 'Chat Log — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'analytics',
|
path: 'analytics',
|
||||||
name: 'analytics',
|
name: 'analytics',
|
||||||
component: () => import('@/views/admin/AnalyticsView.vue'),
|
component: () => import('@/views/admin/AnalyticsView.vue'),
|
||||||
|
meta: { title: 'Analytics — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'retention',
|
path: 'retention',
|
||||||
name: 'retention',
|
name: 'retention',
|
||||||
component: () => import('@/views/admin/PlayerRetentionView.vue'),
|
component: () => import('@/views/admin/PlayerRetentionView.vue'),
|
||||||
|
meta: { title: 'Player Retention — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'notifications',
|
path: 'notifications',
|
||||||
name: 'notifications',
|
name: 'notifications',
|
||||||
component: () => import('@/views/admin/NotificationsView.vue'),
|
component: () => import('@/views/admin/NotificationsView.vue'),
|
||||||
|
meta: { title: 'Notifications — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'team',
|
path: 'team',
|
||||||
name: 'team',
|
name: 'team',
|
||||||
component: () => import('@/views/admin/TeamView.vue'),
|
component: () => import('@/views/admin/TeamView.vue'),
|
||||||
|
meta: { title: 'Team — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'store/config',
|
path: 'store/config',
|
||||||
name: 'store-config',
|
name: 'store-config',
|
||||||
component: () => import('@/views/admin/StoreConfigView.vue'),
|
component: () => import('@/views/admin/StoreConfigView.vue'),
|
||||||
|
meta: { title: 'Store Config — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'store/items',
|
path: 'store/items',
|
||||||
name: 'store-items',
|
name: 'store-items',
|
||||||
component: () => import('@/views/admin/StoreItemsView.vue'),
|
component: () => import('@/views/admin/StoreItemsView.vue'),
|
||||||
|
meta: { title: 'Store Items — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'store/revenue',
|
path: 'store/revenue',
|
||||||
name: 'store-revenue',
|
name: 'store-revenue',
|
||||||
component: () => import('@/views/admin/StoreRevenueView.vue'),
|
component: () => import('@/views/admin/StoreRevenueView.vue'),
|
||||||
|
meta: { title: 'Store Revenue — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'modules',
|
path: 'modules',
|
||||||
name: 'modules',
|
name: 'modules',
|
||||||
component: () => import('@/views/admin/ModuleStoreView.vue'),
|
component: () => import('@/views/admin/ModuleStoreView.vue'),
|
||||||
|
meta: { title: 'Modules — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: () => import('@/views/admin/SettingsView.vue'),
|
component: () => import('@/views/admin/SettingsView.vue'),
|
||||||
|
meta: { title: 'Settings — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'schedules',
|
path: 'schedules',
|
||||||
name: 'schedules',
|
name: 'schedules',
|
||||||
component: () => import('@/views/admin/SchedulesView.vue'),
|
component: () => import('@/views/admin/SchedulesView.vue'),
|
||||||
|
meta: { title: 'Schedules — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'migration',
|
path: 'migration',
|
||||||
name: 'migration',
|
name: 'migration',
|
||||||
component: () => import('@/views/admin/MigrationView.vue'),
|
component: () => import('@/views/admin/MigrationView.vue'),
|
||||||
|
meta: { title: 'Migration — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'changelog',
|
path: 'changelog',
|
||||||
name: 'changelog',
|
name: 'changelog',
|
||||||
component: () => import('@/views/admin/ChangelogView.vue'),
|
component: () => import('@/views/admin/ChangelogView.vue'),
|
||||||
|
meta: { title: 'Changelog — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'alerts',
|
path: 'alerts',
|
||||||
name: 'alerts',
|
name: 'alerts',
|
||||||
component: () => import('@/views/admin/AlertsView.vue'),
|
component: () => import('@/views/admin/AlertsView.vue'),
|
||||||
|
meta: { title: 'Alerts — Corrosion' },
|
||||||
},
|
},
|
||||||
// Platform Admin views (super-admin only)
|
// Platform Admin views (super-admin only)
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
name: 'platform-admin',
|
name: 'platform-admin',
|
||||||
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
|
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
|
||||||
meta: { superAdmin: true },
|
meta: { superAdmin: true, title: 'Admin — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/licenses',
|
path: 'admin/licenses',
|
||||||
name: 'platform-licenses',
|
name: 'platform-licenses',
|
||||||
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
|
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
|
||||||
meta: { superAdmin: true },
|
meta: { superAdmin: true, title: 'Admin: Licenses — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/subscriptions',
|
path: 'admin/subscriptions',
|
||||||
name: 'platform-subscriptions',
|
name: 'platform-subscriptions',
|
||||||
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
|
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
|
||||||
meta: { superAdmin: true },
|
meta: { superAdmin: true, title: 'Admin: Subscriptions — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/users',
|
path: 'admin/users',
|
||||||
name: 'platform-users',
|
name: 'platform-users',
|
||||||
component: () => import('@/views/platform-admin/AdminUsers.vue'),
|
component: () => import('@/views/platform-admin/AdminUsers.vue'),
|
||||||
meta: { superAdmin: true },
|
meta: { superAdmin: true, title: 'Admin: Users — Corrosion' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/servers',
|
path: 'admin/servers',
|
||||||
name: 'platform-servers',
|
name: 'platform-servers',
|
||||||
component: () => import('@/views/platform-admin/AdminServers.vue'),
|
component: () => import('@/views/platform-admin/AdminServers.vue'),
|
||||||
meta: { superAdmin: true },
|
meta: { superAdmin: true, title: 'Admin: Servers — Corrosion' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -329,6 +407,7 @@ const panelRoutes: RouteRecordRaw[] = [
|
|||||||
path: '/status',
|
path: '/status',
|
||||||
name: 'status',
|
name: 'status',
|
||||||
component: () => import('@/views/public/StatusPageView.vue'),
|
component: () => import('@/views/public/StatusPageView.vue'),
|
||||||
|
meta: { title: 'Status — Corrosion' },
|
||||||
},
|
},
|
||||||
|
|
||||||
// Catch-all
|
// Catch-all
|
||||||
@@ -366,6 +445,7 @@ const marketingRoutes: RouteRecordRaw[] = [
|
|||||||
path: '/status',
|
path: '/status',
|
||||||
name: 'status',
|
name: 'status',
|
||||||
component: () => import('@/views/public/StatusPageView.vue'),
|
component: () => import('@/views/public/StatusPageView.vue'),
|
||||||
|
meta: { title: 'Status — Corrosion' },
|
||||||
},
|
},
|
||||||
|
|
||||||
// Catch-all: unknown routes → landing page
|
// Catch-all: unknown routes → landing page
|
||||||
@@ -383,6 +463,38 @@ const router = createRouter({
|
|||||||
routes: isMarketingDomain ? marketingRoutes : panelRoutes,
|
routes: isMarketingDomain ? marketingRoutes : panelRoutes,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Document title + meta description/OG update on every navigation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function setOrClearMeta(selector: string, attr: string, value: string): void {
|
||||||
|
let el = document.querySelector<HTMLMetaElement>(selector)
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('meta')
|
||||||
|
// Parse the selector to set the right attribute (name="..." or property="...")
|
||||||
|
const nameMatch = selector.match(/\[name="([^"]+)"\]/)
|
||||||
|
const propMatch = selector.match(/\[property="([^"]+)"\]/)
|
||||||
|
if (nameMatch?.[1]) el.setAttribute('name', nameMatch[1])
|
||||||
|
if (propMatch?.[1]) el.setAttribute('property', propMatch[1])
|
||||||
|
document.head.appendChild(el)
|
||||||
|
}
|
||||||
|
el.setAttribute(attr, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
// Title
|
||||||
|
document.title = to.meta.title ?? 'Corrosion Management'
|
||||||
|
|
||||||
|
// Description
|
||||||
|
const desc = to.meta.description ?? ''
|
||||||
|
setOrClearMeta('meta[name="description"]', 'content', desc)
|
||||||
|
|
||||||
|
// OG title
|
||||||
|
setOrClearMeta('meta[property="og:title"]', 'content', to.meta.title ?? 'Corrosion Management')
|
||||||
|
|
||||||
|
// OG description
|
||||||
|
setOrClearMeta('meta[property="og:description"]', 'content', desc)
|
||||||
|
})
|
||||||
|
|
||||||
// Auth guard — only meaningful on panel domain (marketing has no requiresAuth routes)
|
// Auth guard — only meaningful on panel domain (marketing has no requiresAuth routes)
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|||||||
@@ -58,6 +58,27 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
permissions.value = {}
|
permissions.value = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the persisted session against the API on app boot. Without this,
|
||||||
|
* a stale/revoked token renders the full panel chrome and only collapses on
|
||||||
|
* the first real API call. useApi's 401 path (refresh → retry → logout)
|
||||||
|
* does the heavy lifting; any non-auth failure (network, 5xx) keeps the
|
||||||
|
* session — never log users out because the API blipped.
|
||||||
|
* Dynamic import avoids a static auth-store ↔ useApi module cycle.
|
||||||
|
*/
|
||||||
|
async function validateSession(): Promise<void> {
|
||||||
|
if (!accessToken.value) return
|
||||||
|
try {
|
||||||
|
const { useApi } = await import('@/composables/useApi')
|
||||||
|
const me = await useApi().get<Partial<User>>('/auth/me')
|
||||||
|
if (user.value && me && typeof me === 'object') {
|
||||||
|
user.value = { ...user.value, ...me }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 401 → refresh → logout/redirect already handled inside useApi.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hasModule(moduleSlug: string): boolean {
|
function hasModule(moduleSlug: string): boolean {
|
||||||
return license.value?.modules_enabled?.includes(moduleSlug) ?? false
|
return license.value?.modules_enabled?.includes(moduleSlug) ?? false
|
||||||
}
|
}
|
||||||
@@ -92,6 +113,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
setAuth,
|
setAuth,
|
||||||
setLicense,
|
setLicense,
|
||||||
logout,
|
logout,
|
||||||
|
validateSession,
|
||||||
hasModule,
|
hasModule,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
}
|
}
|
||||||
|
|||||||
846
frontend/src/styles/marketing.css
Normal file
846
frontend/src/styles/marketing.css
Normal file
@@ -0,0 +1,846 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion — Marketing site styles
|
||||||
|
Consumes the design-system tokens already loaded globally
|
||||||
|
via frontend/src/style.css (tokens/fonts → colors → etc.).
|
||||||
|
Class names match the design kit exactly.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.wrap { max-width: 1140px; margin: 0 auto; padding: 0 32px; }
|
||||||
|
section { position: relative; }
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: var(--tracking-caps);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2.title {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin: 16px auto 0;
|
||||||
|
max-width: 660px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent { color: var(--accent-text); }
|
||||||
|
|
||||||
|
.mark { display: inline-block; color: var(--accent); }
|
||||||
|
.mark svg { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
/* ---- Buttons ---- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 46px;
|
||||||
|
padding: 0 22px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
.btn--primary:hover { background: var(--accent-hover); }
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.btn--ghost:hover { background: var(--surface-active); }
|
||||||
|
|
||||||
|
.btn--sm { height: 36px; padding: 0 14px; font-size: var(--text-sm); }
|
||||||
|
.btn--lg { height: 52px; padding: 0 28px; font-size: var(--text-md); }
|
||||||
|
|
||||||
|
/* ---- Nav ---- */
|
||||||
|
.mkt-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
height: var(--topbar-h);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: color-mix(in srgb, var(--surface-canvas) 84%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.mkt-nav__in { display: flex; align-items: center; gap: 24px; width: 100%; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; }
|
||||||
|
.brand .mark { width: 26px; height: 26px; }
|
||||||
|
.brand b {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.mkt-nav__links { display: flex; gap: 24px; margin-left: 14px; }
|
||||||
|
.mkt-nav__links a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color var(--dur-fast);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.mkt-nav__links a:hover { color: var(--text-primary); }
|
||||||
|
.mkt-nav__cta { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.mkt-nav__signin {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--dur-fast);
|
||||||
|
}
|
||||||
|
.mkt-nav__signin:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ---- Hero ---- */
|
||||||
|
.hero { overflow: hidden; border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.hero__atmo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
transition: background var(--dur-slower) var(--ease-standard);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 50% -10%, var(--atmo-haze), transparent 55%),
|
||||||
|
radial-gradient(70% 50% at 85% 110%, color-mix(in srgb, var(--accent) 9%, transparent), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--atmo-1), var(--surface-canvas) 72%);
|
||||||
|
}
|
||||||
|
.hero__grain {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: .5;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
background-image: radial-gradient(rgba(255,255,255,.05) 1px, transparent 1px);
|
||||||
|
background-size: 3px 3px;
|
||||||
|
}
|
||||||
|
.hero__grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: .32;
|
||||||
|
-webkit-mask-image: radial-gradient(75% 60% at 50% 24%, #000, transparent 78%);
|
||||||
|
mask-image: radial-gradient(75% 60% at 50% 24%, #000, transparent 78%);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--border-subtle) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--border-subtle) 1px, transparent 1px);
|
||||||
|
background-size: 46px 46px;
|
||||||
|
}
|
||||||
|
.hero__in {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 74px 0 88px;
|
||||||
|
}
|
||||||
|
.hero__mark {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 auto 22px;
|
||||||
|
color: var(--accent);
|
||||||
|
filter: drop-shadow(0 0 26px var(--accent-glow));
|
||||||
|
transition: color var(--dur-slow);
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: var(--text-6xl);
|
||||||
|
line-height: 1.04;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.hero h1 .accent { display: block; }
|
||||||
|
.hero__sub {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin: 22px auto 0;
|
||||||
|
max-width: 640px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.hero__cta {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.hero__games {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 34px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.gpill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.gpill[data-on="true"] {
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.hero__foot {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.notpill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.notpill b { color: var(--accent-text); }
|
||||||
|
|
||||||
|
/* ---- Panel mockup ---- */
|
||||||
|
.mock {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 54px auto 0;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-base);
|
||||||
|
box-shadow: 0 50px 130px -34px rgba(0,0,0,.85), var(--ring-default);
|
||||||
|
}
|
||||||
|
.mock__bar {
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.mock__dots { display: flex; gap: 7px; }
|
||||||
|
.mock__dots span {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-active);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.mock__url {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
.mock__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 188px 1fr;
|
||||||
|
min-height: 316px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.mock__side {
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
padding: 14px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.mock__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.mock__brand .mark { width: 18px; height: 18px; }
|
||||||
|
.mock__brand b {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mock__gs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.mock__gs span {
|
||||||
|
flex: 1;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.mock__gs .on {
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.mock__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.mock__nav.on { background: var(--accent-soft); color: var(--accent-text); }
|
||||||
|
.mock__main { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.mock__kpis { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; }
|
||||||
|
.mock__kpi {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.mock__kpi .l { font-size: 10px; color: var(--text-tertiary); }
|
||||||
|
.mock__kpi .v {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 19px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.mock__kpi .v small { color: var(--text-muted); font-size: 12px; }
|
||||||
|
.mock__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mock__row::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.mock__row .g {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.mock__row .nm { flex: 1; font-size: 12px; font-weight: 600; }
|
||||||
|
.mock__row .nm small {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.mock__row .st {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--status-online);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.mock__row .st b {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--status-online);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Section spacing ---- */
|
||||||
|
.sec { padding: 88px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.sec__head { text-align: center; margin-bottom: 48px; }
|
||||||
|
.sec__head .eyebrow { display: block; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
/* ---- Problem cards ---- */
|
||||||
|
.pain {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4,1fr);
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.pain__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.pain__x {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--status-offline-soft);
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
.closing {
|
||||||
|
text-align: center;
|
||||||
|
margin: 40px auto 0;
|
||||||
|
max-width: 720px;
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Steps ---- */
|
||||||
|
.steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3,1fr);
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
padding: 28px 24px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.step__n {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.step b { font-size: var(--text-md); font-weight: 600; }
|
||||||
|
.step p { color: var(--text-tertiary); font-size: var(--text-sm); margin: 8px 0 0; }
|
||||||
|
.nots {
|
||||||
|
display: flex;
|
||||||
|
gap: 26px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 34px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.nots span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Blueprints (game cards) ---- */
|
||||||
|
.blueprints {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.bp {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 90% at 100% 0%, var(--atmo-haze), transparent 55%),
|
||||||
|
linear-gradient(160deg, color-mix(in srgb, var(--atmo-1) 80%, transparent), var(--surface-base) 70%);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.bp__head { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
|
||||||
|
.bp__ic {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.bp__name { font-family: var(--font-brand); font-weight: 700; font-size: var(--text-xl); }
|
||||||
|
.bp__accent {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-text);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
.bp__role { font-size: var(--text-sm); font-weight: 600; color: var(--text-secondary); margin: 10px 0 14px; }
|
||||||
|
.bp__list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.bp__list div { display: flex; align-items: center; gap: 9px; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ---- Capabilities (3 col) ---- */
|
||||||
|
.caps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3,1fr);
|
||||||
|
gap: 30px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.caps__col > .eyebrow { display: block; margin-bottom: 8px; }
|
||||||
|
.feat { display: flex; gap: 12px; padding: 14px 0; border-top: 1px solid var(--border-subtle); }
|
||||||
|
.feat:first-of-type { border-top: 0; }
|
||||||
|
.feat__ic {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.feat b { font-size: var(--text-sm); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---- Pipeline ---- */
|
||||||
|
.pipe {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.pchip {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 15px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.pchip--last {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.stack-lines { display: flex; flex-direction: column; gap: 8px; align-items: center; margin-top: 32px; }
|
||||||
|
.stack-lines span { color: var(--text-tertiary); font-size: var(--text-md); }
|
||||||
|
.stack-lines .hi { color: var(--accent-text); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---- Infra ---- */
|
||||||
|
.infra {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5,1fr);
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.icard {
|
||||||
|
padding: 20px 16px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.icard__ic {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.icard b { font-size: var(--text-sm); font-weight: 600; display: block; }
|
||||||
|
.icard p { margin: 5px 0 0; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.5; }
|
||||||
|
.techrow {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.techrow span {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Store ---- */
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.chip-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.chip-card--accent {
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Pricing ---- */
|
||||||
|
.pricing {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4,1fr);
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.plan {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px 22px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.plan--feature {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border), var(--glow-accent-sm);
|
||||||
|
background: linear-gradient(180deg, var(--accent-soft), var(--surface-base) 40%);
|
||||||
|
}
|
||||||
|
.plan__tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--accent-text);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.plan__name { font-family: var(--font-brand); font-weight: 700; font-size: var(--text-xl); }
|
||||||
|
.plan__price {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
margin: 12px 0 2px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.plan__price small { font-size: var(--text-sm); color: var(--text-muted); font-weight: 400; }
|
||||||
|
.plan__scope { font-size: var(--text-sm); color: var(--text-tertiary); min-height: 40px; }
|
||||||
|
.plan .btn { margin-top: 18px; width: 100%; justify-content: center; }
|
||||||
|
|
||||||
|
.fleetblock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 14px auto 0;
|
||||||
|
padding: 16px 22px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.fleetblock b { font-family: var(--font-brand); font-weight: 700; }
|
||||||
|
.fleetblock .p { font-family: var(--font-mono); color: var(--accent-text); font-weight: 600; }
|
||||||
|
.fleetblock span { color: var(--text-tertiary); font-size: var(--text-sm); }
|
||||||
|
|
||||||
|
.commercial {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 26px auto 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.commercial b { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ---- Support block (below pricing) ---- */
|
||||||
|
.support-note {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 20px auto 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.support-note b { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ---- Admins ---- */
|
||||||
|
.admins {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 11px;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.admins span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Final CTA ---- */
|
||||||
|
.finalcta {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
padding: 104px 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.finalcta__atmo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
background: radial-gradient(60% 100% at 50% 100%, var(--atmo-haze), transparent 60%);
|
||||||
|
}
|
||||||
|
.finalcta__in { position: relative; z-index: 1; }
|
||||||
|
.finalcta h2 {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: var(--text-5xl);
|
||||||
|
margin: 0 0 28px;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.finalcta .cta-row { display: flex; gap: 14px; justify-content: center; }
|
||||||
|
|
||||||
|
/* ---- Footer ---- */
|
||||||
|
.mkt-footer { padding: 56px 0 40px; }
|
||||||
|
.footer__cols { display: grid; grid-template-columns: 1.5fr 1fr 1fr 1fr 1fr; gap: 24px; }
|
||||||
|
.footer__brand .mark { width: 24px; height: 24px; }
|
||||||
|
.footer__brand p {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 12px 0 0;
|
||||||
|
max-width: 230px;
|
||||||
|
}
|
||||||
|
.footer__col h5 {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.footer__col a {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: 9px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--dur-fast);
|
||||||
|
}
|
||||||
|
.footer__col a:hover { color: var(--text-primary); }
|
||||||
|
.footer__bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 44px;
|
||||||
|
padding-top: 22px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scroll reveal ---- */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
transition: opacity .6s var(--ease-out), transform .6s var(--ease-out);
|
||||||
|
}
|
||||||
|
.reveal.in { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Responsive ---- */
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.pain { grid-template-columns: 1fr 1fr; }
|
||||||
|
.steps, .caps, .blueprints, .pricing { grid-template-columns: 1fr; }
|
||||||
|
.infra { grid-template-columns: 1fr 1fr; }
|
||||||
|
.footer__cols { grid-template-columns: 1fr 1fr; }
|
||||||
|
.mock__body { grid-template-columns: 1fr; }
|
||||||
|
.mock__side { display: none; }
|
||||||
|
.hero h1 { font-size: var(--text-5xl); }
|
||||||
|
.mkt-nav__links { display: none; }
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@
|
|||||||
JetBrains Mono — console, data, IDs, telemetry
|
JetBrains Mono — console, data, IDs, telemetry
|
||||||
Oxanium — brand wordmark + marketing display (game-ops flavor)
|
Oxanium — brand wordmark + marketing display (game-ops flavor)
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
NOTE: Loaded from Google Fonts CDN. If you want these self-
|
NOTE: The Google Fonts stylesheet is loaded via <link> tags in
|
||||||
hosted (offline), send the woff2 files and these @imports
|
index.html — NOT @import here. A CSS @import that ends up
|
||||||
become @font-face rules.
|
mid-bundle after concatenation is silently dropped by the
|
||||||
|
optimizer (fonts never load in production). If you want these
|
||||||
|
self-hosted (offline), send the woff2 files and they become
|
||||||
|
@font-face rules here.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap');
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
--font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
|
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
|
||||||
|
|||||||
@@ -1,136 +1,97 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* DashboardView — Fleet / Solo dashboard.
|
* DashboardView — Single-server cockpit wired entirely to real data.
|
||||||
* Fleet: multi-game server cockpit (representative mock data — pending multi-instance backend).
|
|
||||||
* Solo: single-server detail wired to the real useServerStore where data exists.
|
|
||||||
*
|
*
|
||||||
* View toggle (Fleet / Solo) lives inside the page so the shell (DashboardLayout) stays clean.
|
* Architecture:
|
||||||
* Routing stays at path '/', no new routes added.
|
* - useServerStore → connection + config + live stats (WebSocket updateStats)
|
||||||
|
* - useApi → /analytics/timeseries for 24h player history (PlayersChart)
|
||||||
|
* - useGameProfile → per-game labels/terminology (defaults to 'rust' today)
|
||||||
|
* - useWebSocket → subscribes to console_output and server_stats events
|
||||||
|
*
|
||||||
|
* Empty states:
|
||||||
|
* - No connection record → "No server connected" EmptyState with CTA to /server
|
||||||
|
* - Connection exists but stats absent → meters show '—', chart shows awaiting telemetry
|
||||||
|
* - No upcoming wipe schedules → honest empty state in the wipes panel
|
||||||
|
*
|
||||||
|
* No fabricated data anywhere in this file.
|
||||||
|
* The fleet/multi-server view has been removed — the current backend is
|
||||||
|
* single-server-per-license. When the backend supports multiple servers per
|
||||||
|
* license, restore a fleet tab wired to real data.
|
||||||
*/
|
*/
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
|
import { useWipeStore } from '@/stores/wipe'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
||||||
|
import { useGameProfile } from '@/config/gameProfiles'
|
||||||
import { useThemeGame } from '@/composables/useThemeGame'
|
import { useThemeGame } from '@/composables/useThemeGame'
|
||||||
import Panel from '@/components/ds/data/Panel.vue'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
import ServerCard from '@/components/ds/data/ServerCard.vue'
|
import ConsoleLineDS from '@/components/ds/data/ConsoleLine.vue'
|
||||||
import ConsoleLine from '@/components/ds/data/ConsoleLine.vue'
|
|
||||||
import ResourceMeter from '@/components/ds/data/ResourceMeter.vue'
|
import ResourceMeter from '@/components/ds/data/ResourceMeter.vue'
|
||||||
import PlayersChart from '@/components/ds/data/PlayersChart.vue'
|
import PlayersChart from '@/components/ds/data/PlayersChart.vue'
|
||||||
import Badge from '@/components/ds/core/Badge.vue'
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
import Button from '@/components/ds/core/Button.vue'
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
import Icon from '@/components/ds/core/Icon.vue'
|
|
||||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
|
||||||
import Input from '@/components/ds/forms/Input.vue'
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
import Switch from '@/components/ds/forms/Switch.vue'
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
import {
|
import type { TimeseriesData, WipeSchedule } from '@/types'
|
||||||
MOCK_SERVERS, MOCK_FEED, MOCK_WIPES, buildStats,
|
import { safeDate } from '@/utils/formatters'
|
||||||
type MockServer, type GameKey,
|
|
||||||
} from './_dashboardMock'
|
|
||||||
|
|
||||||
// ---- Stores / composables ----
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stores / composables
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
|
const wipeStore = useWipeStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const api = useApi()
|
||||||
const { activeGame } = useThemeGame()
|
const { activeGame } = useThemeGame()
|
||||||
|
|
||||||
// ---- View toggle ----
|
// Profile follows the GameSwitcher selection. 'all' falls back to rust (neutral house skin).
|
||||||
const VIEW_KEY = 'cc-dash-view'
|
// When the backend adds a `game` field on licenses, swap activeGame for server.config?.game.
|
||||||
const view = ref<'fleet' | 'solo'>((localStorage.getItem(VIEW_KEY) as 'fleet' | 'solo') ?? 'fleet')
|
const profile = computed(() => {
|
||||||
function setView(v: string) {
|
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
|
||||||
view.value = v as 'fleet' | 'solo'
|
return useGameProfile(game)
|
||||||
localStorage.setItem(VIEW_KEY, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewItems = [
|
|
||||||
{ value: 'fleet', label: 'Fleet', icon: 'layout-grid' },
|
|
||||||
{ value: 'solo', label: 'Solo', icon: 'square-dashed' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---- Fleet: filter servers by activeGame ----
|
|
||||||
const serverStatus = ref<'all' | 'online' | 'offline'>('all')
|
|
||||||
const statusItems = computed(() => [
|
|
||||||
{ value: 'all', label: 'All', count: inGame.value.length },
|
|
||||||
{ value: 'online', label: 'Running', count: inGame.value.filter((s) => s.status !== 'offline').length },
|
|
||||||
{ value: 'offline', label: 'Stopped', count: inGame.value.filter((s) => s.status === 'offline').length },
|
|
||||||
])
|
|
||||||
|
|
||||||
const GAME_LABEL: Record<string, string> = { rust: 'Rust', dune: 'Dune', conan: 'Conan Exiles', soulmask: 'Soulmask' }
|
|
||||||
|
|
||||||
const inGame = computed<MockServer[]>(() =>
|
|
||||||
activeGame.value === 'all'
|
|
||||||
? MOCK_SERVERS
|
|
||||||
: MOCK_SERVERS.filter((s) => s.game === (activeGame.value as GameKey)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const shownServers = computed<MockServer[]>(() => {
|
|
||||||
const sv = serverStatus.value
|
|
||||||
return inGame.value.filter((s) => {
|
|
||||||
if (sv === 'all') return true
|
|
||||||
if (sv === 'online') return s.status !== 'offline'
|
|
||||||
return s.status === 'offline'
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Fleet KPIs ----
|
// ---------------------------------------------------------------------------
|
||||||
const runningCount = computed(() => inGame.value.filter((s) => s.status !== 'offline').length)
|
// Derived server state — all real, no fallbacks to fabricated values
|
||||||
const playersCur = computed(() => inGame.value.reduce((a, s) => a + (s.players?.cur ?? 0), 0))
|
// ---------------------------------------------------------------------------
|
||||||
const playersMax = computed(() => inGame.value.reduce((a, s) => a + (s.players?.max ?? 0), 0))
|
|
||||||
const cpuValues = computed(() => inGame.value.filter((s) => s.cpu != null).map((s) => s.cpu as number))
|
|
||||||
const avgCpu = computed<string>(() =>
|
|
||||||
cpuValues.value.length
|
|
||||||
? String(Math.round(cpuValues.value.reduce((a, b) => a + b, 0) / cpuValues.value.length))
|
|
||||||
: '—',
|
|
||||||
)
|
|
||||||
|
|
||||||
const scopeLabel = computed(() =>
|
const hasConnection = computed(() => server.connection !== null)
|
||||||
activeGame.value === 'all'
|
const isConnected = computed(() => server.connection?.connection_status === 'connected')
|
||||||
? 'Fleet overview'
|
|
||||||
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} fleet`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const fleetTitle = computed(() => {
|
const soloName = computed(() => server.config?.server_name ?? null)
|
||||||
if (activeGame.value === 'all') {
|
|
||||||
const games = new Set(MOCK_SERVERS.map((s) => s.game)).size
|
const soloPlayers = computed(() => server.stats?.player_count ?? null)
|
||||||
return `${MOCK_SERVERS.length} servers · ${games} games`
|
const soloMaxPlayers = computed(() => server.stats?.max_players ?? server.config?.max_players ?? null)
|
||||||
}
|
const soloFps = computed(() => server.stats?.fps ?? null)
|
||||||
const n = inGame.value.length
|
|
||||||
const label = GAME_LABEL[activeGame.value as string] ?? activeGame.value
|
// Memory: store gives memory_usage_mb; max must come from agent telemetry.
|
||||||
return `${n} ${label} server${n === 1 ? '' : 's'}`
|
// We do NOT hard-code a "representative" max — show raw MB and no percentage
|
||||||
|
// until the agent reports a known max.
|
||||||
|
const soloRamMb = computed(() => server.stats?.memory_usage_mb ?? null)
|
||||||
|
const soloRamPct = computed(() => {
|
||||||
|
// ServerStats has no ram_max field — we cannot compute a real percentage.
|
||||||
|
// Return null; ResourceMeter and StatCard will show '—'.
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
const soloRamSub = computed(() => {
|
||||||
|
const mb = soloRamMb.value
|
||||||
|
if (mb === null) return null
|
||||||
|
return `${(mb / 1024).toFixed(1)} GB used`
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartSubtitle = computed(() =>
|
// CPU: not in ServerStats today. Show null — never fabricate.
|
||||||
activeGame.value === 'all'
|
const soloCpu = computed(() => null as number | null)
|
||||||
? 'All servers · last 24 hours'
|
|
||||||
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} servers · last 24 hours`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Chart period toggle ----
|
const soloStatus = computed<'online' | 'offline' | 'starting'>(() => {
|
||||||
const chartPeriod = ref('24h')
|
|
||||||
const periodItems = [
|
|
||||||
{ value: '24h', label: '24h' },
|
|
||||||
{ value: '7d', label: '7d' },
|
|
||||||
{ value: '30d', label: '30d' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---- Solo: real store data + representative fallbacks ----
|
|
||||||
const soloName = computed(() => server.config?.server_name ?? 'Main · 2x Vanilla')
|
|
||||||
const soloPlayers = computed(() => server.stats?.player_count ?? 0)
|
|
||||||
const soloMaxPlayers = computed(() => server.stats?.max_players ?? server.config?.max_players ?? 200)
|
|
||||||
const soloFps = computed(() => server.stats?.fps ?? 59.8)
|
|
||||||
// Memory: store gives memory_usage_mb (no max), use 8192 MB representative max for %
|
|
||||||
const soloRamMb = computed(() => server.stats?.memory_usage_mb ?? 0)
|
|
||||||
const soloRamPct = computed(() => soloRamMb.value > 0 ? Math.round((soloRamMb.value / 8192) * 100) : 68)
|
|
||||||
const soloRamSub = computed(() => soloRamMb.value > 0 ? `${(soloRamMb.value / 1024).toFixed(1)} / 8 GB` : '5.4 / 8 GB')
|
|
||||||
// CPU: not in ServerStats; use representative value
|
|
||||||
const soloCpuPct = 41
|
|
||||||
// Status badge derived from connection_status
|
|
||||||
const soloStatus = computed<'online' | 'offline' | 'starting' | 'wiping'>(() => {
|
|
||||||
const cs = server.connection?.connection_status
|
const cs = server.connection?.connection_status
|
||||||
if (cs === 'connected') return 'online'
|
if (cs === 'connected') return 'online'
|
||||||
if (cs === 'degraded') return 'starting'
|
if (cs === 'degraded') return 'starting'
|
||||||
return 'offline'
|
return 'offline'
|
||||||
})
|
})
|
||||||
const soloStatusTone = computed<'online' | 'offline' | 'starting' | 'warn'>(() => {
|
const soloStatusTone = computed<'online' | 'offline' | 'warn'>(() => {
|
||||||
if (soloStatus.value === 'online') return 'online'
|
if (soloStatus.value === 'online') return 'online'
|
||||||
if (soloStatus.value === 'starting') return 'warn'
|
if (soloStatus.value === 'starting') return 'warn'
|
||||||
return 'offline'
|
return 'offline'
|
||||||
@@ -140,217 +101,282 @@ const soloStatusLabel = computed(() => {
|
|||||||
if (soloStatus.value === 'starting') return 'Degraded'
|
if (soloStatus.value === 'starting') return 'Degraded'
|
||||||
return 'Offline'
|
return 'Offline'
|
||||||
})
|
})
|
||||||
const soloRegion = computed(() => {
|
|
||||||
const ip = server.connection?.server_ip
|
|
||||||
return ip ? 'Bare metal' : 'US-East'
|
|
||||||
})
|
|
||||||
const soloIp = computed(() => {
|
const soloIp = computed(() => {
|
||||||
const ip = server.connection?.server_ip
|
const ip = server.connection?.server_ip
|
||||||
const port = server.connection?.game_port ?? server.connection?.server_port
|
const port = server.connection?.game_port ?? server.connection?.server_port
|
||||||
if (ip && port) return `${ip}:${port}`
|
if (ip && port) return `${ip}:${port}`
|
||||||
return '89.142.0.7:28015'
|
if (ip) return ip
|
||||||
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
const soloUptime = computed(() => {
|
const soloUptime = computed(() => {
|
||||||
const sec = server.stats?.uptime_seconds ?? 0
|
const sec = server.stats?.uptime_seconds ?? 0
|
||||||
if (sec === 0) return '—'
|
if (sec === 0) return null
|
||||||
const d = Math.floor(sec / 86400)
|
const d = Math.floor(sec / 86400)
|
||||||
const h = Math.floor((sec % 86400) / 3600)
|
const h = Math.floor((sec % 86400) / 3600)
|
||||||
return `${d}d ${h}h`
|
if (d > 0) return `${d}d ${h}h`
|
||||||
|
return `${h}h`
|
||||||
})
|
})
|
||||||
|
|
||||||
// Representative plugin list (uMod plugin state not in backend store)
|
// ---------------------------------------------------------------------------
|
||||||
const pluginStates = ref([
|
// Players chart — real 24h timeseries from /analytics/timeseries
|
||||||
{ name: 'RaidableBases', ver: '2.7.4', on: true },
|
// ---------------------------------------------------------------------------
|
||||||
{ name: 'Kits', ver: '4.3.1', on: true },
|
const chartData = ref<number[] | null>(null)
|
||||||
{ name: 'CorrosionTeleportGUI', ver: '1.2.0', on: true },
|
const chartLoading = ref(false)
|
||||||
{ name: 'Economics', ver: '3.9.6', on: true },
|
|
||||||
{ name: 'ZLevels Remastered', ver: '3.2.0', on: false },
|
|
||||||
])
|
|
||||||
|
|
||||||
|
async function loadChartData() {
|
||||||
|
chartLoading.value = true
|
||||||
|
try {
|
||||||
|
const ts = await api.get<TimeseriesData>('/analytics/timeseries?range=24&granularity=hourly')
|
||||||
|
chartData.value = ts.player_count.length > 0 ? ts.player_count : null
|
||||||
|
} catch {
|
||||||
|
// API unavailable or no data yet — chart will show "awaiting telemetry"
|
||||||
|
chartData.value = null
|
||||||
|
} finally {
|
||||||
|
chartLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wipe schedules — real data from wipeStore
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const nextWipe = computed<WipeSchedule | null>(() => {
|
||||||
|
const schedules = wipeStore.schedules.filter((s) => s.is_active && s.next_scheduled_run)
|
||||||
|
if (schedules.length === 0) return null
|
||||||
|
return schedules.slice().sort((a, b) => {
|
||||||
|
const at = a.next_scheduled_run ? new Date(a.next_scheduled_run).getTime() : Infinity
|
||||||
|
const bt = b.next_scheduled_run ? new Date(b.next_scheduled_run).getTime() : Infinity
|
||||||
|
return at - bt
|
||||||
|
})[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextWipeLabel = computed(() => {
|
||||||
|
const w = nextWipe.value
|
||||||
|
if (!w?.next_scheduled_run) return null
|
||||||
|
return safeDate(w.next_scheduled_run)
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextWipeType = computed(() => {
|
||||||
|
const w = nextWipe.value
|
||||||
|
if (!w) return null
|
||||||
|
const t = w.wipe_type
|
||||||
|
if (t === 'full') return `Full ${profile.value.terminology.reset}`
|
||||||
|
if (t === 'blueprint') return 'Blueprint wipe'
|
||||||
|
return `Map ${profile.value.terminology.reset}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Console lines — real WebSocket events only
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface ConsoleLine {
|
||||||
|
time: string
|
||||||
|
level: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
|
||||||
|
who?: string
|
||||||
|
msg: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleLines = ref<ConsoleLine[]>([])
|
||||||
|
const MAX_CONSOLE_LINES = 100
|
||||||
|
|
||||||
|
function now(): string {
|
||||||
|
return new Date().toLocaleTimeString('en-US', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWsMessage(msg: WebSocketMessage) {
|
||||||
|
if (msg.type !== 'event') return
|
||||||
|
|
||||||
|
// Live server stats
|
||||||
|
if (msg.event === 'server_stats' && msg.data) {
|
||||||
|
server.updateStats(msg.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console output lines
|
||||||
|
if (msg.event === 'console_output') {
|
||||||
|
const text = msg.data?.line ?? msg.data?.output ?? msg.raw ?? ''
|
||||||
|
if (!text) return
|
||||||
|
consoleLines.value.push({
|
||||||
|
time: now(),
|
||||||
|
level: 'info',
|
||||||
|
msg: String(text),
|
||||||
|
})
|
||||||
|
if (consoleLines.value.length > MAX_CONSOLE_LINES) {
|
||||||
|
consoleLines.value.splice(0, consoleLines.value.length - MAX_CONSOLE_LINES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
// Console input
|
// Console input
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const consoleInput = ref('')
|
const consoleInput = ref('')
|
||||||
|
|
||||||
function sendConsoleCommand() {
|
function sendConsoleCommand() {
|
||||||
if (!consoleInput.value.trim()) return
|
const cmd = consoleInput.value.trim()
|
||||||
server.sendCommand(consoleInput.value.trim()).catch(() => {})
|
if (!cmd) return
|
||||||
|
consoleLines.value.push({ time: now(), level: 'cmd', who: 'admin', msg: cmd })
|
||||||
|
server.sendCommand(cmd).catch(() => {})
|
||||||
consoleInput.value = ''
|
consoleInput.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await server.fetchServer()
|
||||||
|
await wipeStore.fetchSchedules()
|
||||||
|
await loadChartData()
|
||||||
|
|
||||||
|
const ws = useWebSocket()
|
||||||
|
unsubscribe = ws.subscribe(handleWsMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unsubscribe?.()
|
||||||
|
})
|
||||||
|
|
||||||
// Navigation helpers
|
// Navigation helpers
|
||||||
function navConsole() { router.push('/console') }
|
function navConsole() { router.push('/console') }
|
||||||
function navWipes() { router.push('/wipes') }
|
function navWipes() { router.push('/wipes') }
|
||||||
|
function navServer() { router.push('/server') }
|
||||||
// ---- Lifecycle ----
|
|
||||||
onMounted(() => {
|
|
||||||
server.fetchServer()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dash">
|
<div class="dash">
|
||||||
<!-- ===== FLEET VIEW ===== -->
|
|
||||||
<template v-if="view === 'fleet'">
|
<!-- ===== NO CONNECTION: honest empty state ===== -->
|
||||||
<!-- Page head -->
|
<template v-if="!server.isLoading && !hasConnection">
|
||||||
<div class="page__head">
|
<div class="page__head">
|
||||||
<div>
|
<div>
|
||||||
<div class="t-eyebrow">{{ scopeLabel }}</div>
|
<div class="t-eyebrow">Dashboard</div>
|
||||||
<h1 class="page__title">{{ fleetTitle }}</h1>
|
<h1 class="page__title">Server cockpit</h1>
|
||||||
</div>
|
|
||||||
<div class="page__actions">
|
|
||||||
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
|
|
||||||
<Button variant="secondary" size="sm" icon="download">Export</Button>
|
|
||||||
<Button size="sm" icon="rocket">Deploy server</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Panel>
|
||||||
<!-- KPIs -->
|
<EmptyState
|
||||||
<div class="dash__kpis">
|
icon="server"
|
||||||
<StatCard icon="server" label="Servers running" :value="String(runningCount)" :unit="'/' + inGame.length" delta="+1" note="today" />
|
title="No server connected"
|
||||||
<StatCard icon="users" label="Players online" :value="String(playersCur)" :unit="'/' + playersMax" delta="+38" note="since wipe" />
|
description="Install the Corrosion host agent on your host machine to begin managing your server from Corrosion."
|
||||||
<StatCard icon="cpu" :label="activeGame === 'all' ? 'Fleet CPU' : 'Avg CPU'" :value="avgCpu" :unit="avgCpu === '—' ? '' : '%'" note="reporting agents" />
|
|
||||||
<StatCard icon="server-cog" label="Agent nodes" value="2" unit="/2" note="all reporting" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main grid -->
|
|
||||||
<div class="dash__grid">
|
|
||||||
<!-- Left column -->
|
|
||||||
<div class="dash__col">
|
|
||||||
<!-- Players chart panel — themed ECharts -->
|
|
||||||
<Panel title="Players online" :subtitle="chartSubtitle">
|
|
||||||
<template #actions>
|
|
||||||
<Tabs v-model="chartPeriod" :items="periodItems" />
|
|
||||||
</template>
|
|
||||||
<PlayersChart :height="200" :max="200" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- Servers list -->
|
|
||||||
<Panel :flush-body="true" title="Servers">
|
|
||||||
<template #actions>
|
|
||||||
<Tabs v-model="serverStatus" :items="statusItems" />
|
|
||||||
</template>
|
|
||||||
<div class="server__list">
|
|
||||||
<ServerCard
|
|
||||||
v-for="(s, i) in shownServers"
|
|
||||||
:key="i"
|
|
||||||
:game="s.game"
|
|
||||||
:game-icon="s.gameIcon"
|
|
||||||
:name="s.name"
|
|
||||||
:region="s.region"
|
|
||||||
:map="s.map"
|
|
||||||
:version="s.version"
|
|
||||||
:status="s.status"
|
|
||||||
:players="s.players"
|
|
||||||
:cpu="s.cpu"
|
|
||||||
:ram="s.ram"
|
|
||||||
:ram-sub="s.ramSub"
|
|
||||||
:ip="s.ip"
|
|
||||||
:stats="buildStats(s)"
|
|
||||||
/>
|
|
||||||
<div v-if="shownServers.length === 0" class="server__empty">
|
|
||||||
No servers match the current filter.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right sidebar column -->
|
|
||||||
<div class="dash__col dash__col--side">
|
|
||||||
<!-- Live activity -->
|
|
||||||
<Panel :flush-body="true" title="Live activity">
|
|
||||||
<template #actions>
|
|
||||||
<Badge tone="online" :dot="true" :pulse="true">Live</Badge>
|
|
||||||
</template>
|
|
||||||
<div class="feed">
|
|
||||||
<ConsoleLine
|
|
||||||
v-for="(f, i) in MOCK_FEED"
|
|
||||||
:key="i"
|
|
||||||
:time="f.time"
|
|
||||||
:level="f.level"
|
|
||||||
:who="f.who"
|
|
||||||
>{{ f.msg }}</ConsoleLine>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- Upcoming wipes -->
|
|
||||||
<Panel title="Upcoming wipes">
|
|
||||||
<div class="wipes">
|
|
||||||
<div
|
|
||||||
v-for="(w, i) in MOCK_WIPES"
|
|
||||||
:key="i"
|
|
||||||
class="wipe"
|
|
||||||
:data-game="w.game"
|
|
||||||
>
|
>
|
||||||
<div class="wipe__dot" />
|
<template #action>
|
||||||
<div class="wipe__body">
|
<Button icon="server" @click="navServer">Set up server</Button>
|
||||||
<div class="wipe__name">{{ w.name }}</div>
|
</template>
|
||||||
<div class="wipe__when">{{ w.when }}</div>
|
</EmptyState>
|
||||||
</div>
|
|
||||||
<Badge :tone="w.tone" size="md">{{ w.label }}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ===== SOLO VIEW ===== -->
|
<!-- ===== SERVER COCKPIT ===== -->
|
||||||
<template v-else>
|
<template v-else-if="hasConnection">
|
||||||
|
|
||||||
<!-- Page head -->
|
<!-- Page head -->
|
||||||
<div class="page__head">
|
<div class="page__head">
|
||||||
<div class="solo-id">
|
<div class="solo-id">
|
||||||
<div class="solo-id__chip">
|
<div class="solo-id__chip">
|
||||||
<Icon name="box" :size="21" :stroke-width="2" />
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="solo-id__name">
|
<div class="solo-id__name">
|
||||||
{{ soloName }}
|
{{ soloName ?? 'Server' }}
|
||||||
<Badge :tone="soloStatusTone" :dot="true" :pulse="soloStatus === 'online'">{{ soloStatusLabel }}</Badge>
|
<Badge :tone="soloStatusTone" :dot="true" :pulse="soloStatus === 'online'">{{ soloStatusLabel }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="solo-id__meta">
|
<div class="solo-id__meta">
|
||||||
{{ soloRegion }} · {{ soloIp }}
|
<template v-if="soloIp">{{ soloIp }}</template>
|
||||||
<span v-if="soloUptime !== '—'"> · up {{ soloUptime }}</span>
|
<template v-else>No IP registered</template>
|
||||||
|
<template v-if="soloUptime"> · up {{ soloUptime }}</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="page__actions">
|
<div class="page__actions">
|
||||||
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
|
|
||||||
<Button variant="secondary" size="sm" icon="refresh-cw" @click="server.restartServer()">Restart</Button>
|
<Button variant="secondary" size="sm" icon="refresh-cw" @click="server.restartServer()">Restart</Button>
|
||||||
<Button variant="danger-soft" size="sm" icon="power" @click="server.stopServer()">Stop</Button>
|
<Button variant="danger-soft" size="sm" icon="power" @click="server.stopServer()">Stop</Button>
|
||||||
<Button size="sm" icon="terminal" @click="navConsole">Console</Button>
|
<Button size="sm" icon="terminal" @click="navConsole">Console</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KPIs -->
|
<!-- KPIs — game profile drives stat labels; null values show '—' -->
|
||||||
<div class="dash__kpis">
|
<div class="dash__kpis">
|
||||||
<StatCard icon="users" label="Players online" :value="String(soloPlayers)" :unit="'/' + soloMaxPlayers" delta="+12" note="since wipe" />
|
<StatCard
|
||||||
<StatCard icon="cpu" label="CPU" :value="String(soloCpuPct)" unit="%" delta="3%" delta-dir="down" note="agent · representative" />
|
icon="users"
|
||||||
<StatCard icon="memory-stick" label="Memory" :value="String(soloRamPct)" unit="%" :note="soloRamSub" />
|
:label="(profile.statFields[0] ?? 'Players') + ' online'"
|
||||||
<StatCard icon="gauge" label="Server FPS" :value="String(soloFps)" delta-dir="flat" note="stable" />
|
:value="soloPlayers !== null ? String(soloPlayers) : '—'"
|
||||||
|
:unit="soloMaxPlayers !== null ? '/' + soloMaxPlayers : ''"
|
||||||
|
note="live via agent"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="cpu"
|
||||||
|
label="CPU"
|
||||||
|
:value="soloCpu !== null ? String(soloCpu) : '—'"
|
||||||
|
:unit="soloCpu !== null ? '%' : ''"
|
||||||
|
note="agent telemetry"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="memory-stick"
|
||||||
|
label="Memory"
|
||||||
|
:value="soloRamMb !== null ? (soloRamMb / 1024).toFixed(1) : '—'"
|
||||||
|
:unit="soloRamMb !== null ? 'GB' : ''"
|
||||||
|
:note="soloRamSub ?? 'agent telemetry'"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="gauge"
|
||||||
|
label="Server FPS"
|
||||||
|
:value="soloFps !== null ? String(soloFps) : '—'"
|
||||||
|
:unit="soloFps !== null ? 'fps' : ''"
|
||||||
|
note="live via agent"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Solo grid -->
|
<!-- Main grid -->
|
||||||
<div class="dash__grid">
|
<div class="dash__grid">
|
||||||
|
|
||||||
<!-- Left column -->
|
<!-- Left column -->
|
||||||
<div class="dash__col">
|
<div class="dash__col">
|
||||||
<!-- Players chart — themed ECharts -->
|
|
||||||
<Panel title="Players online" :subtitle="soloName + ' · last 24 hours'">
|
<!-- Players chart — real 24h data or honest empty state -->
|
||||||
<PlayersChart :height="196" :max="soloMaxPlayers" />
|
<Panel
|
||||||
|
title="Players online"
|
||||||
|
:subtitle="(soloName ?? 'Server') + ' · last 24 hours'"
|
||||||
|
>
|
||||||
|
<div v-if="chartLoading" class="chart-loading">Loading telemetry…</div>
|
||||||
|
<PlayersChart
|
||||||
|
v-else
|
||||||
|
:height="196"
|
||||||
|
:max="soloMaxPlayers ?? 200"
|
||||||
|
:data="chartData ?? undefined"
|
||||||
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Console panel -->
|
<!-- Console — real WebSocket lines only -->
|
||||||
<Panel :flush-body="true" title="Console">
|
<Panel :flush-body="true" title="Console">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Badge tone="online" :dot="true" :pulse="soloStatus === 'online'">Live</Badge>
|
<Badge
|
||||||
|
:tone="isConnected ? 'online' : 'offline'"
|
||||||
|
:dot="true"
|
||||||
|
:pulse="isConnected"
|
||||||
|
>{{ isConnected ? 'Live' : 'Disconnected' }}</Badge>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="feed feed--solo">
|
<div class="feed feed--solo">
|
||||||
<ConsoleLine
|
<template v-if="consoleLines.length > 0">
|
||||||
v-for="(f, i) in MOCK_FEED"
|
<ConsoleLineDS
|
||||||
|
v-for="(line, i) in consoleLines"
|
||||||
:key="i"
|
:key="i"
|
||||||
:time="f.time"
|
:time="line.time"
|
||||||
:level="f.level"
|
:level="line.level"
|
||||||
:who="f.who"
|
:who="line.who"
|
||||||
>{{ f.msg }}</ConsoleLine>
|
>{{ line.msg }}</ConsoleLineDS>
|
||||||
|
</template>
|
||||||
|
<div v-else class="feed__empty">
|
||||||
|
<span v-if="isConnected">Waiting for output — try sending a command below</span>
|
||||||
|
<span v-else>Console offline — server is not connected</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="console-bar">
|
<div class="console-bar">
|
||||||
<span class="console-bar__prompt">></span>
|
<span class="console-bar__prompt">></span>
|
||||||
<Input
|
<Input
|
||||||
@@ -358,58 +384,88 @@ onMounted(() => {
|
|||||||
:mono="true"
|
:mono="true"
|
||||||
size="sm"
|
size="sm"
|
||||||
placeholder="say, kick, ban, oxide.reload …"
|
placeholder="say, kick, ban, oxide.reload …"
|
||||||
|
:disabled="!isConnected"
|
||||||
style="flex: 1"
|
style="flex: 1"
|
||||||
@keydown.enter="sendConsoleCommand"
|
@keydown.enter="sendConsoleCommand"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="secondary" icon="corner-down-left" @click="sendConsoleCommand">Send</Button>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="corner-down-left"
|
||||||
|
:disabled="!isConnected"
|
||||||
|
@click="sendConsoleCommand"
|
||||||
|
>Send</Button>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right sidebar -->
|
<!-- Right sidebar -->
|
||||||
<div class="dash__col dash__col--side">
|
<div class="dash__col dash__col--side">
|
||||||
<!-- Resources -->
|
|
||||||
<Panel title="Resources" subtitle="Companion agent telemetry">
|
<!-- Resources — real stats from agent; null = '—' -->
|
||||||
|
<Panel title="Resources" subtitle="Host agent telemetry">
|
||||||
<div class="solo-meters">
|
<div class="solo-meters">
|
||||||
<ResourceMeter label="CPU" :value="soloCpuPct" sub="representative" />
|
<ResourceMeter
|
||||||
<ResourceMeter label="Memory" :value="soloRamPct" :sub="soloRamSub" />
|
label="CPU"
|
||||||
<ResourceMeter label="Disk" :value="64" sub="representative" />
|
:value="soloCpu ?? 0"
|
||||||
|
:sub="soloCpu !== null ? soloCpu + '%' : 'awaiting telemetry'"
|
||||||
|
/>
|
||||||
|
<ResourceMeter
|
||||||
|
label="Memory"
|
||||||
|
:value="soloRamPct ?? 0"
|
||||||
|
:sub="soloRamSub ?? 'awaiting telemetry'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="soloCpu === null && soloRamMb === null" class="meters-note">
|
||||||
|
Resource metrics arrive via the host agent heartbeat.
|
||||||
|
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
|
||||||
|
Agent setup
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Plugins -->
|
<!-- Next wipe/reset — title follows game terminology -->
|
||||||
<Panel :flush-body="true" title="Plugins" subtitle="uMod / Oxide">
|
<Panel :title="'Next ' + profile.terminology.reset.toLowerCase()">
|
||||||
<template #actions>
|
<div v-if="nextWipe" class="solo-wipe">
|
||||||
<Button size="sm" variant="ghost" icon="plus" @click="router.push('/plugins')">Add</Button>
|
|
||||||
</template>
|
|
||||||
<div class="plugs">
|
|
||||||
<div
|
|
||||||
v-for="(p, i) in pluginStates"
|
|
||||||
:key="i"
|
|
||||||
class="plug"
|
|
||||||
>
|
|
||||||
<div class="plug__id">
|
|
||||||
<span class="plug__name">{{ p.name }}</span>
|
|
||||||
<span class="plug__ver">{{ p.ver }}</span>
|
|
||||||
</div>
|
|
||||||
<Switch v-model="p.on" size="sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- Next wipe -->
|
|
||||||
<Panel title="Next wipe">
|
|
||||||
<div class="solo-wipe">
|
|
||||||
<div>
|
<div>
|
||||||
<div class="solo-wipe__when">Thu · 18:00 UTC</div>
|
<div class="solo-wipe__type">{{ nextWipeType }}</div>
|
||||||
<div class="solo-wipe__sub">representative — configure in wipe manager</div>
|
<div class="solo-wipe__when">{{ nextWipeLabel }}</div>
|
||||||
|
<div class="solo-wipe__name">{{ nextWipe.schedule_name }}</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">Edit</Button>
|
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">Edit</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
icon="calendar"
|
||||||
|
:title="'No ' + profile.terminology.reset.toLowerCase() + ' scheduled'"
|
||||||
|
:description="'Configure automatic ' + profile.terminology.reset.toLowerCase() + 's in the wipe manager.'"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">
|
||||||
|
Open wipe manager
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="page__head">
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Dashboard</div>
|
||||||
|
<h1 class="page__title">Server cockpit</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Panel>
|
||||||
|
<div class="dash-loading">Loading server data…</div>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -431,23 +487,6 @@ onMounted(() => {
|
|||||||
.dash__grid { display: grid; grid-template-columns: minmax(0, 1fr) 366px; gap: 16px; align-items: start; }
|
.dash__grid { display: grid; grid-template-columns: minmax(0, 1fr) 366px; gap: 16px; align-items: start; }
|
||||||
.dash__col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
.dash__col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
||||||
|
|
||||||
/* ---------- Servers list ---------- */
|
|
||||||
.server__list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 14px; }
|
|
||||||
.server__empty { grid-column: 1 / -1; font-size: var(--text-sm); color: var(--text-muted); text-align: center; padding: 24px; }
|
|
||||||
|
|
||||||
/* ---------- Live feed ---------- */
|
|
||||||
.feed { padding: 8px 2px; max-height: 340px; overflow-y: auto; }
|
|
||||||
.feed--solo { max-height: 230px; }
|
|
||||||
|
|
||||||
/* ---------- Upcoming wipes ---------- */
|
|
||||||
.wipes { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.wipe { display: flex; align-items: center; gap: 11px; padding: 9px 6px; border-radius: var(--radius-md); }
|
|
||||||
.wipe:hover { background: var(--surface-hover); }
|
|
||||||
.wipe__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; box-shadow: 0 0 10px -1px var(--accent-glow); }
|
|
||||||
.wipe__body { flex: 1; min-width: 0; }
|
|
||||||
.wipe__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.wipe__when { font-family: var(--font-mono); font-size: 11px; color: var(--text-tertiary); margin-top: 1px; }
|
|
||||||
|
|
||||||
/* ---------- Solo identity header ---------- */
|
/* ---------- Solo identity header ---------- */
|
||||||
.solo-id { display: flex; align-items: center; gap: 13px; }
|
.solo-id { display: flex; align-items: center; gap: 13px; }
|
||||||
.solo-id__chip {
|
.solo-id__chip {
|
||||||
@@ -463,6 +502,21 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.solo-id__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 3px; }
|
.solo-id__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 3px; }
|
||||||
|
|
||||||
|
/* ---------- Chart loading ---------- */
|
||||||
|
.chart-loading {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
height: 196px; font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Console feed ---------- */
|
||||||
|
.feed { padding: 8px 2px; max-height: 340px; overflow-y: auto; }
|
||||||
|
.feed--solo { max-height: 230px; }
|
||||||
|
.feed__empty {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
height: 100px; font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Console bar ---------- */
|
/* ---------- Console bar ---------- */
|
||||||
.console-bar {
|
.console-bar {
|
||||||
display: flex; align-items: center; gap: 9px; padding: 11px 12px;
|
display: flex; align-items: center; gap: 9px; padding: 11px 12px;
|
||||||
@@ -472,24 +526,28 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* ---------- Resources ---------- */
|
/* ---------- Resources ---------- */
|
||||||
.solo-meters { display: flex; flex-direction: column; gap: 13px; }
|
.solo-meters { display: flex; flex-direction: column; gap: 13px; }
|
||||||
|
.meters-note {
|
||||||
/* ---------- Plugin list ---------- */
|
margin-top: 14px; font-size: var(--text-xs); color: var(--text-muted);
|
||||||
.plugs { display: flex; flex-direction: column; }
|
border-top: 1px solid var(--border-subtle); padding-top: 12px;
|
||||||
.plug { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); }
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
.plug:last-child { border-bottom: 0; }
|
}
|
||||||
.plug__id { display: flex; align-items: baseline; gap: 9px; min-width: 0; }
|
.meters-cta { margin-left: auto; }
|
||||||
.plug__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
|
||||||
.plug__ver { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* ---------- Next wipe ---------- */
|
/* ---------- Next wipe ---------- */
|
||||||
.solo-wipe { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
.solo-wipe { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||||
.solo-wipe__when { font-family: var(--font-mono); font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
|
.solo-wipe__type { font-size: var(--text-xs); color: var(--text-tertiary); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-wider); margin-bottom: 3px; }
|
||||||
.solo-wipe__sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
.solo-wipe__when { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
||||||
|
.solo-wipe__name { font-size: var(--text-xs); color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ---------- Loading ---------- */
|
||||||
|
.dash-loading {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 60px; font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Responsive ---------- */
|
/* ---------- Responsive ---------- */
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.dash__grid { grid-template-columns: 1fr; }
|
.dash__grid { grid-template-columns: 1fr; }
|
||||||
.server__list { grid-template-columns: 1fr; }
|
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dash__kpis { grid-template-columns: repeat(2, 1fr); }
|
.dash__kpis { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ onMounted(() => {
|
|||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Alert tone="info">
|
<Alert tone="info">
|
||||||
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
|
The plugin will be registered in your plugin list immediately. Your host agent must be connected
|
||||||
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { useThemeGame } from '@/composables/useThemeGame'
|
||||||
|
import { useGameProfile } from '@/config/gameProfiles'
|
||||||
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
import Panel from '@/components/ds/data/Panel.vue'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
@@ -11,6 +13,7 @@ import Badge from '@/components/ds/core/Badge.vue'
|
|||||||
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||||
import Icon from '@/components/ds/core/Icon.vue'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
import Alert from '@/components/ds/feedback/Alert.vue'
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
import Input from '@/components/ds/forms/Input.vue'
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
import Switch from '@/components/ds/forms/Switch.vue'
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
@@ -18,6 +21,39 @@ import Tabs from '@/components/ds/navigation/Tabs.vue'
|
|||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
const { activeGame } = useThemeGame()
|
||||||
|
|
||||||
|
// Profile follows the GameSwitcher. 'all' defaults to rust (neutral house skin).
|
||||||
|
const profile = computed(() => {
|
||||||
|
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
|
||||||
|
return useGameProfile(game)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Game-specific derived flags
|
||||||
|
const isRust = computed(() => profile.value.mods === 'umod')
|
||||||
|
const hasPluginSystem = computed(() => profile.value.mods === 'umod')
|
||||||
|
const isDockerManaged = computed(() => profile.value.managementModel === 'docker-compose')
|
||||||
|
|
||||||
|
// Management model human label for the identity badge
|
||||||
|
const managementModelLabel = computed(() => {
|
||||||
|
const m = profile.value.managementModel
|
||||||
|
const c = profile.value.console
|
||||||
|
if (m === 'docker-compose') {
|
||||||
|
return profile.value.clustering === 'battlegroup' ? 'Docker · BattleGroup' : 'Docker · Compose'
|
||||||
|
}
|
||||||
|
if (c === 'rcon+ingame') return 'Process · RCON + In-game'
|
||||||
|
if (c === 'rcon+gm') return 'Process · RCON + GM'
|
||||||
|
return 'Process · RCON'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clustering section label per game
|
||||||
|
const clusterLabel = computed(() => {
|
||||||
|
const cl = profile.value.clustering
|
||||||
|
if (cl === 'battlegroup') return 'BattleGroups & Sietches'
|
||||||
|
if (cl === 'main-client') return 'Cluster'
|
||||||
|
if (cl === 'character-transfer') return 'Clans & Character Transfer'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
const editMode = ref(false)
|
const editMode = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -64,22 +100,22 @@ const agentLastSeenLabel = computed(() => {
|
|||||||
const licenseKey = computed(() => auth.license?.license_key || 'YOUR-LICENSE-KEY')
|
const licenseKey = computed(() => auth.license?.license_key || 'YOUR-LICENSE-KEY')
|
||||||
|
|
||||||
const linuxCommands = computed(() => `# Download the agent
|
const linuxCommands = computed(() => `# Download the agent
|
||||||
curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64
|
curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64
|
||||||
chmod +x corrosion-companion-linux-amd64
|
chmod +x corrosion-host-agent-linux-amd64
|
||||||
|
|
||||||
# Start with your license key
|
# Start with your license key
|
||||||
export LICENSE_ID="${licenseKey.value}"
|
export LICENSE_ID="${licenseKey.value}"
|
||||||
export NATS_URL="nats://nats.corrosionmgmt.com:4222"
|
export NATS_URL="nats://nats.corrosionmgmt.com:4222"
|
||||||
./corrosion-companion-linux-amd64`)
|
./corrosion-host-agent-linux-amd64`)
|
||||||
|
|
||||||
const windowsCommands = computed(() => `# Requires PowerShell (not Command Prompt)
|
const windowsCommands = computed(() => `# Requires PowerShell (not Command Prompt)
|
||||||
# Download the agent
|
# Download the agent
|
||||||
Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe" -OutFile "corrosion-companion-windows-amd64.exe"
|
Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe" -OutFile "corrosion-host-agent-windows-amd64.exe"
|
||||||
|
|
||||||
# Start with your license key
|
# Start with your license key
|
||||||
$env:LICENSE_ID="${licenseKey.value}"
|
$env:LICENSE_ID="${licenseKey.value}"
|
||||||
$env:NATS_URL="nats://nats.corrosionmgmt.com:4222"
|
$env:NATS_URL="nats://nats.corrosionmgmt.com:4222"
|
||||||
.\\corrosion-companion-windows-amd64.exe`)
|
.\\corrosion-host-agent-windows-amd64.exe`)
|
||||||
|
|
||||||
async function copySetupCommands() {
|
async function copySetupCommands() {
|
||||||
try {
|
try {
|
||||||
@@ -278,17 +314,18 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sv">
|
<div class="sv">
|
||||||
<!-- Page head -->
|
<!-- Page head — game-aware identity -->
|
||||||
<div class="sv__head">
|
<div class="sv__head">
|
||||||
<div class="sv__head-id">
|
<div class="sv__head-id">
|
||||||
<div class="sv__head-chip">
|
<div class="sv__head-chip">
|
||||||
<Icon name="server" :size="20" :stroke-width="2" />
|
<Icon name="server" :size="20" :stroke-width="2" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="t-eyebrow">Server management</div>
|
<div class="t-eyebrow">{{ profile.label }} · Server management</div>
|
||||||
<h1 class="sv__title">Server</h1>
|
<h1 class="sv__title">Server</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Badge tone="neutral" :mono="true" class="sv__model-badge">{{ managementModelLabel }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connection -->
|
<!-- Connection -->
|
||||||
@@ -350,8 +387,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Companion agent -->
|
<!-- Host agent -->
|
||||||
<Panel title="Companion agent" subtitle="Bare-metal server management binary">
|
<Panel title="Host agent" subtitle="Bare-metal server management binary">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Badge :tone="isAgentConnected ? 'online' : 'offline'" :dot="true" :pulse="isAgentConnected">
|
<Badge :tone="isAgentConnected ? 'online' : 'offline'" :dot="true" :pulse="isAgentConnected">
|
||||||
{{ isAgentConnected ? 'Active' : 'Inactive' }}
|
{{ isAgentConnected ? 'Active' : 'Inactive' }}
|
||||||
@@ -380,20 +417,20 @@ onMounted(async () => {
|
|||||||
<!-- Download -->
|
<!-- Download -->
|
||||||
<div class="sv__section-head">
|
<div class="sv__section-head">
|
||||||
<Icon name="download" :size="14" />
|
<Icon name="download" :size="14" />
|
||||||
<span>Download companion agent</span>
|
<span>Download host agent</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sv__downloads sv__mb">
|
<div class="sv__downloads sv__mb">
|
||||||
<a
|
<a
|
||||||
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64"
|
href="https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64"
|
||||||
download="corrosion-companion-linux-amd64"
|
download="corrosion-host-agent-linux-amd64"
|
||||||
class="sv__dl-link"
|
class="sv__dl-link"
|
||||||
>
|
>
|
||||||
<Icon name="download" :size="15" />
|
<Icon name="download" :size="15" />
|
||||||
Linux (amd64)
|
Linux (amd64)
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"
|
href="https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"
|
||||||
download="corrosion-companion-windows-amd64.exe"
|
download="corrosion-host-agent-windows-amd64.exe"
|
||||||
class="sv__dl-link"
|
class="sv__dl-link"
|
||||||
>
|
>
|
||||||
<Icon name="download" :size="15" />
|
<Icon name="download" :size="15" />
|
||||||
@@ -424,28 +461,28 @@ onMounted(async () => {
|
|||||||
<!-- Linux commands -->
|
<!-- Linux commands -->
|
||||||
<div v-if="setupTab === 'linux'" class="sv__codeblock">
|
<div v-if="setupTab === 'linux'" class="sv__codeblock">
|
||||||
<p class="sv__cmt"># Download the agent</p>
|
<p class="sv__cmt"># Download the agent</p>
|
||||||
<p>curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64</p>
|
<p>curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
||||||
<p>chmod +x corrosion-companion-linux-amd64</p>
|
<p>chmod +x corrosion-host-agent-linux-amd64</p>
|
||||||
<p class="sv__cmt sv__mt"># Start with your license key</p>
|
<p class="sv__cmt sv__mt"># Start with your license key</p>
|
||||||
<p>export LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
|
<p>export LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
|
||||||
<p>export NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
<p>export NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
||||||
<p>./corrosion-companion-linux-amd64</p>
|
<p>./corrosion-host-agent-linux-amd64</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Windows commands -->
|
<!-- Windows commands -->
|
||||||
<div v-if="setupTab === 'windows'" class="sv__codeblock">
|
<div v-if="setupTab === 'windows'" class="sv__codeblock">
|
||||||
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
|
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
|
||||||
<p class="sv__cmt"># Download the agent</p>
|
<p class="sv__cmt"># Download the agent</p>
|
||||||
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-companion-windows-amd64.exe"</span></p>
|
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-host-agent-windows-amd64.exe"</span></p>
|
||||||
<p class="sv__cmt sv__mt"># Start with your license key</p>
|
<p class="sv__cmt sv__mt"># Start with your license key</p>
|
||||||
<p>$env:LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
|
<p>$env:LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
|
||||||
<p>$env:NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
<p>$env:NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
||||||
<p>.\corrosion-companion-windows-amd64.exe</p>
|
<p>.\corrosion-host-agent-windows-amd64.exe</p>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Deploy Rust Server -->
|
<!-- Deploy Server — Rust only (SteamCMD path). Other games use docker-compose or external tooling. -->
|
||||||
<Panel title="Deploy Rust server" subtitle="One-click: SteamCMD, download, configure, start">
|
<Panel v-if="isRust" title="Deploy Rust server" subtitle="One-click: SteamCMD, download, configure, start">
|
||||||
<template #title-append>
|
<template #title-append>
|
||||||
<Icon name="rocket" :size="15" />
|
<Icon name="rocket" :size="15" />
|
||||||
</template>
|
</template>
|
||||||
@@ -560,8 +597,28 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Install Oxide / uMod -->
|
<!-- Non-Rust: Docker-managed server note -->
|
||||||
<Panel title="Install Oxide / uMod" subtitle="Required for all plugins including CorrosionCompanion">
|
<Panel
|
||||||
|
v-if="isDockerManaged"
|
||||||
|
:title="profile.label + ' server deployment'"
|
||||||
|
subtitle="Managed via Docker Compose"
|
||||||
|
>
|
||||||
|
<template #title-append>
|
||||||
|
<Icon name="box" :size="15" />
|
||||||
|
</template>
|
||||||
|
<EmptyState
|
||||||
|
icon="box"
|
||||||
|
title="Docker-managed deployment"
|
||||||
|
:description="profile.label + ' servers are managed via Docker Compose. Connect the host agent on your Docker host to enable lifecycle management.'"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Badge tone="info">Docker · Compose</Badge>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Install Oxide / uMod — Rust only -->
|
||||||
|
<Panel v-if="hasPluginSystem" title="Install Oxide / uMod" subtitle="Required for all plugins including CorrosionCompanion">
|
||||||
<template #title-append>
|
<template #title-append>
|
||||||
<Icon name="puzzle" :size="15" />
|
<Icon name="puzzle" :size="15" />
|
||||||
</template>
|
</template>
|
||||||
@@ -611,6 +668,79 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Workshop Mods info — Conan / Soulmask (Steam Workshop, no install step needed) -->
|
||||||
|
<Panel
|
||||||
|
v-else-if="profile.mods === 'workshop'"
|
||||||
|
:title="(profile.terminology.mods ?? 'Workshop Mods')"
|
||||||
|
:subtitle="profile.label + ' uses Steam Workshop — no manual install step required'"
|
||||||
|
>
|
||||||
|
<template #title-append>
|
||||||
|
<Icon name="layers" :size="15" />
|
||||||
|
</template>
|
||||||
|
<EmptyState
|
||||||
|
icon="layers"
|
||||||
|
:title="profile.label + ' mod management'"
|
||||||
|
:description="profile.label + ' loads mods directly from Steam Workshop. Manage your mod list in server config — no Corrosion install step needed.'"
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Conan Exiles special concepts (Clans / Thralls / Purge) -->
|
||||||
|
<Panel
|
||||||
|
v-if="profile.accent === 'conan'"
|
||||||
|
title="Conan Exiles concepts"
|
||||||
|
subtitle="Key admin mechanics for Conan Exiles servers"
|
||||||
|
>
|
||||||
|
<div class="sv__concept-grid">
|
||||||
|
<div class="sv__concept">
|
||||||
|
<Icon name="users" :size="16" />
|
||||||
|
<div>
|
||||||
|
<div class="sv__concept-label">Clans</div>
|
||||||
|
<div class="sv__concept-desc">Player factions. Clan management via in-game admin panel or RCON.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sv__concept">
|
||||||
|
<Icon name="zap" :size="16" />
|
||||||
|
<div>
|
||||||
|
<div class="sv__concept-label">Thralls & Avatars</div>
|
||||||
|
<div class="sv__concept-desc">Server-controlled NPCs and deity summons. Purge cycle managed via server settings.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sv__concept">
|
||||||
|
<Icon name="shield" :size="16" />
|
||||||
|
<div>
|
||||||
|
<div class="sv__concept-label">Purge</div>
|
||||||
|
<div class="sv__concept-desc">NPC raid events targeting player bases. Enable / tune via server config.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Soulmask clustering section -->
|
||||||
|
<Panel
|
||||||
|
v-if="profile.clustering === 'main-client'"
|
||||||
|
:title="clusterLabel"
|
||||||
|
subtitle="Main-client cluster topology for Soulmask"
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
icon="network"
|
||||||
|
title="Cluster management coming soon"
|
||||||
|
:description="'Connect a ' + profile.label + ' host to manage the main-client cluster from this panel. Cluster configuration requires the host agent.'"
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Dune BattleGroup / Sietches section -->
|
||||||
|
<Panel
|
||||||
|
v-if="profile.clustering === 'battlegroup'"
|
||||||
|
title="BattleGroups & Sietches"
|
||||||
|
subtitle="Dune: Awakening server cluster topology"
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
icon="map"
|
||||||
|
title="Sietch management requires a connected Dune host"
|
||||||
|
description="Connect the host agent on your Dune: Awakening Docker host to manage BattleGroups and Sietches from this panel."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Configuration -->
|
<!-- Configuration -->
|
||||||
<Panel title="Configuration">
|
<Panel title="Configuration">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
@@ -708,8 +838,13 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="sv__toggle-row">
|
<div class="sv__toggle-row">
|
||||||
<div class="sv__toggle-body">
|
<div class="sv__toggle-body">
|
||||||
<div class="sv__toggle-label">Auto-update on force wipe</div>
|
<div class="sv__toggle-label">
|
||||||
<div class="sv__toggle-sub">Update when Facepunch pushes</div>
|
<!-- Rust: "force wipe" is a Facepunch concept. Others: plain "auto-update" -->
|
||||||
|
{{ isRust ? 'Auto-update on force wipe' : 'Auto-update on patch' }}
|
||||||
|
</div>
|
||||||
|
<div class="sv__toggle-sub">
|
||||||
|
{{ isRust ? 'Update when Facepunch pushes' : 'Update when the developer pushes a patch' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="server.config?.auto_update_on_force_wipe ?? false"
|
:model-value="server.config?.auto_update_on_force_wipe ?? false"
|
||||||
@@ -717,7 +852,8 @@ onMounted(async () => {
|
|||||||
@update:model-value="toggleAutomation('auto_update_on_force_wipe')"
|
@update:model-value="toggleAutomation('auto_update_on_force_wipe')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="sv__toggle-row">
|
<!-- Rust-only: force wipe eligibility is a Facepunch concept -->
|
||||||
|
<div v-if="isRust" class="sv__toggle-row">
|
||||||
<div class="sv__toggle-body">
|
<div class="sv__toggle-body">
|
||||||
<div class="sv__toggle-label">Force wipe eligible</div>
|
<div class="sv__toggle-label">Force wipe eligible</div>
|
||||||
<div class="sv__toggle-sub">Server participates in force wipes</div>
|
<div class="sv__toggle-sub">Server participates in force wipes</div>
|
||||||
@@ -848,4 +984,19 @@ onMounted(async () => {
|
|||||||
.sv__toggle-row:first-child { padding-top: 0; }
|
.sv__toggle-row:first-child { padding-top: 0; }
|
||||||
.sv__toggle-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
.sv__toggle-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
.sv__toggle-sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
.sv__toggle-sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Management model badge in page head */
|
||||||
|
.sv__model-badge { align-self: center; }
|
||||||
|
|
||||||
|
/* Game concept cards (Conan Exiles special features) */
|
||||||
|
.sv__concept-grid { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.sv__concept {
|
||||||
|
display: flex; align-items: flex-start; gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--surface-raised); border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.sv__concept-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); margin-bottom: 2px; }
|
||||||
|
.sv__concept-desc { font-size: var(--text-xs); color: var(--text-tertiary); line-height: 1.5; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dashboard mock data — representative placeholder pending multi-instance backend.
|
|
||||||
* Current backend is single-server-per-license; the fleet view is a forward-looking
|
|
||||||
* surface that will bind to a multi-instance API. All data here is static and clearly
|
|
||||||
* labeled so it is never confused for real tenant data.
|
|
||||||
*
|
|
||||||
* Per-game fields are isolated by game key — a Dune row NEVER receives a Rust field
|
|
||||||
* like `umod`, and vice-versa. See GAME_FIELDS for the row-field contract.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type ServerStatus = 'online' | 'offline' | 'starting' | 'wiping' | 'updating'
|
|
||||||
export type GameKey = 'rust' | 'dune' | 'conan' | 'soulmask'
|
|
||||||
|
|
||||||
export interface MockServer {
|
|
||||||
game: GameKey
|
|
||||||
gameIcon: string
|
|
||||||
name: string
|
|
||||||
region: string
|
|
||||||
map: string
|
|
||||||
version: string
|
|
||||||
status: ServerStatus
|
|
||||||
players: { cur: number; max: number }
|
|
||||||
cpu?: number
|
|
||||||
ram?: number
|
|
||||||
ramSub?: string
|
|
||||||
ip: string
|
|
||||||
// Rust-only
|
|
||||||
umod?: string
|
|
||||||
wipe?: string
|
|
||||||
// Dune-only
|
|
||||||
sietches?: string
|
|
||||||
control?: string
|
|
||||||
// Conan-only
|
|
||||||
clans?: string
|
|
||||||
purge?: string
|
|
||||||
// Soulmask-only
|
|
||||||
tribe?: string
|
|
||||||
mask?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MockFeedLine {
|
|
||||||
time: string
|
|
||||||
level: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
|
|
||||||
who?: string
|
|
||||||
msg: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MockWipe {
|
|
||||||
game: GameKey
|
|
||||||
name: string
|
|
||||||
when: string
|
|
||||||
tone: 'wiping' | 'starting' | 'warn' | 'online'
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatItem {
|
|
||||||
label: string
|
|
||||||
value: string | number
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Fleet server roster
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const MOCK_SERVERS: MockServer[] = [
|
|
||||||
{
|
|
||||||
game: 'rust', gameIcon: 'box', name: 'Main · 2x Vanilla', region: 'US-East',
|
|
||||||
map: 'Procedural 4500', version: 'v2024.12', status: 'online',
|
|
||||||
players: { cur: 142, max: 200 }, cpu: 41, ram: 68, ramSub: '5.4 / 8 GB',
|
|
||||||
ip: '89.142.0.7:28015', umod: '14', wipe: '2d',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'rust', gameIcon: 'box', name: '5x Modded · Build', region: 'US-East',
|
|
||||||
map: 'Barren 3000', version: 'v2024.12', status: 'online',
|
|
||||||
players: { cur: 38, max: 100 }, ip: '89.142.0.7:28017', umod: '27', wipe: '2d',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'rust', gameIcon: 'box', name: 'Hardcore · Solo/Duo', region: 'US-West',
|
|
||||||
map: 'Procedural 3500', version: 'v2024.12', status: 'wiping',
|
|
||||||
players: { cur: 0, max: 80 }, cpu: 8, ram: 30, ramSub: '2.4 / 8 GB',
|
|
||||||
ip: '74.91.3.2:28015', umod: '9', wipe: 'now',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'dune', gameIcon: 'sun', name: 'Arrakis · Hardcore', region: 'EU-Frankfurt',
|
|
||||||
map: 'Hagga Basin', version: 'v0.9.4', status: 'online',
|
|
||||||
players: { cur: 54, max: 60 }, cpu: 63, ram: 74, ramSub: '11.8 / 16 GB',
|
|
||||||
ip: '51.83.12.4:7777', sietches: '3', control: '62%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'dune', gameIcon: 'sun', name: 'Deep Desert · PvP', region: 'EU-Frankfurt',
|
|
||||||
map: 'Deep Desert', version: 'v0.9.4', status: 'starting',
|
|
||||||
players: { cur: 0, max: 40 }, ip: '51.83.12.4:7779', sietches: '0', control: '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'dune', gameIcon: 'sun', name: 'Sietch · Roleplay', region: 'SG-Singapore',
|
|
||||||
map: 'Hagga Basin', version: 'v0.9.4', status: 'offline',
|
|
||||||
players: { cur: 0, max: 50 }, ip: '139.99.4.8:7777', sietches: '5', control: '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'conan', gameIcon: 'swords', name: 'Exiled Lands · PvP-C', region: 'US-East',
|
|
||||||
map: 'Exiled Lands', version: 'v3.0.5', status: 'online',
|
|
||||||
players: { cur: 32, max: 40 }, cpu: 48, ram: 60, ramSub: '9.6 / 16 GB',
|
|
||||||
ip: '89.142.0.7:7777', clans: '7', purge: 'Tier 4',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'soulmask', gameIcon: 'drama', name: 'Sienna Plateau · PvE', region: 'EU-Frankfurt',
|
|
||||||
map: 'Sienna Plateau', version: 'v1.4', status: 'online',
|
|
||||||
players: { cur: 18, max: 30 }, cpu: 35, ram: 52, ramSub: '8.3 / 16 GB',
|
|
||||||
ip: '51.83.12.4:8777', tribe: '4', mask: 'Jaguar',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Per-game stat field sets — never share slots across games
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function pl(s: MockServer): string {
|
|
||||||
return `${s.players.cur} / ${s.players.max}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GAME_FIELDS: Record<GameKey, (s: MockServer) => StatItem[]> = {
|
|
||||||
rust: (s) => [{ label: 'Players', value: pl(s) }, { label: 'uMod', value: s.umod ?? '—' }, { label: 'Wipe', value: s.wipe ?? '—' }],
|
|
||||||
dune: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Sietches', value: s.sietches ?? '—' }, { label: 'Control', value: s.control ?? '—' }],
|
|
||||||
conan: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Clans', value: s.clans ?? '—' }, { label: 'Purge', value: s.purge ?? '—' }],
|
|
||||||
soulmask: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Tribe', value: s.tribe ?? '—' }, { label: 'Mask', value: s.mask ?? '—' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildStats(s: MockServer): StatItem[] {
|
|
||||||
const fn = GAME_FIELDS[s.game] ?? GAME_FIELDS.rust
|
|
||||||
return fn(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Live activity feed
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const MOCK_FEED: MockFeedLine[] = [
|
|
||||||
{ time: '18:42:07', level: 'connect', who: 'ShadowFox', msg: 'connected — 89.142.0.7' },
|
|
||||||
{ time: '18:41:55', level: 'cmd', who: 'admin', msg: 'oxide.grant group default kits.use' },
|
|
||||||
{ time: '18:41:30', level: 'kill', who: 'ironMaiden', msg: 'was killed by Scorpion (AK-47, 84m)' },
|
|
||||||
{ time: '18:40:12', level: 'warn', msg: '5x Modded agent reconnected — telemetry resuming' },
|
|
||||||
{ time: '18:39:48', level: 'chat', who: 'BlightWalker:', msg: 'anyone selling sulfur?' },
|
|
||||||
{ time: '18:38:02', level: 'info', msg: 'RaidableBases spawned Tier-3 at G14' },
|
|
||||||
{ time: '18:36:51', level: 'connect', who: 'Vex', msg: 'connected — 51.83.12.4' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Upcoming wipes
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const MOCK_WIPES: MockWipe[] = [
|
|
||||||
{ game: 'rust', name: 'Main · 2x Vanilla', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map + BP' },
|
|
||||||
{ game: 'rust', name: '5x Modded · Build', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map only' },
|
|
||||||
{ game: 'dune', name: 'Deep Desert · PvP', when: 'Sun · 12:00 UTC', tone: 'starting', label: 'Deep Desert' },
|
|
||||||
]
|
|
||||||
@@ -191,6 +191,8 @@ function handleBackToLogin() {
|
|||||||
<p v-if="!showTotpInput" class="auth-footer">
|
<p v-if="!showTotpInput" class="auth-footer">
|
||||||
No account?
|
No account?
|
||||||
<router-link to="/register" class="auth-footer__link">Create one</router-link>
|
<router-link to="/register" class="auth-footer__link">Create one</router-link>
|
||||||
|
·
|
||||||
|
<router-link to="/forgot-password" class="auth-footer__link">Forgot password?</router-link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function syncPorts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connectionTypes = [
|
const connectionTypes = [
|
||||||
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via Companion Agent' },
|
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via Corrosion host agent' },
|
||||||
{ value: 'amp', label: 'AMP (CubeCoders)', desc: 'Connect through AMP panel API' },
|
{ value: 'amp', label: 'AMP (CubeCoders)', desc: 'Connect through AMP panel API' },
|
||||||
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
|
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
|
||||||
]
|
]
|
||||||
@@ -183,7 +183,7 @@ async function completeSetup() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Companion agent install -->
|
<!-- Step 2: Corrosion host agent install -->
|
||||||
<div v-if="step === 2" class="setup-card">
|
<div v-if="step === 2" class="setup-card">
|
||||||
<div class="setup-card__head setup-card__head--center">
|
<div class="setup-card__head setup-card__head--center">
|
||||||
<div class="setup-icon">
|
<div class="setup-icon">
|
||||||
@@ -191,19 +191,22 @@ async function completeSetup() {
|
|||||||
<path d="M5 12.55a11 11 0 0 1 14.08 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01" />
|
<path d="M5 12.55a11 11 0 0 1 14.08 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="setup-card__title">Install the Companion Agent</h1>
|
<h1 class="setup-card__title">Install the Corrosion host agent</h1>
|
||||||
<p class="setup-card__sub">The agent runs on your server and connects to Corrosion — no inbound ports required.</p>
|
<p class="setup-card__sub">The agent runs on your server and connects to Corrosion — no inbound ports required.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setup-code">
|
<div class="setup-code">
|
||||||
<p class="setup-code__comment"># Download and install the Companion Agent</p>
|
<p class="setup-code__comment"># Download the Corrosion host agent (Linux)</p>
|
||||||
<p class="setup-code__cmd">curl -sSL https://get.corrosionmgmt.com | sh</p>
|
<p class="setup-code__cmd">curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
||||||
<p class="setup-code__comment setup-code__comment--mt"># Start the agent with your license key</p>
|
<p class="setup-code__cmd">chmod +x corrosion-host-agent-linux-amd64</p>
|
||||||
<p class="setup-code__cmd">corrosion-agent start --key {{ auth.license?.license_key ?? 'YOUR-LICENSE-KEY' }}</p>
|
<p class="setup-code__comment setup-code__comment--mt"># Start with your license key</p>
|
||||||
|
<p class="setup-code__cmd">export LICENSE_ID="{{ auth.license?.license_key ?? 'YOUR-LICENSE-KEY' }}"</p>
|
||||||
|
<p class="setup-code__cmd">export NATS_URL="nats://nats.corrosionmgmt.com:4222"</p>
|
||||||
|
<p class="setup-code__cmd">./corrosion-host-agent-linux-amd64</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="setup-hint">
|
<p class="setup-hint">
|
||||||
The agent auto-registers with your panel. You can also use the uMod plugin for lightweight integration.
|
On Windows, download the agent from the Server page after setup. The agent connects outbound and auto-registers with your panel.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="setup-actions">
|
<div class="setup-actions">
|
||||||
@@ -235,7 +238,7 @@ async function completeSetup() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="setup-card__title">You're all set</h1>
|
<h1 class="setup-card__title">You're all set</h1>
|
||||||
<p class="setup-card__sub">Your server is configured. Head to the dashboard to start managing your Rust server.</p>
|
<p class="setup-card__sub">Your server is configured. Head to the dashboard to start managing your game server.</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
|
|||||||
@@ -1,16 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
/**
|
||||||
import { Shield, Users, Star, MessageCircle, Clock, ChevronRight, Check, Zap, Terminal, RefreshCw, LayoutDashboard } from 'lucide-vue-next'
|
* EarlyAccess signup page.
|
||||||
|
*
|
||||||
|
* Backend endpoint: POST /api/early-access
|
||||||
|
* The early_access_signups entity exists but no NestJS controller/module exposes it yet.
|
||||||
|
* TODO: Create backend-nest/src/modules/early-access/ with a @Public() POST /early-access
|
||||||
|
* controller that accepts { email, name?, game_interest? } and writes to early_access_signups.
|
||||||
|
* The server_count column on the entity is varchar(10) — map game_interest to it or add a
|
||||||
|
* migration adding a game_interest column.
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
|
|
||||||
// ---------- Email capture ----------
|
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
|
||||||
|
|
||||||
|
// Form state
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const serverCount = ref('')
|
const name = ref('')
|
||||||
|
const gameInterest = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const submitted = ref(false)
|
const submitted = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
|
|
||||||
async function handleSubmit() {
|
const GAME_OPTIONS = [
|
||||||
if (!email.value || !serverCount.value) return
|
{ value: 'rust', label: 'Rust' },
|
||||||
|
{ value: 'dune', label: 'Dune: Awakening' },
|
||||||
|
{ value: 'conan', label: 'Conan Exiles' },
|
||||||
|
{ value: 'soulmask', label: 'Soulmask' },
|
||||||
|
{ value: 'multiple', label: 'Multiple games' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
if (!email.value) return
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -19,12 +42,13 @@ async function handleSubmit() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: email.value,
|
email: email.value,
|
||||||
server_count: serverCount.value,
|
// server_count column stores game interest (varchar 10) — no dedicated name column in DB
|
||||||
|
server_count: gameInterest.value || 'not specified',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({ message: 'Something went wrong' }))
|
const data = await res.json().catch(() => ({ message: 'Something went wrong' }))
|
||||||
throw new Error(data.message || `HTTP ${res.status}`)
|
throw new Error((data as { message?: string }).message ?? `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -34,291 +58,393 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Demo panels ----------
|
// Scroll-reveal
|
||||||
const panels = [
|
let io: IntersectionObserver | null = null
|
||||||
{ label: 'Dashboard', icon: LayoutDashboard, desc: 'Server overview, player count, uptime, and alerts at a glance.' },
|
|
||||||
{ label: 'Wipe Scheduler', icon: RefreshCw, desc: 'Visual wipe timeline with pre-wipe backup, map rotation, and health verification.' },
|
|
||||||
{ label: 'Plugin Config', icon: Zap, desc: 'Edit plugin settings from your browser. No JSON. No SFTP.' },
|
|
||||||
{ label: 'Player Management', icon: Users, desc: 'Online players, session tracking, kick/ban controls, and playtime history.' },
|
|
||||||
{ label: 'Console', icon: Terminal, desc: 'Real-time RCON console with timestamped, color-coded output.' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---------- Roadmap voting ----------
|
function initReveal(): void {
|
||||||
interface VoteItem {
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
id: string
|
io = new IntersectionObserver(
|
||||||
label: string
|
(entries) => {
|
||||||
votes: number
|
entries.forEach((e) => {
|
||||||
voted: boolean
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add('in')
|
||||||
|
io?.unobserve(e.target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
)
|
||||||
|
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||||
}
|
}
|
||||||
|
|
||||||
const voteItems = ref<VoteItem[]>([
|
onMounted(() => { initReveal() })
|
||||||
{ id: 'analytics', label: 'Analytics & Retention Insights', votes: 47, voted: false },
|
onUnmounted(() => { io?.disconnect() })
|
||||||
{ id: 'webstore', label: 'Integrated Webstore', votes: 38, voted: false },
|
|
||||||
{ id: 'modules', label: 'Module Marketplace', votes: 31, voted: false },
|
|
||||||
{ id: 'discord', label: 'Discord Bot Integration', votes: 28, voted: false },
|
|
||||||
{ id: 'hosting', label: 'Hosting Provider API', votes: 19, voted: false },
|
|
||||||
])
|
|
||||||
|
|
||||||
function vote(item: VoteItem) {
|
|
||||||
if (item.voted) return
|
|
||||||
item.votes++
|
|
||||||
item.voted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalVotes = computed(() => voteItems.value.reduce((sum, i) => sum + i.votes, 0))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<!-- PAGE HEADER -->
|
||||||
<!-- Hero -->
|
<section class="hero" style="padding-bottom:0; border-bottom:none;">
|
||||||
<section class="relative overflow-hidden">
|
<div class="hero__atmo" />
|
||||||
<div class="max-w-4xl mx-auto px-6 pt-20 pb-16 text-center">
|
<div class="hero__grid" />
|
||||||
<span class="inline-block px-4 py-1.5 bg-green-500/10 border border-green-500/20 rounded-full text-green-400 text-sm font-medium mb-6">
|
<div class="hero__grain" />
|
||||||
Early Access Is Now Open
|
<div class="wrap hero__in" style="padding-bottom:52px;">
|
||||||
</span>
|
<div class="hero__mark">
|
||||||
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4 tracking-tight">
|
<CorrosionMark :size="56" />
|
||||||
Wipe Night Just Got<br />
|
</div>
|
||||||
<span class="text-oxide-500">A Lot Easier.</span>
|
<span class="eyebrow">Early access</span>
|
||||||
|
<h1 style="font-size:var(--text-5xl)">
|
||||||
|
Take control of your servers.
|
||||||
|
<span class="accent">Starting now.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg text-neutral-400 max-w-xl mx-auto mb-10">
|
<p class="hero__sub">
|
||||||
Corrosion is live in limited early access. Install once. Automate everything. Never SSH again.
|
Corrosion is in early access. Join the list to be notified when your access opens.
|
||||||
</p>
|
No spam. No fabricated scarcity.
|
||||||
<div class="flex items-center justify-center gap-4">
|
|
||||||
<a href="#join" class="px-8 py-3.5 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors">
|
|
||||||
Claim Your Spot
|
|
||||||
</a>
|
|
||||||
<a href="#demo" class="px-8 py-3.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-semibold rounded-lg border border-neutral-700 transition-colors">
|
|
||||||
View Demo Architecture
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-oxide-500/8 rounded-full blur-3xl pointer-events-none" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Early Access Live Banner -->
|
|
||||||
<section class="py-12 border-t border-neutral-800">
|
|
||||||
<div class="max-w-3xl mx-auto px-6 text-center">
|
|
||||||
<div class="inline-flex items-center gap-3 px-6 py-4 bg-green-500/10 border border-green-500/20 rounded-2xl">
|
|
||||||
<div class="w-2.5 h-2.5 bg-green-400 rounded-full animate-pulse shrink-0" />
|
|
||||||
<p class="text-green-300 font-semibold text-lg">Early Access is now live — founding admin spots are limited.</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-neutral-500 text-sm mt-4">
|
|
||||||
Sign up below to lock in founding pricing before spots run out.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- What Early Access Means -->
|
<!-- WHAT YOU GET -->
|
||||||
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
|
<section class="sec" id="access">
|
||||||
<div class="max-w-4xl mx-auto px-6">
|
<div class="wrap">
|
||||||
<h2 class="text-2xl font-bold text-neutral-100 mb-8 text-center">What Early Access Means</h2>
|
<div class="sec__head reveal">
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<span class="eyebrow">What early access means</span>
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
|
<h2 class="title">Real access to a real platform.</h2>
|
||||||
<Shield class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
|
<p class="lead">
|
||||||
<p class="text-sm font-medium text-neutral-200">Limited Founding Licenses</p>
|
Early access is not a waitlist gimmick. It is how we manage onboarding while the
|
||||||
<p class="text-xs text-neutral-500 mt-1">25–50 spots</p>
|
platform stabilizes. You get the full Corrosion control plane — one tier at a time
|
||||||
|
as capacity opens.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
|
|
||||||
<MessageCircle class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
|
<div class="infra reveal">
|
||||||
<p class="text-sm font-medium text-neutral-200">Direct Founder Discord</p>
|
<div class="icard">
|
||||||
<p class="text-xs text-neutral-500 mt-1">Private channel access</p>
|
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||||
|
<b>Full control plane</b>
|
||||||
|
<p>Agent, panel, wipes, console, plugins, schedules — all of it. Not a trimmed preview.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
|
<div class="icard">
|
||||||
<Star class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
|
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
||||||
<p class="text-sm font-medium text-neutral-200">Influence the Roadmap</p>
|
<b>Pricing you can lock in</b>
|
||||||
<p class="text-xs text-neutral-500 mt-1">Vote on features</p>
|
<p>Early access pricing is the live pricing. No bait-and-switch after launch.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
|
<div class="icard">
|
||||||
<Clock class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
|
<div class="icard__ic"><Icon name="message-square" :size="16" /></div>
|
||||||
<p class="text-sm font-medium text-neutral-200">Lifetime Pricing Lock</p>
|
<b>Direct feedback channel</b>
|
||||||
<p class="text-xs text-neutral-500 mt-1">Never pay more</p>
|
<p>Early access operators have a direct line for platform bug reports and feature input.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="box" :size="16" /></div>
|
||||||
|
<b>Rust-first</b>
|
||||||
|
<p>Rust support is complete. Dune, Conan, and Soulmask are in active development.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="users" :size="16" /></div>
|
||||||
|
<b>RBAC team access</b>
|
||||||
|
<p>Add your admin team from day one. Fine-grained permission roles are built in.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Email Capture -->
|
<!-- SIGNUP FORM -->
|
||||||
<section id="join" class="py-16 border-t border-neutral-800">
|
<section class="sec" id="join">
|
||||||
<div class="max-w-md mx-auto px-6">
|
<div class="wrap">
|
||||||
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">Claim Your Founding Spot</h2>
|
<div class="ea-form-wrap reveal">
|
||||||
<p class="text-neutral-400 text-center mb-8">Early access is open now. Spots are limited — lock in founding pricing today.</p>
|
<!-- Success state -->
|
||||||
|
<div v-if="submitted" class="ea-success">
|
||||||
<div v-if="submitted" class="bg-green-500/10 border border-green-500/20 rounded-xl p-8 text-center">
|
<div class="ea-success__ic">
|
||||||
<Check class="w-10 h-10 text-green-400 mx-auto mb-3" />
|
<Icon name="check" :size="28" />
|
||||||
<h3 class="text-lg font-semibold text-neutral-100 mb-1">You're in.</h3>
|
</div>
|
||||||
<p class="text-sm text-neutral-400">We'll be in touch shortly with your access details.</p>
|
<h2 class="ea-success__title">You are on the list.</h2>
|
||||||
|
<p class="ea-success__body">
|
||||||
|
We will reach out when your access slot opens. In the meantime, read the
|
||||||
|
<RouterLink :to="{ name: 'how-it-works' }" class="ea-link">how it works</RouterLink>
|
||||||
|
guide or review the
|
||||||
|
<RouterLink :to="{ name: 'faq' }" class="ea-link">FAQ</RouterLink>.
|
||||||
|
</p>
|
||||||
|
<RouterLink class="btn btn--ghost" :to="{ name: 'landing' }">
|
||||||
|
Back to home
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form v-else @submit.prevent="handleSubmit" class="space-y-4">
|
<!-- Form state -->
|
||||||
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
|
<form v-else @submit.prevent="handleSubmit" class="ea-form">
|
||||||
|
<div class="ea-form__head">
|
||||||
|
<h2>Join the early access list</h2>
|
||||||
|
<p>Required: email address. Everything else is optional but helps us prioritise.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error banner -->
|
||||||
|
<div v-if="errorMsg" class="ea-error">
|
||||||
|
<Icon name="triangle-alert" :size="15" />
|
||||||
{{ errorMsg }}
|
{{ errorMsg }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="ea-email" class="block text-sm font-medium text-neutral-400 mb-1.5">Email</label>
|
<!-- Email (required) -->
|
||||||
|
<div class="ea-field">
|
||||||
|
<label class="ea-field__label" for="ea-email">
|
||||||
|
Email address <span class="ea-field__req">*</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="ea-email"
|
id="ea-email"
|
||||||
v-model="email"
|
v-model="email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
|
autocomplete="email"
|
||||||
placeholder="admin@example.com"
|
placeholder="admin@example.com"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-900 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
class="ea-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="ea-servers" class="block text-sm font-medium text-neutral-400 mb-1.5">How many servers do you run?</label>
|
<!-- Name (optional) -->
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="ea-field">
|
||||||
|
<label class="ea-field__label" for="ea-name">
|
||||||
|
Your name <span class="ea-field__optional">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ea-name"
|
||||||
|
v-model="name"
|
||||||
|
type="text"
|
||||||
|
autocomplete="name"
|
||||||
|
placeholder="Server admin name or handle"
|
||||||
|
class="ea-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game interest (optional) -->
|
||||||
|
<div class="ea-field">
|
||||||
|
<label class="ea-field__label">
|
||||||
|
Primary game interest <span class="ea-field__optional">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<div class="ea-pills">
|
||||||
<button
|
<button
|
||||||
v-for="option in ['1', '2-3', '4+']"
|
v-for="opt in GAME_OPTIONS"
|
||||||
:key="option"
|
:key="opt.value"
|
||||||
type="button"
|
type="button"
|
||||||
@click="serverCount = option"
|
class="ea-pill"
|
||||||
class="py-2.5 text-sm font-medium rounded-lg border transition-colors"
|
:class="{ 'ea-pill--on': gameInterest === opt.value }"
|
||||||
:class="serverCount === option
|
@click="gameInterest = gameInterest === opt.value ? '' : opt.value"
|
||||||
? 'bg-oxide-500/15 border-oxide-500/40 text-oxide-400'
|
|
||||||
: 'bg-neutral-900 border-neutral-700 text-neutral-400 hover:border-neutral-600'"
|
|
||||||
>
|
>
|
||||||
{{ option }}
|
{{ opt.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="submitting || !email || !serverCount"
|
class="btn btn--primary btn--lg ea-submit"
|
||||||
class="w-full py-3 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
|
:disabled="submitting || !email"
|
||||||
>
|
>
|
||||||
{{ submitting ? 'Submitting...' : 'Join Early Access' }}
|
<Icon v-if="submitting" name="loader" :size="16" />
|
||||||
|
<Icon v-else name="send" :size="16" />
|
||||||
|
{{ submitting ? 'Submitting…' : 'Join early access' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<p class="ea-privacy">
|
||||||
|
We store your email to contact you when access opens. We do not sell or share it.
|
||||||
|
No newsletters unless you opt in separately.
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Founding Admin Program -->
|
<!-- HOW IT WORKS TEASER -->
|
||||||
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
|
<section class="sec" id="teaser">
|
||||||
<div class="max-w-3xl mx-auto px-6 text-center">
|
<div class="wrap">
|
||||||
<span class="inline-block px-3 py-1 bg-oxide-500/10 border border-oxide-500/20 rounded-full text-oxide-400 text-xs font-semibold uppercase tracking-wider mb-4">
|
<div class="sec__head reveal">
|
||||||
Limited to 25 Servers
|
<span class="eyebrow">How it works</span>
|
||||||
</span>
|
<h2 class="title">Install the agent. Never SSH again.</h2>
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-4">Founding Admin Program</h2>
|
|
||||||
<p class="text-neutral-400 mb-8">
|
|
||||||
The first 25 servers to run Corrosion receive:
|
|
||||||
</p>
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
|
|
||||||
<Star class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
|
|
||||||
<p class="text-sm font-medium text-neutral-200">Founding Admin Role</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-1">Discord badge</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
|
<div class="steps reveal">
|
||||||
<Clock class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
|
<div class="step">
|
||||||
<p class="text-sm font-medium text-neutral-200">Permanent Early Pricing</p>
|
<div class="step__n">1</div>
|
||||||
<p class="text-xs text-neutral-500 mt-1">Locked forever</p>
|
<b>Install the host agent</b>
|
||||||
|
<p>Download the Go binary from your dashboard. Run it on Windows or Linux. One agent per machine.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
|
<div class="step">
|
||||||
<Users class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
|
<div class="step__n">2</div>
|
||||||
<p class="text-sm font-medium text-neutral-200">Public Recognition</p>
|
<b>Agent connects to Corrosion</b>
|
||||||
<p class="text-xs text-neutral-500 mt-1">Featured server</p>
|
<p>Single outbound NATS connection. No inbound ports. No exposed management panel on your machine.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
|
<div class="step">
|
||||||
<MessageCircle class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
|
<div class="step__n">3</div>
|
||||||
<p class="text-sm font-medium text-neutral-200">Direct Feature Input</p>
|
<b>Manage from the browser</b>
|
||||||
<p class="text-xs text-neutral-500 mt-1">Private channel</p>
|
<p>Console, wipes, plugins, schedules, file manager, player management — all at panel.corrosionmgmt.com.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="closing reveal">
|
||||||
|
<RouterLink :to="{ name: 'how-it-works' }" class="btn btn--ghost btn--lg">
|
||||||
|
<Icon name="chevron-right" :size="17" />Read the full walkthrough
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Demo Dashboard Preview -->
|
<!-- FINAL CTA -->
|
||||||
<section id="demo" class="py-16 border-t border-neutral-800">
|
<section class="finalcta">
|
||||||
<div class="max-w-5xl mx-auto px-6">
|
<div class="finalcta__atmo" />
|
||||||
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">Preview of Corrosion 1.0 Dashboard</h2>
|
<div class="wrap finalcta__in reveal">
|
||||||
<p class="text-neutral-500 text-center mb-10">Screenshots will replace these frames at launch.</p>
|
<h2>Ready to stop babysitting<br>your servers?</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="cta-row">
|
||||||
<div
|
<a href="#join" class="btn btn--primary btn--lg">
|
||||||
v-for="panel in panels"
|
Sign up above
|
||||||
:key="panel.label"
|
</a>
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden hover:border-neutral-700 transition-colors"
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
>
|
<Icon name="key" :size="17" />Sign in
|
||||||
<div class="h-36 bg-neutral-800/50 flex items-center justify-center border-b border-neutral-800">
|
</a>
|
||||||
<component :is="panel.icon" class="w-10 h-10 text-neutral-700" />
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<h3 class="text-sm font-semibold text-neutral-200 mb-1">{{ panel.label }}</h3>
|
|
||||||
<p class="text-xs text-neutral-500 leading-relaxed">{{ panel.desc }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Roadmap Voting -->
|
|
||||||
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
|
|
||||||
<div class="max-w-2xl mx-auto px-6">
|
|
||||||
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">What Should We Build Next?</h2>
|
|
||||||
<p class="text-neutral-500 text-center mb-8">Vote on the features that matter most to you.</p>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<button
|
|
||||||
v-for="item in voteItems"
|
|
||||||
:key="item.id"
|
|
||||||
@click="vote(item)"
|
|
||||||
class="w-full flex items-center gap-4 p-4 bg-neutral-900 border rounded-xl transition-colors text-left"
|
|
||||||
:class="item.voted ? 'border-oxide-500/30' : 'border-neutral-800 hover:border-neutral-700'"
|
|
||||||
>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium" :class="item.voted ? 'text-oxide-400' : 'text-neutral-200'">{{ item.label }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
|
||||||
<div class="w-24 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full transition-all duration-300"
|
|
||||||
:class="item.voted ? 'bg-oxide-500' : 'bg-neutral-600'"
|
|
||||||
:style="{ width: `${totalVotes ? (item.votes / totalVotes) * 100 : 0}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs font-medium tabular-nums w-8 text-right" :class="item.voted ? 'text-oxide-400' : 'text-neutral-500'">
|
|
||||||
{{ item.votes }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Timeline -->
|
|
||||||
<section class="py-16 border-t border-neutral-800">
|
|
||||||
<div class="max-w-2xl mx-auto px-6">
|
|
||||||
<h2 class="text-2xl font-bold text-neutral-100 mb-8 text-center">Launch Timeline</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="w-8 h-8 bg-green-500/10 border border-green-500/20 rounded-full flex items-center justify-center shrink-0 mt-0.5">
|
|
||||||
<Check class="w-4 h-4 text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-neutral-200">Week 1 — Closed Beta Stabilization</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-0.5">Core platform hardening and testing.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4 w-px h-4 bg-neutral-800" />
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="w-8 h-8 bg-green-500/10 border border-green-500/20 rounded-full flex items-center justify-center shrink-0 mt-0.5">
|
|
||||||
<Check class="w-4 h-4 text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-neutral-200">Week 2 — Early Access Open</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-0.5">Founding Admin licenses are live — claim yours now.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4 w-px h-4 bg-neutral-800" />
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="w-8 h-8 bg-neutral-800 border border-neutral-700 rounded-full flex items-center justify-center shrink-0 mt-0.5">
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-neutral-400">Public Release</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-0.5">Shortly after early access stabilization.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Form wrapper */
|
||||||
|
.ea-form-wrap {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success state */
|
||||||
|
.ea-success {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 32px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.ea-success__ic {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
}
|
||||||
|
.ea-success__title {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.ea-success__body {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
.ea-link {
|
||||||
|
color: var(--accent-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ea-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.ea-form {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 36px 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
.ea-form__head h2 {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.ea-form__head p {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error banner */
|
||||||
|
.ea-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: color-mix(in srgb, var(--status-offline) 10%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--status-offline) 25%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--status-offline);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
.ea-field { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.ea-field__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.ea-field__req { color: var(--status-offline); }
|
||||||
|
.ea-field__optional { color: var(--text-muted); font-weight: 400; }
|
||||||
|
|
||||||
|
.ea-input {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 13px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
transition: border-color var(--dur-fast), box-shadow var(--dur-fast);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.ea-input::placeholder { color: var(--text-muted); }
|
||||||
|
.ea-input:focus {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game pills */
|
||||||
|
.ea-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ea-pill {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.ea-pill--on {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit */
|
||||||
|
.ea-submit {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ea-submit:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Privacy note */
|
||||||
|
.ea-privacy {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,101 +1,353 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { ChevronDown } from 'lucide-vue-next'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
|
|
||||||
interface FaqItem {
|
interface FaqItem {
|
||||||
question: string
|
question: string
|
||||||
answer: string
|
answer: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const faqs: FaqItem[] = [
|
interface FaqGroup {
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
items: FaqItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: FaqGroup[] = [
|
||||||
{
|
{
|
||||||
question: 'Do I need to open any firewall ports?',
|
label: 'Support',
|
||||||
answer: 'No. All connections are outbound from your server to Corrosion\'s cloud. No inbound ports required.',
|
icon: 'help-circle',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: 'Do you provide direct support?',
|
||||||
|
answer:
|
||||||
|
'Corrosion is a self-service tool. Every plan includes documentation, community forum access, diagnostics, and structured platform bug reports. We do not provide 1:1 setup assistance, Discord DMs, video calls, server administration, hosting-provider troubleshooting, firewall configuration, mod installation, or emergency wipe-day support.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Does Corrosion replace my hosting panel (AMP / Pterodactyl)?',
|
question: 'What if Corrosion itself is broken?',
|
||||||
answer: 'No. Corrosion integrates with them via API or companion agent. Your existing panel remains intact.',
|
answer:
|
||||||
|
'Platform bugs and agent issues go through structured bug reports in the panel. Operator and Network customers receive priority bug triage for platform-level issues. Direct server administration is not included in any support tier.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Do you manage my server for me?',
|
||||||
|
answer:
|
||||||
|
'No. Corrosion provides the panel, the agent, automation workflows, diagnostics, and the control plane. You remain responsible for your host machine, operating system, network, firewall, game files, mods, and community rules.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Is hands-on help available?',
|
||||||
|
answer:
|
||||||
|
'Yes — separately. Direct 1:1 support is available at $125/hour, prepaid in 1-hour blocks. This is billed time with a human, not a support tier. It is available to any customer who needs it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'What does community support include?',
|
||||||
|
answer:
|
||||||
|
'Documentation (setup guides, architecture reference, troubleshooting walkthroughs), a community forum for operator-to-operator knowledge sharing, in-panel diagnostics (agent health, log access), and a structured bug report system for platform issues.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Product',
|
||||||
|
icon: 'server',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: 'Do I need my own server?',
|
||||||
|
answer:
|
||||||
|
'Yes. Corrosion is bring-your-own-server. You supply the host machine — a VPS, dedicated server, or bare metal box running Windows or Linux. Corrosion provides the control plane, the agent, and the panel.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Does Corrosion host my game server for me?',
|
||||||
|
answer:
|
||||||
|
'No. Corrosion is not a hosting provider. It is a management layer that runs on top of a server you already own or rent. If you need hosting, you need a separate hosting provider.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Do I need to open inbound firewall ports for Corrosion?',
|
||||||
|
answer:
|
||||||
|
'No. The host agent establishes a single outbound NATS connection to Corrosion\'s cloud. No inbound management ports are required. Your game server\'s player ports (RCON, game ports) remain as they have always been.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Does Corrosion replace AMP or Pterodactyl?',
|
||||||
|
answer:
|
||||||
|
'It can, depending on your use case. Corrosion is an independent control plane — it does not require an existing game panel. If you are currently using AMP or Pterodactyl, migration tooling is available in the panel.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'What happens if Corrosion goes offline?',
|
question: 'What happens if Corrosion goes offline?',
|
||||||
answer: 'Your Rust server continues running normally. Corrosion does not proxy gameplay traffic.',
|
answer:
|
||||||
|
'Your game servers continue running normally. Corrosion does not proxy gameplay traffic — it only handles management operations. If the panel or cloud is unreachable, your players are unaffected.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Is my data shared with other servers?',
|
question: 'Can multiple admins manage the same server?',
|
||||||
answer: 'No. All data is isolated by license ID. Multi-tenant database queries are scoped per license.',
|
answer:
|
||||||
|
'Yes. Role-based access control (RBAC) is built in. You can grant team members specific permissions — from full admin access down to read-only viewer — without sharing credentials.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'What if a wipe fails?',
|
question: 'What OS does the agent run on?',
|
||||||
answer: 'Corrosion can automatically retry and optionally roll back using the pre-wipe backup.',
|
answer:
|
||||||
|
'Both Windows and Linux are supported for the host agent. The agent binary is downloaded directly from your dashboard — there is no manual build or dependency setup required.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Does this work on bare metal?',
|
question: 'Is my data isolated from other customers?',
|
||||||
answer: 'Yes. Use the Companion Agent — no SSH required after initial setup.',
|
answer:
|
||||||
|
'Yes. All data is scoped by license ID at the database level. No server, config, or player data is shared across tenant boundaries.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can I manage multiple admins?',
|
label: 'Games',
|
||||||
answer: 'Yes. Multi-Admin Role-Based Access Control is built in. Grant granular permissions per team member.',
|
icon: 'box',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: 'Which games are supported?',
|
||||||
|
answer:
|
||||||
|
'Currently in active development or shipped: Rust, Dune: Awakening, Conan Exiles, and Soulmask. Each game has a purpose-built blueprint — not a generic template — that models its specific operational reality (wipe types, mod systems, cluster configurations). More games are planned.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Is this beginner friendly?',
|
question: 'Does Corrosion support Rust plugin management?',
|
||||||
answer: 'Yes. If you can install a uMod plugin, you can use Corrosion.',
|
answer:
|
||||||
|
'Yes. Corrosion integrates with uMod (Oxide) for Rust. You can browse the plugin registry, install plugins, manage configuration profiles, and push config changes to the server — all from the browser.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Does this replace Tebex?',
|
question: 'Can I run multiple game types on the same host machine?',
|
||||||
answer: 'Corrosion includes an optional integrated store (Phase 5 roadmap), but does not require Tebex.',
|
answer:
|
||||||
|
'Yes. A single host agent can supervise multiple game server processes — across different games — on the same machine. Each game instance has its own lifecycle, configuration, and wipe schedule.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How is licensing handled?',
|
question: 'Does Corrosion handle Rust wipes?',
|
||||||
answer: 'One license per server. License validation occurs on plugin startup and periodically.',
|
answer:
|
||||||
|
'Yes. Rust wipes are a first-class feature: map wipes, blueprint wipes, and full wipes. Wipes run as verified, logged sequences — pre-warning, backup, stop, update, map rotation, restart, health check, announce. Rollback is available when supported.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Billing',
|
||||||
|
icon: 'credit-card',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: 'What counts as commercial use?',
|
||||||
|
answer:
|
||||||
|
'Commercial use includes monetized communities, paid access, VIP slots, donation-funded servers, sponsorship-supported servers, hosting providers, or managing servers for others. Hobby and Community plans are non-commercial only. Operator and Network plans permit commercial use.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'What is the Fleet Block on the Network plan?',
|
||||||
|
answer:
|
||||||
|
'The Network plan base includes 50 server instances at $99.99/mo. Each additional Fleet Block adds 50 more server slots at $49.99/mo. Stack as many Fleet Blocks as your operation requires.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Can I upgrade my plan?',
|
||||||
|
answer:
|
||||||
|
'Yes. You can upgrade at any time. Pricing is prorated from the upgrade date.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Is there a free trial?',
|
||||||
|
answer:
|
||||||
|
'Corrosion is currently in early access. Join the early access list to be notified when access opens.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Are there annual billing discounts?',
|
||||||
|
answer:
|
||||||
|
'Not at this time. All plans are billed monthly.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const openIndex = ref<number | null>(null)
|
const openKey = ref<string | null>(null)
|
||||||
|
|
||||||
function toggle(index: number) {
|
function toggle(key: string): void {
|
||||||
openIndex.value = openIndex.value === index ? null : index
|
openKey.value = openKey.value === key ? null : key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function itemKey(groupLabel: string, idx: number): string {
|
||||||
|
return `${groupLabel}-${idx}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll-reveal
|
||||||
|
let io: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
function initReveal(): void {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add('in')
|
||||||
|
io?.unobserve(e.target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
)
|
||||||
|
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { initReveal() })
|
||||||
|
onUnmounted(() => { io?.disconnect() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<!-- PAGE HEADER -->
|
||||||
<!-- Header -->
|
<section class="hero" style="padding-bottom:0; border-bottom:none;">
|
||||||
<section class="pt-20 pb-12">
|
<div class="hero__atmo" />
|
||||||
<div class="max-w-4xl mx-auto px-6 text-center">
|
<div class="hero__grid" />
|
||||||
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">Frequently Asked Questions</h1>
|
<div class="hero__grain" />
|
||||||
<p class="text-lg text-neutral-400">Everything you need to know about Corrosion.</p>
|
<div class="wrap hero__in" style="padding-bottom:52px;">
|
||||||
|
<div class="hero__mark">
|
||||||
|
<CorrosionMark :size="56" />
|
||||||
|
</div>
|
||||||
|
<span class="eyebrow">FAQ</span>
|
||||||
|
<h1 style="font-size:var(--text-5xl)">
|
||||||
|
Honest answers.
|
||||||
|
<span class="accent">No marketing fluff.</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero__sub">
|
||||||
|
Common questions about support, the product, supported games, and billing — answered
|
||||||
|
plainly.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- FAQ Accordion -->
|
<!-- FAQ GROUPS -->
|
||||||
<section class="pb-20">
|
<section class="sec" id="faq">
|
||||||
<div class="max-w-3xl mx-auto px-6">
|
<div class="wrap">
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
<div
|
||||||
v-for="(faq, index) in faqs"
|
v-for="group in groups"
|
||||||
:key="index"
|
:key="group.label"
|
||||||
class="bg-neutral-900 border rounded-xl overflow-hidden transition-colors"
|
class="faq-group reveal"
|
||||||
:class="openIndex === index ? 'border-oxide-500/30' : 'border-neutral-800'"
|
>
|
||||||
|
<div class="faq-group__head">
|
||||||
|
<span class="faq-group__ic">
|
||||||
|
<Icon :name="group.icon" :size="16" />
|
||||||
|
</span>
|
||||||
|
<span class="eyebrow">{{ group.label }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, idx) in group.items"
|
||||||
|
:key="idx"
|
||||||
|
class="faq-item"
|
||||||
|
:class="{ 'faq-item--open': openKey === itemKey(group.label, idx) }"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@click="toggle(index)"
|
class="faq-item__q"
|
||||||
class="w-full flex items-center justify-between p-6 text-left"
|
@click="toggle(itemKey(group.label, idx))"
|
||||||
>
|
>
|
||||||
<span class="text-neutral-100 font-medium pr-4">{{ faq.question }}</span>
|
<span>{{ item.question }}</span>
|
||||||
<ChevronDown
|
<span class="faq-item__chevron">
|
||||||
class="w-5 h-5 text-neutral-500 shrink-0 transition-transform duration-200"
|
<Icon
|
||||||
:class="{ 'rotate-180': openIndex === index }"
|
name="chevron-down"
|
||||||
|
:size="16"
|
||||||
|
:class="{ 'faq-item__chevron--open': openKey === itemKey(group.label, idx) }"
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="openIndex === index"
|
v-if="openKey === itemKey(group.label, idx)"
|
||||||
class="px-6 pb-6 -mt-2"
|
class="faq-item__a"
|
||||||
>
|
>
|
||||||
<p class="text-neutral-400 leading-relaxed">{{ faq.answer }}</p>
|
{{ item.answer }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- SUPPORT CTA -->
|
||||||
|
<section class="sec" id="support-cta" style="border-bottom:none">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Still have questions?</span>
|
||||||
|
<h2 class="title">Check the docs or join the community</h2>
|
||||||
|
<p class="lead">
|
||||||
|
The documentation covers setup, architecture, troubleshooting, and every supported
|
||||||
|
game. The community forum is where operators share configs, ask questions, and help
|
||||||
|
each other.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hero__cta reveal">
|
||||||
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
|
Join early access
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink class="btn btn--ghost btn--lg" :to="{ name: 'pricing' }">
|
||||||
|
<Icon name="credit-card" :size="17" />View pricing
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.faq-group {
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
.faq-group__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.faq-group__ic {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.faq-item {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow var(--dur-fast);
|
||||||
|
}
|
||||||
|
.faq-item--open {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.faq-item__q {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: color var(--dur-fast);
|
||||||
|
}
|
||||||
|
.faq-item__q:hover { color: var(--accent-text); }
|
||||||
|
.faq-item__chevron {
|
||||||
|
flex: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color var(--dur-fast);
|
||||||
|
}
|
||||||
|
.faq-item__chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
.faq-item__a {
|
||||||
|
padding: 0 20px 18px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,124 +1,338 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Download, Globe, Wifi, LayoutDashboard, ArrowDown } from 'lucide-vue-next'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
|
|
||||||
|
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
|
||||||
|
|
||||||
|
// Scroll-reveal
|
||||||
|
let io: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
function initReveal(): void {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add('in')
|
||||||
|
io?.unobserve(e.target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
)
|
||||||
|
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { initReveal() })
|
||||||
|
onUnmounted(() => { io?.disconnect() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<!-- PAGE HEADER -->
|
||||||
<!-- Header -->
|
<section class="hero" style="padding-bottom:0; border-bottom:none;">
|
||||||
<section class="pt-20 pb-12">
|
<div class="hero__atmo" />
|
||||||
<div class="max-w-4xl mx-auto px-6 text-center">
|
<div class="hero__grid" />
|
||||||
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">How Corrosion Works</h1>
|
<div class="hero__grain" />
|
||||||
<p class="text-lg text-neutral-400">
|
<div class="wrap hero__in" style="padding-bottom:52px;">
|
||||||
Corrosion connects your Rust server to a hosted control plane — securely, outbound-only.
|
<div class="hero__mark">
|
||||||
|
<CorrosionMark :size="56" />
|
||||||
|
</div>
|
||||||
|
<span class="eyebrow">How it works</span>
|
||||||
|
<h1 style="font-size:var(--text-5xl)">
|
||||||
|
One agent.
|
||||||
|
<span class="accent">Every game. No SSH.</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero__sub">
|
||||||
|
Install the host agent once on your Windows or Linux machine. Corrosion connects
|
||||||
|
securely, outbound-only. You manage every game instance from the browser.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Steps -->
|
<!-- THE MODEL: 3-STEP OVERVIEW -->
|
||||||
<section class="pb-20">
|
<section class="sec" id="model">
|
||||||
<div class="max-w-3xl mx-auto px-6">
|
<div class="wrap">
|
||||||
<div class="space-y-2">
|
<div class="sec__head reveal">
|
||||||
<!-- Step 1 -->
|
<span class="eyebrow">The agent model</span>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
|
<h2 class="title">Bring your own server.<br>We provide the control plane.</h2>
|
||||||
<div class="flex items-start gap-6">
|
<p class="lead">
|
||||||
<div class="w-12 h-12 bg-oxide-500/10 border border-oxide-500/20 rounded-xl flex items-center justify-center shrink-0">
|
Corrosion is not a hosting provider. You supply the hardware or the VPS. The host
|
||||||
<Download class="w-6 h-6 text-oxide-500" />
|
agent runs on that machine and bridges your game instances to Corrosion's control
|
||||||
|
plane — securely, without opening inbound firewall ports.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="steps reveal">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__n">1</div>
|
||||||
|
<b>Install the host agent</b>
|
||||||
|
<p>
|
||||||
|
Download the Corrosion agent binary from your dashboard. Run it on any Windows
|
||||||
|
or Linux host. One agent per machine — it manages every game instance you assign
|
||||||
|
to it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__n">2</div>
|
||||||
|
<b>It connects to Corrosion</b>
|
||||||
|
<p>
|
||||||
|
The agent makes a single outbound NATS connection to Corrosion's cloud. No
|
||||||
|
inbound ports. No open panels. No SSH required after initial setup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__n">3</div>
|
||||||
|
<b>Deploy and manage from the browser</b>
|
||||||
|
<p>
|
||||||
|
Create game instances, run wipes, manage plugins, schedule maintenance, and
|
||||||
|
monitor players — all from the Corrosion panel at panel.corrosionmgmt.com.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nots reveal">
|
||||||
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No open inbound ports</span>
|
||||||
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No permanent SSH sessions</span>
|
||||||
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No config file spelunking</span>
|
||||||
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />Windows and Linux supported</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- MULTI-GAME RUNTIME -->
|
||||||
|
<section class="sec" id="multi-game">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Multi-game host runtime</span>
|
||||||
|
<h2 class="title">One agent. Multiple game worlds<br>on the same machine.</h2>
|
||||||
|
<p class="lead">
|
||||||
|
The host agent is not a per-game process. It is a general-purpose ops runtime. One
|
||||||
|
agent on a single machine can supervise multiple game server processes across
|
||||||
|
different games — each with its own configuration, lifecycle, and wipe schedule.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="blueprints reveal">
|
||||||
|
<div class="bp" data-game="rust">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="box" :size="21" /></span>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="bp__name">Rust</div>
|
||||||
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 1</span>
|
<div class="bp__accent">Oxide Orange</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-neutral-100 mb-2">Install the Plugin</h3>
|
</div>
|
||||||
<p class="text-neutral-400">
|
<div class="bp__role">uMod / Oxide plugin ecosystem</div>
|
||||||
Drop the Corrosion plugin into <code class="px-2 py-0.5 bg-neutral-800 rounded text-oxide-300 text-sm">oxide/plugins</code>.
|
<div class="bp__list">
|
||||||
That's it. No dependencies, no config files to create.
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Map / BP / full wipe scheduling</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Plugin config profiles from the browser</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Wipe-day backup before every change</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp" data-game="dune">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="sun" :size="21" /></span>
|
||||||
|
<div>
|
||||||
|
<div class="bp__name">Dune: Awakening</div>
|
||||||
|
<div class="bp__accent">Spice Amber</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp__role">Battlegroup orchestration</div>
|
||||||
|
<div class="bp__list">
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Deep Desert wipe scheduling</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Procedural map regeneration</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Battlegroup lifecycle controls</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp" data-game="soulmask">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="drama" :size="21" /></span>
|
||||||
|
<div>
|
||||||
|
<div class="bp__name">Soulmask</div>
|
||||||
|
<div class="bp__accent">Ritual Jade</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp__role">Linked-world cluster deployment</div>
|
||||||
|
<div class="bp__list">
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />One-click linked-server clusters</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Port and config automation</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Cluster health monitoring</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp" data-game="conan">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="swords" :size="21" /></span>
|
||||||
|
<div>
|
||||||
|
<div class="bp__name">Conan Exiles</div>
|
||||||
|
<div class="bp__accent">Hyborian Bronze</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp__role">Persistent world management</div>
|
||||||
|
<div class="bp__list">
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Mod management + world backups</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Purge, decay, and event tracking</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Scheduled maintenance workflows</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- WIPE AND MAINTENANCE AUTOMATION -->
|
||||||
|
<section class="sec" id="automation">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Wipe and maintenance automation</span>
|
||||||
|
<h2 class="title">Self-service workflows,<br>not manual processes</h2>
|
||||||
|
<p class="lead">
|
||||||
|
Every wipe, update, and maintenance window runs as a verified, logged sequence.
|
||||||
|
Pre-warning announcements, pre-wipe backups, health checks after restart, and
|
||||||
|
rollback capability when things go wrong.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pipe reveal">
|
||||||
|
<span class="pchip">Pre-warning</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Backup</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Stop services</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Update / rotate</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Restart</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Health check</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip pchip--last">Announce complete</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack-lines reveal">
|
||||||
|
<span>Every operation is logged. Every step is verified.</span>
|
||||||
|
<span class="hi">Rollback is one click away when supported.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ARCHITECTURE: HOW CONNECTIVITY WORKS -->
|
||||||
|
<section class="sec" id="connectivity">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Connectivity model</span>
|
||||||
|
<h2 class="title">Outbound-only. No exposed panel.</h2>
|
||||||
|
<p class="lead">
|
||||||
|
The host agent establishes one secure NATS connection to Corrosion's cloud. All
|
||||||
|
commands flow through that channel. Your machine never needs to accept inbound
|
||||||
|
connections from the internet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="infra reveal">
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="server" :size="16" /></div>
|
||||||
|
<b>Your host machine</b>
|
||||||
|
<p>Windows or Linux. Bare metal, VPS, or dedicated. You own it and run it.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||||
|
<b>Corrosion agent</b>
|
||||||
|
<p>A single Go binary. Runs as a service. Manages game processes, files, and updates.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
||||||
|
<b>Outbound NATS channel</b>
|
||||||
|
<p>One secure, outbound-only connection. No open ports. No SSH tunnels.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
||||||
|
<b>Corrosion cloud</b>
|
||||||
|
<p>Hosted control plane. Multi-tenant isolation. Every command is license-scoped.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="layout-dashboard" :size="16" /></div>
|
||||||
|
<b>Your browser</b>
|
||||||
|
<p>The panel at panel.corrosionmgmt.com. Console, wipes, plugins, schedules — all here.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="techrow reveal">
|
||||||
|
<span>Go host agent</span>
|
||||||
|
<span>NATS JetStream</span>
|
||||||
|
<span>NestJS API</span>
|
||||||
|
<span>PostgreSQL</span>
|
||||||
|
<span>Outbound-only connectivity</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HOST REQUIREMENTS -->
|
||||||
|
<section class="sec" id="requirements">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Host requirements</span>
|
||||||
|
<h2 class="title">What you need to get started</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="caps reveal" style="max-width:760px">
|
||||||
|
<div class="caps__col">
|
||||||
|
<span class="eyebrow">Your machine</span>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="server" :size="16" /></span>
|
||||||
|
<div>
|
||||||
|
<b>Windows or Linux host</b>
|
||||||
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
|
VPS, dedicated server, or bare metal. You supply it; Corrosion manages it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="hard-drive" :size="16" /></span>
|
||||||
|
<div>
|
||||||
|
<b>Enough CPU and RAM for your game</b>
|
||||||
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
|
Corrosion's agent is lightweight. Your game server determines the actual
|
||||||
|
hardware requirement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="wifi" :size="16" /></span>
|
||||||
|
<div>
|
||||||
|
<b>Outbound internet access</b>
|
||||||
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
|
The agent connects out; your game server's player ports stay open as they
|
||||||
|
always have been.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="caps__col">
|
||||||
<div class="flex justify-center py-1">
|
<span class="eyebrow">From Corrosion</span>
|
||||||
<ArrowDown class="w-5 h-5 text-neutral-700" />
|
<div class="feat">
|
||||||
</div>
|
<span class="feat__ic"><Icon name="download" :size="16" /></span>
|
||||||
|
|
||||||
<!-- Step 2 -->
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
|
|
||||||
<div class="flex items-start gap-6">
|
|
||||||
<div class="w-12 h-12 bg-oxide-500/10 border border-oxide-500/20 rounded-xl flex items-center justify-center shrink-0">
|
|
||||||
<Globe class="w-6 h-6 text-oxide-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<b>Agent binary (Windows or Linux)</b>
|
||||||
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 2</span>
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
</div>
|
Downloaded from your dashboard. No manual build. No dependency management.
|
||||||
<h3 class="text-xl font-bold text-neutral-100 mb-2">Register Online</h3>
|
|
||||||
<p class="text-neutral-400">
|
|
||||||
Activate your license and configure your server. Set your hostname, game port, and admin preferences.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="key" :size="16" /></span>
|
||||||
<div class="flex justify-center py-1">
|
|
||||||
<ArrowDown class="w-5 h-5 text-neutral-700" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 3 -->
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
|
|
||||||
<div class="flex items-start gap-6">
|
|
||||||
<div class="w-12 h-12 bg-oxide-500/10 border border-oxide-500/20 rounded-xl flex items-center justify-center shrink-0">
|
|
||||||
<Wifi class="w-6 h-6 text-oxide-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<b>Your license key</b>
|
||||||
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 3</span>
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
</div>
|
Issued when you register. The agent uses it to authenticate to the cloud.
|
||||||
<h3 class="text-xl font-bold text-neutral-100 mb-2">Secure Outbound Connection</h3>
|
|
||||||
<p class="text-neutral-400">
|
|
||||||
Your server connects securely to Corrosion's NATS cluster. No inbound firewall rules required. Your server initiates all connections.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="globe" :size="16" /></span>
|
||||||
<div class="flex justify-center py-1">
|
|
||||||
<ArrowDown class="w-5 h-5 text-neutral-700" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 4 -->
|
|
||||||
<div class="bg-neutral-900 border border-oxide-500/30 rounded-xl p-8">
|
|
||||||
<div class="flex items-start gap-6">
|
|
||||||
<div class="w-12 h-12 bg-oxide-500/15 border border-oxide-500/30 rounded-xl flex items-center justify-center shrink-0">
|
|
||||||
<LayoutDashboard class="w-6 h-6 text-oxide-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<b>The panel</b>
|
||||||
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 4</span>
|
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
|
||||||
</div>
|
Everything else — console, wipes, schedules, players — lives at
|
||||||
<h3 class="text-xl font-bold text-neutral-100 mb-2">Full Orchestration</h3>
|
panel.corrosionmgmt.com.
|
||||||
<p class="text-neutral-400 mb-4">From the dashboard, you can:</p>
|
</p>
|
||||||
<ul class="space-y-2">
|
|
||||||
<li class="text-neutral-300 text-sm flex items-center gap-2">
|
|
||||||
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
|
|
||||||
Execute console commands
|
|
||||||
</li>
|
|
||||||
<li class="text-neutral-300 text-sm flex items-center gap-2">
|
|
||||||
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
|
|
||||||
Configure plugins
|
|
||||||
</li>
|
|
||||||
<li class="text-neutral-300 text-sm flex items-center gap-2">
|
|
||||||
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
|
|
||||||
Schedule wipes
|
|
||||||
</li>
|
|
||||||
<li class="text-neutral-300 text-sm flex items-center gap-2">
|
|
||||||
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
|
|
||||||
Monitor performance
|
|
||||||
</li>
|
|
||||||
<li class="text-neutral-300 text-sm flex items-center gap-2">
|
|
||||||
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
|
|
||||||
Automate Steam updates
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,25 +340,19 @@ import { Download, Globe, Wifi, LayoutDashboard, ArrowDown } from 'lucide-vue-ne
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Architecture Diagram -->
|
<!-- FINAL CTA -->
|
||||||
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
|
<section class="finalcta">
|
||||||
<div class="max-w-3xl mx-auto px-6 text-center">
|
<div class="finalcta__atmo" />
|
||||||
<h2 class="text-2xl font-bold text-neutral-100 mb-10">Architecture Overview</h2>
|
<div class="wrap finalcta__in reveal">
|
||||||
<div class="space-y-3">
|
<h2>Install the agent.<br>Never SSH again.</h2>
|
||||||
<div class="inline-block px-6 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-medium">Rust Server</div>
|
<div class="cta-row">
|
||||||
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
<div class="inline-block px-6 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-medium">Corrosion Plugin</div>
|
Join early access
|
||||||
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
|
</RouterLink>
|
||||||
<div class="inline-block px-6 py-3 bg-oxide-500/10 border border-oxide-500/30 rounded-lg text-oxide-400 font-medium">Secure NATS Messaging</div>
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
|
<Icon name="key" :size="17" />Sign in
|
||||||
<div class="inline-block px-6 py-3 bg-oxide-500/10 border border-oxide-500/30 rounded-lg text-oxide-400 font-medium">Corrosion Cloud</div>
|
</a>
|
||||||
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
|
|
||||||
<div class="inline-block px-6 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-medium">Dashboard + Store + Analytics</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-neutral-500 mt-10">
|
|
||||||
Corrosion does not proxy gameplay traffic. It orchestrates operations.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,288 +1,680 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Shield, Zap, RefreshCw, Terminal, Users, Wifi, Server, ChevronRight } from 'lucide-vue-next'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import { useThemeGame, type Game } from '@/composables/useThemeGame'
|
||||||
|
|
||||||
const panelUrl = import.meta.env.VITE_PANEL_URL || ''
|
const { setGame } = useThemeGame()
|
||||||
|
|
||||||
|
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
|
||||||
|
|
||||||
|
// ---- Game pill data ----
|
||||||
|
interface GameDef {
|
||||||
|
key: Game
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const GAMES: GameDef[] = [
|
||||||
|
{ key: 'rust', label: 'Rust', icon: 'box' },
|
||||||
|
{ key: 'dune', label: 'Dune: Awakening', icon: 'sun' },
|
||||||
|
{ key: 'soulmask', label: 'Soulmask', icon: 'drama' },
|
||||||
|
{ key: 'conan', label: 'Conan Exiles', icon: 'swords' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeGame = ref<Game>('rust')
|
||||||
|
const userPicked = ref(false)
|
||||||
|
let rotateTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let idx = 0
|
||||||
|
|
||||||
|
function pickGame(g: Game): void {
|
||||||
|
userPicked.value = true
|
||||||
|
activeGame.value = g
|
||||||
|
setGame(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
function heroIsVisible(): boolean {
|
||||||
|
const hero = document.querySelector('.hero')
|
||||||
|
if (!hero) return false
|
||||||
|
return hero.getBoundingClientRect().bottom >= 140
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRotation(): void {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
|
rotateTimer = setInterval(() => {
|
||||||
|
if (userPicked.value || !heroIsVisible()) return
|
||||||
|
idx = (idx + 1) % GAMES.length
|
||||||
|
const next = GAMES[idx]
|
||||||
|
if (next) {
|
||||||
|
activeGame.value = next.key
|
||||||
|
setGame(next.key)
|
||||||
|
}
|
||||||
|
}, 3400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Scroll-reveal via IntersectionObserver ----
|
||||||
|
let io: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
function initReveal(): void {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add('in')
|
||||||
|
io?.unobserve(e.target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
)
|
||||||
|
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Set initial game on html — stays in sync with the global composable
|
||||||
|
setGame('rust')
|
||||||
|
activeGame.value = 'rust'
|
||||||
|
startRotation()
|
||||||
|
initReveal()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (rotateTimer !== null) clearInterval(rotateTimer)
|
||||||
|
io?.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock sidebar game switcher active key mirrors activeGame
|
||||||
|
const mockActiveGame = activeGame
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<!-- HERO -->
|
||||||
<!-- Hero -->
|
<section class="hero">
|
||||||
<section class="relative overflow-hidden">
|
<div class="hero__atmo" />
|
||||||
<div class="max-w-6xl mx-auto px-6 pt-20 pb-24 text-center">
|
<div class="hero__grid" />
|
||||||
<h1 class="text-5xl md:text-6xl font-bold text-neutral-100 mb-6 tracking-tight">
|
<div class="hero__grain" />
|
||||||
The Control Plane<br />
|
<div class="wrap hero__in">
|
||||||
<span class="text-oxide-500">for Rust Servers</span>
|
<div class="hero__mark">
|
||||||
</h1>
|
<CorrosionMark :size="72" />
|
||||||
<p class="text-xl text-neutral-400 max-w-2xl mx-auto mb-10">
|
</div>
|
||||||
Deploy once. Automate everything. Never SSH again.
|
<div class="notpill">
|
||||||
|
<b>Not hosting.</b> Not a generic panel. Self-hosted, agent-based.
|
||||||
|
</div>
|
||||||
|
<h1>Run your game servers<span class="accent">like an operation.</span></h1>
|
||||||
|
<p class="hero__sub">
|
||||||
|
Corrosion is a management panel for self-hosted survival game servers. Deploy servers, automate
|
||||||
|
wipes, manage plugins and mods, schedule maintenance, monitor players, and orchestrate
|
||||||
|
multi-server worlds — from one command center.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center justify-center gap-4">
|
<div class="hero__cta">
|
||||||
<a :href="panelUrl + '/register'" class="px-8 py-3.5 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors text-lg">
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
Buy License
|
Join early access
|
||||||
</a>
|
</RouterLink>
|
||||||
<a href="#demo" class="px-8 py-3.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-semibold rounded-lg border border-neutral-700 transition-colors text-lg">
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
View Live Demo
|
<Icon name="key" :size="17" />Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Game pills -->
|
||||||
|
<div class="hero__games">
|
||||||
|
<button
|
||||||
|
v-for="g in GAMES"
|
||||||
|
:key="g.key"
|
||||||
|
class="gpill"
|
||||||
|
:data-on="String(activeGame === g.key)"
|
||||||
|
@click="pickGame(g.key)"
|
||||||
|
>
|
||||||
|
<Icon :name="g.icon" :size="15" />
|
||||||
|
<span>{{ g.label }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Gradient glow -->
|
</div>
|
||||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-oxide-500/10 rounded-full blur-3xl pointer-events-none" />
|
|
||||||
|
<!-- Panel mockup -->
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="mock">
|
||||||
|
<div class="mock__bar">
|
||||||
|
<div class="mock__dots">
|
||||||
|
<span /><span /><span />
|
||||||
|
</div>
|
||||||
|
<div class="mock__url">panel.corrosionmgmt.com / fleet</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock__body">
|
||||||
|
<aside class="mock__side">
|
||||||
|
<div class="mock__brand">
|
||||||
|
<span class="mark"><CorrosionMark :size="18" /></span>
|
||||||
|
<b>Corrosion</b>
|
||||||
|
</div>
|
||||||
|
<div class="mock__gs">
|
||||||
|
<span :class="{ on: mockActiveGame === 'rust' }">
|
||||||
|
<Icon name="box" :size="13" />
|
||||||
|
</span>
|
||||||
|
<span :class="{ on: mockActiveGame === 'dune' }">
|
||||||
|
<Icon name="sun" :size="13" />
|
||||||
|
</span>
|
||||||
|
<span :class="{ on: mockActiveGame === 'soulmask' }">
|
||||||
|
<Icon name="drama" :size="13" />
|
||||||
|
</span>
|
||||||
|
<span :class="{ on: mockActiveGame === 'conan' }">
|
||||||
|
<Icon name="swords" :size="13" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mock__nav on"><Icon name="layout-dashboard" :size="14" />Dashboard</div>
|
||||||
|
<div class="mock__nav"><Icon name="server" :size="14" />Servers</div>
|
||||||
|
<div class="mock__nav"><Icon name="terminal" :size="14" />Console</div>
|
||||||
|
<div class="mock__nav"><Icon name="trash-2" :size="14" />Wipes</div>
|
||||||
|
<div class="mock__nav"><Icon name="cpu" :size="14" />Agents</div>
|
||||||
|
</aside>
|
||||||
|
<main class="mock__main">
|
||||||
|
<div class="mock__kpis">
|
||||||
|
<div class="mock__kpi">
|
||||||
|
<div class="l">Servers running</div>
|
||||||
|
<div class="v">5<small>/6</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="mock__kpi">
|
||||||
|
<div class="l">Players online</div>
|
||||||
|
<div class="v">234</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock__kpi">
|
||||||
|
<div class="l">Agent nodes</div>
|
||||||
|
<div class="v">2<small>/2</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock__row">
|
||||||
|
<span class="g"><Icon name="box" :size="13" /></span>
|
||||||
|
<span class="nm">
|
||||||
|
Main · 2x Vanilla
|
||||||
|
<small>rust-host · rust</small>
|
||||||
|
</span>
|
||||||
|
<span class="st"><b />online</span>
|
||||||
|
</div>
|
||||||
|
<div class="mock__row">
|
||||||
|
<span class="g"><Icon name="sun" :size="13" /></span>
|
||||||
|
<span class="nm">
|
||||||
|
Arrakis · Hardcore
|
||||||
|
<small>dune-host · dune</small>
|
||||||
|
</span>
|
||||||
|
<span class="st"><b />online</span>
|
||||||
|
</div>
|
||||||
|
<div class="mock__row">
|
||||||
|
<span class="g"><Icon name="swords" :size="13" /></span>
|
||||||
|
<span class="nm">
|
||||||
|
Exiled Lands · PvP-C
|
||||||
|
<small>conan-host · conan</small>
|
||||||
|
</span>
|
||||||
|
<span class="st"><b />online</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wrap" style="text-align:center">
|
||||||
|
<div class="hero__foot">
|
||||||
|
One host agent, many game instances · Rust · Dune: Awakening · Soulmask · Conan Exiles ·
|
||||||
|
Windows & Linux hosts
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="height:80px" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- The Problem -->
|
<!-- PROBLEM -->
|
||||||
<section class="py-20 border-t border-neutral-800">
|
<section class="sec" id="problem">
|
||||||
<div class="max-w-4xl mx-auto px-6">
|
<div class="wrap">
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-8 text-center">The Problem</h2>
|
<div class="sec__head reveal">
|
||||||
<p class="text-neutral-400 text-lg mb-8 text-center">Running a Rust server today means:</p>
|
<span class="eyebrow">The problem</span>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto mb-10">
|
<h2 class="title">Game servers were never supposed<br>to be babysitting duty</h2>
|
||||||
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<span class="text-red-400 mt-0.5">✗</span>
|
|
||||||
<span class="text-neutral-300">Editing JSON configs over SFTP</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<div class="pain reveal">
|
||||||
<span class="text-red-400 mt-0.5">✗</span>
|
<div class="pain__item">
|
||||||
<span class="text-neutral-300">Babysitting wipes</span>
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Editing configs over SFTP
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<div class="pain__item">
|
||||||
<span class="text-red-400 mt-0.5">✗</span>
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
<span class="text-neutral-300">Manually installing and updating plugins</span>
|
Manually updating mods & plugins
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<div class="pain__item">
|
||||||
<span class="text-red-400 mt-0.5">✗</span>
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
<span class="text-neutral-300">Restarting servers blindly</span>
|
Guessing when a server crashed
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg md:col-span-2 md:max-w-sm md:mx-auto">
|
<div class="pain__item">
|
||||||
<span class="text-red-400 mt-0.5">✗</span>
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
<span class="text-neutral-300">Staying online when something crashes</span>
|
Running wipe day by hand
|
||||||
|
</div>
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Juggling Discord bots & cron tasks
|
||||||
|
</div>
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Linking multi-server clusters manually
|
||||||
|
</div>
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Managing admins without real permissions
|
||||||
|
</div>
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Explaining downtime to players
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center text-lg text-neutral-300 font-medium">
|
<p class="closing reveal">
|
||||||
Rust servers deserve <span class="text-oxide-400">orchestration</span> — not babysitting.
|
Your community sees the server. You deal with the chaos.<br>
|
||||||
|
<span class="accent">Corrosion gives you the control plane.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- The Shift -->
|
<!-- SHIFT -->
|
||||||
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
|
<section class="sec" id="shift">
|
||||||
<div class="max-w-4xl mx-auto px-6 text-center">
|
<div class="wrap">
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-8">The Shift</h2>
|
<div class="sec__head reveal">
|
||||||
<p class="text-lg text-neutral-400 mb-10">
|
<span class="eyebrow">The shift</span>
|
||||||
Corrosion moves Rust server administration to a unified cloud control plane.
|
<h2 class="title">Drop in the agent.<br>Take control from the panel.</h2>
|
||||||
|
<p class="lead">
|
||||||
|
One lightweight host agent runs on your machine and manages every game instance you assign
|
||||||
|
to it — an outbound-only ops runtime, not an exposed panel.
|
||||||
</p>
|
</p>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
|
||||||
<div class="p-6 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<div class="w-10 h-10 bg-oxide-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
|
||||||
<span class="text-oxide-400 text-xl font-bold">1</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-neutral-200 font-medium">Install one plugin</p>
|
<div class="steps reveal">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__n">1</div>
|
||||||
|
<b>Install the Corrosion Agent</b>
|
||||||
|
<p>One runtime on your Windows or Linux host. Outbound connection only.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<div class="step">
|
||||||
<div class="w-10 h-10 bg-oxide-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
<div class="step__n">2</div>
|
||||||
<span class="text-oxide-400 text-xl font-bold">2</span>
|
<b>Register your server or fleet</b>
|
||||||
|
<p>Connect one server, a cluster, or multiple game worlds on the same box.</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-neutral-200 font-medium">Register online</p>
|
<div class="step">
|
||||||
</div>
|
<div class="step__n">3</div>
|
||||||
<div class="p-6 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<b>Manage from the browser</b>
|
||||||
<div class="w-10 h-10 bg-oxide-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
<p>Console, files, schedules, wipes, plugins, mods, players, backups, metrics.</p>
|
||||||
<span class="text-oxide-400 text-xl font-bold">3</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-neutral-200 font-medium">Manage everything from your browser</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col md:flex-row items-center justify-center gap-4 text-neutral-500">
|
<div class="nots reveal">
|
||||||
<span>No open firewall ports.</span>
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No open inbound ports</span>
|
||||||
<span class="hidden md:inline">·</span>
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No constant SSH sessions</span>
|
||||||
<span>No manual file editing.</span>
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No config spelunking</span>
|
||||||
<span class="hidden md:inline">·</span>
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No fragile scripts</span>
|
||||||
<span>No SSH required.</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="closing reveal" style="font-size:var(--text-lg)">
|
||||||
|
You provide the machine.
|
||||||
|
<span class="accent">Corrosion provides the control plane.</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Core Capabilities -->
|
<!-- BLUEPRINTS / SUPPORTED GAMES -->
|
||||||
<section class="py-20 border-t border-neutral-800">
|
<section class="sec" id="blueprints">
|
||||||
<div class="max-w-6xl mx-auto px-6">
|
<div class="wrap">
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-12 text-center">Core Capabilities</h2>
|
<div class="sec__head reveal">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<span class="eyebrow">Supported games</span>
|
||||||
<!-- Operational Control -->
|
<h2 class="title">Game-aware blueprints,<br>not generic templates</h2>
|
||||||
|
<p class="lead">
|
||||||
|
Every game has a different operational reality. Corrosion models each one as an operations
|
||||||
|
blueprint — Rust wipes, Dune: Awakening battlegroups, Soulmask clusters, Conan persistent
|
||||||
|
worlds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="blueprints reveal">
|
||||||
|
<!-- Rust card — sets its own game scope via inline style on the surrounding element.
|
||||||
|
The token system resolves var(--accent) from data-game on <html> (set globally by
|
||||||
|
useThemeGame). Cards carry a data-game attr for future per-card scoping if desired. -->
|
||||||
|
<div class="bp" data-game="rust">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="box" :size="21" /></span>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold text-oxide-400 mb-6 uppercase tracking-wider">Operational Control</h3>
|
<div class="bp__name">Rust</div>
|
||||||
<div class="space-y-4">
|
<div class="bp__accent">Oxide Orange</div>
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
</div>
|
||||||
<RefreshCw class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
</div>
|
||||||
|
<div class="bp__role">Modded server operations</div>
|
||||||
|
<div class="bp__list">
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />uMod / Oxide plugin browsing</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Plugin config profiles</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Map / blueprint / full wipes</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Wipe schedules & map library</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Player & admin workflows</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp" data-game="dune">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="sun" :size="21" /></span>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-neutral-200 font-medium">Auto-Wiper with Rollback</p>
|
<div class="bp__name">Dune: Awakening</div>
|
||||||
<p class="text-sm text-neutral-500 mt-1">Full wipe sequences with health verification</p>
|
<div class="bp__accent">Spice Amber</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<div class="bp__role">Battlegroup orchestration</div>
|
||||||
<Terminal class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
<div class="bp__list">
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Deep Desert wipe scheduling</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Procedural map regeneration</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Service health checks</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Backups before maintenance</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Battlegroup lifecycle controls</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp" data-game="soulmask">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="drama" :size="21" /></span>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-neutral-200 font-medium">Real-Time Console + Player Control</p>
|
<div class="bp__name">Soulmask</div>
|
||||||
<p class="text-sm text-neutral-500 mt-1">Execute commands from your browser</p>
|
<div class="bp__accent">Ritual Jade</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<div class="bp__role">Linked-world cluster deployment</div>
|
||||||
<Zap class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
<div class="bp__list">
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />One-click linked-server clusters</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Linked map validation</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Port & config automation</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Cluster health</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Scheduled maintenance</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp" data-game="conan">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="swords" :size="21" /></span>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-neutral-200 font-medium">Web-Based Plugin Configuration</p>
|
<div class="bp__name">Conan Exiles</div>
|
||||||
<p class="text-sm text-neutral-500 mt-1">No more JSON editing over SFTP</p>
|
<div class="bp__accent">Hyborian Bronze</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<Server class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-neutral-200 font-medium">Automated Steam Updates</p>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Stay current without manual intervention</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Infrastructure & Scale -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-oxide-400 mb-6 uppercase tracking-wider">Infrastructure & Scale</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<Shield class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-neutral-200 font-medium">Companion Agent — No SSH Required</p>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Outbound-only secure connections</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<Users class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-neutral-200 font-medium">Multi-Admin Role-Based Access Control</p>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Scale your team without losing order</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<Wifi class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-neutral-200 font-medium">Zero Inbound Ports Required</p>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Your server initiates all connections</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bp__role">Persistent world management</div>
|
||||||
|
<div class="bp__list">
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Mod & server management</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Clan & player visibility</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Purge, decay & event tracking</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Backup & restart scheduling</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />World maintenance workflows</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Wipe Orchestration -->
|
<!-- CAPABILITIES -->
|
||||||
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
|
<section class="sec" id="caps">
|
||||||
<div class="max-w-4xl mx-auto px-6">
|
<div class="wrap">
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-4 text-center">Wipe Orchestration</h2>
|
<div class="sec__head reveal">
|
||||||
<p class="text-lg text-neutral-400 mb-10 text-center">
|
<span class="eyebrow">Core capabilities</span>
|
||||||
Wipes aren't just "delete map and restart."
|
<h2 class="title">Everything an operator needs</h2>
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-3 mb-10">
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Pre-Wipe</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Backup</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Map Rotation</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Steam Update</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Restart</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Health Check</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-oxide-500/15 border border-oxide-500/30 rounded-lg text-sm text-oxide-400">Rollback</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col md:flex-row items-center justify-center gap-6 text-neutral-400">
|
<div class="caps reveal">
|
||||||
<span>Every wipe is logged.</span>
|
<div class="caps__col">
|
||||||
|
<span class="eyebrow">Operations</span>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="power" :size="16" /></span>
|
||||||
|
<b>Server lifecycle control</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="terminal" :size="16" /></span>
|
||||||
|
<b>Real-time console</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="users" :size="16" /></span>
|
||||||
|
<b>Player visibility</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="folder-open" :size="16" /></span>
|
||||||
|
<b>File manager</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="calendar-clock" :size="16" /></span>
|
||||||
|
<b>Scheduled tasks & restart windows</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="caps__col">
|
||||||
|
<span class="eyebrow">Automation</span>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="refresh-cw" :size="16" /></span>
|
||||||
|
<b>Wipe orchestration</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="database-backup" :size="16" /></span>
|
||||||
|
<b>Backup-before-change</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="download" :size="16" /></span>
|
||||||
|
<b>SteamCMD / game updates</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="bell" :size="16" /></span>
|
||||||
|
<b>Discord / status announcements</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="undo-2" :size="16" /></span>
|
||||||
|
<b>Health checks & rollback</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="caps__col">
|
||||||
|
<span class="eyebrow">Game systems</span>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="puzzle" :size="16" /></span>
|
||||||
|
<b>Rust plugins & configs</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="sun" :size="16" /></span>
|
||||||
|
<b>Dune: Awakening battlegroups</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="drama" :size="16" /></span>
|
||||||
|
<b>Soulmask clusters</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="swords" :size="16" /></span>
|
||||||
|
<b>Conan mods & events</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="store" :size="16" /></span>
|
||||||
|
<b>Public pages & storefront</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- WIPE & MAINTENANCE ORCHESTRATION -->
|
||||||
|
<section class="sec" id="wipe">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Wipe & maintenance orchestration</span>
|
||||||
|
<h2 class="title">Wipes should be workflows,<br>not rituals</h2>
|
||||||
|
<p class="lead">
|
||||||
|
Rust map / BP / full wipes. Dune: Awakening Deep Desert wipes. Soulmask & Conan
|
||||||
|
maintenance and event resets — all as verified, logged sequences.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pipe reveal">
|
||||||
|
<span class="pchip">Pre-warning</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Backup</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Stop services</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Update</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Rotate map / config</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Restart</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Health check</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip pchip--last">Announce complete</span>
|
||||||
|
</div>
|
||||||
|
<div class="stack-lines reveal">
|
||||||
|
<span>Every operation is logged.</span>
|
||||||
<span>Every step is verified.</span>
|
<span>Every step is verified.</span>
|
||||||
<span class="text-oxide-400 font-medium">Rollback is one click away.</span>
|
<span class="hi">Rollback is one click away when supported.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Built Like Infrastructure -->
|
<!-- BUILT LIKE INFRASTRUCTURE -->
|
||||||
<section class="py-20 border-t border-neutral-800">
|
<section class="sec" id="platform">
|
||||||
<div class="max-w-4xl mx-auto px-6 text-center">
|
<div class="wrap">
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-4">Built Like Infrastructure</h2>
|
<div class="sec__head reveal">
|
||||||
<p class="text-lg text-neutral-400 mb-10">
|
<span class="eyebrow">Built like infrastructure</span>
|
||||||
Corrosion isn't a UI wrapper. It's a hosted SaaS platform built with:
|
<h2 class="title">Not a skin over SSH</h2>
|
||||||
|
<p class="lead">
|
||||||
|
A hosted control plane plus a host agent — with tenant isolation, command namespacing,
|
||||||
|
health reporting, and outbound-only connectivity.
|
||||||
</p>
|
</p>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-10">
|
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<p class="text-oxide-400 font-semibold mb-1">NestJS</p>
|
|
||||||
<p class="text-xs text-neutral-500">TypeScript backend</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<div class="infra reveal">
|
||||||
<p class="text-oxide-400 font-semibold mb-1">NATS</p>
|
<div class="icard">
|
||||||
<p class="text-xs text-neutral-500">JetStream messaging</p>
|
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||||
|
<b>Agent-based control</b>
|
||||||
|
<p>Your host connects to Corrosion. No exposed management panel required.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<div class="icard">
|
||||||
<p class="text-oxide-400 font-semibold mb-1">PostgreSQL</p>
|
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
||||||
<p class="text-xs text-neutral-500">Multi-tenant isolation</p>
|
<b>Tenant isolated</b>
|
||||||
|
<p>Every license, server, and command is scoped.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<div class="icard">
|
||||||
<p class="text-oxide-400 font-semibold mb-1">Outbound-Only</p>
|
<div class="icard__ic"><Icon name="route" :size="16" /></div>
|
||||||
<p class="text-xs text-neutral-500">Secure connections</p>
|
<b>Command namespaced</b>
|
||||||
|
<p>Server actions are routed intentionally, not sprayed blindly.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
||||||
|
<b>Event-driven</b>
|
||||||
|
<p>NATS-powered messaging keeps agents and panel in sync.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
|
||||||
|
<b>Observable</b>
|
||||||
|
<p>Health, metrics, task history, and agent status — all visible.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center gap-2 text-neutral-500 text-sm">
|
<div class="techrow reveal">
|
||||||
<span>Every server is scoped by license.</span>
|
<span>NestJS</span>
|
||||||
<span>Every command is namespaced.</span>
|
<span>NATS JetStream</span>
|
||||||
<span>Every tenant is isolated.</span>
|
<span>PostgreSQL</span>
|
||||||
|
<span>Go host agent</span>
|
||||||
|
<span>Outbound-only</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Public Server Sites -->
|
<!-- PUBLIC SITES & STOREFRONT -->
|
||||||
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
|
<section class="sec" id="store">
|
||||||
<div class="max-w-4xl mx-auto px-6 text-center">
|
<div class="wrap">
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-4">Public Server Sites & Storefront</h2>
|
<div class="sec__head reveal">
|
||||||
<p class="text-lg text-neutral-400 mb-10">Each license includes:</p>
|
<span class="eyebrow">Public server sites & storefront</span>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
<h2 class="title">Give your players a home base</h2>
|
||||||
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<p class="lead">
|
||||||
<p class="text-sm text-neutral-200">Public server page</p>
|
Publish a server page with live status, wipe countdowns, player counts, plugin / mod lists,
|
||||||
|
announcements, and optional storefront support.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<div class="chips reveal">
|
||||||
<p class="text-sm text-neutral-200">Wipe countdown</p>
|
<div class="chip-card"><Icon name="globe" :size="16" style="color:var(--accent-text)" />Public server page</div>
|
||||||
|
<div class="chip-card"><Icon name="users" :size="16" style="color:var(--accent-text)" />Live player count</div>
|
||||||
|
<div class="chip-card"><Icon name="timer" :size="16" style="color:var(--accent-text)" />Wipe countdown</div>
|
||||||
|
<div class="chip-card"><Icon name="puzzle" :size="16" style="color:var(--accent-text)" />Mod / plugin list</div>
|
||||||
|
<div class="chip-card"><Icon name="megaphone" :size="16" style="color:var(--accent-text)" />Announcements</div>
|
||||||
|
<div class="chip-card chip-card--accent"><Icon name="shopping-cart" :size="16" />Integrated webstore</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<p
|
||||||
<p class="text-sm text-neutral-200">Live player count</p>
|
class="closing reveal"
|
||||||
</div>
|
style="font-size:var(--text-md);color:var(--text-tertiary);font-weight:500"
|
||||||
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
>
|
||||||
<p class="text-sm text-neutral-200">Plugin/mod list</p>
|
Operate the server. Inform the players. Monetize without duct tape.
|
||||||
</div>
|
</p>
|
||||||
<div class="p-4 bg-neutral-900 border border-oxide-500/30 rounded-lg">
|
|
||||||
<p class="text-sm text-oxide-400">Integrated webstore</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-neutral-400">Monetize your server without third-party complexity.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- For Serious Admins -->
|
<!-- PRICING -->
|
||||||
<section class="py-20 border-t border-neutral-800">
|
<section class="sec" id="pricing">
|
||||||
<div class="max-w-3xl mx-auto px-6 text-center">
|
<div class="wrap">
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-8">For Serious Admins</h2>
|
<div class="sec__head reveal">
|
||||||
<p class="text-lg text-neutral-400 mb-8">If you:</p>
|
<span class="eyebrow">Pricing</span>
|
||||||
<div class="space-y-3 max-w-md mx-auto mb-10">
|
<h2 class="title">Scale from one server to a fleet</h2>
|
||||||
<p class="text-neutral-300 text-lg">Run scheduled wipes</p>
|
|
||||||
<p class="text-neutral-300 text-lg">Care about uptime</p>
|
|
||||||
<p class="text-neutral-300 text-lg">Want crash recovery</p>
|
|
||||||
<p class="text-neutral-300 text-lg">Want automation</p>
|
|
||||||
<p class="text-neutral-300 text-lg">Manage multiple admins</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xl text-oxide-400 font-semibold">Corrosion was built for you.</p>
|
<div class="pricing reveal">
|
||||||
|
<div class="plan">
|
||||||
|
<div class="plan__tag" />
|
||||||
|
<div class="plan__name">Hobby</div>
|
||||||
|
<div class="plan__price">$9.99<small>/mo</small></div>
|
||||||
|
<div class="plan__scope">1–5 non-commercial servers.</div>
|
||||||
|
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="plan">
|
||||||
|
<div class="plan__tag" />
|
||||||
|
<div class="plan__name">Community</div>
|
||||||
|
<div class="plan__price">$19.99<small>/mo</small></div>
|
||||||
|
<div class="plan__scope">6–10 non-commercial servers.</div>
|
||||||
|
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="plan plan--feature">
|
||||||
|
<div class="plan__tag">Most popular</div>
|
||||||
|
<div class="plan__name">Operator</div>
|
||||||
|
<div class="plan__price">$99.99<small>/mo</small></div>
|
||||||
|
<div class="plan__scope">Commercial use, or up to 50 servers.</div>
|
||||||
|
<RouterLink class="btn btn--primary" :to="{ name: 'early-access' }">Get Operator</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="plan">
|
||||||
|
<div class="plan__tag" />
|
||||||
|
<div class="plan__name">Network</div>
|
||||||
|
<div class="plan__price">$99.99<small>/mo</small></div>
|
||||||
|
<div class="plan__scope">50+ servers for fleets and hosting partners. Fleet Blocks add capacity.</div>
|
||||||
|
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fleetblock reveal">
|
||||||
|
<b>Fleet Block</b>
|
||||||
|
<span class="p">+$49.99/mo</span>
|
||||||
|
<span>each additional 50 servers — stack as many as your network needs.</span>
|
||||||
|
</div>
|
||||||
|
<p class="commercial reveal">
|
||||||
|
<b>Commercial use</b> includes monetized communities, paid access, VIP slots, donations,
|
||||||
|
sponsorship-supported servers, hosting providers, or managing servers for others.
|
||||||
|
</p>
|
||||||
|
<p class="support-note reveal">
|
||||||
|
Community support is included with every plan (documentation, community forum, diagnostics,
|
||||||
|
structured bug reports).
|
||||||
|
<b>Direct 1:1 support</b> is available separately at $125/hour in prepaid 1-hour blocks.
|
||||||
|
Corrosion is a tool, not a managed service.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- CTA -->
|
<!-- FOR SERIOUS ADMINS -->
|
||||||
<section class="py-24 bg-neutral-900/50 border-t border-neutral-800">
|
<section class="sec" id="admins">
|
||||||
<div class="max-w-3xl mx-auto px-6 text-center">
|
<div class="wrap">
|
||||||
<p class="text-2xl text-neutral-400 mb-2">Stop babysitting your server.</p>
|
<div class="sec__head reveal">
|
||||||
<p class="text-3xl font-bold text-neutral-100 mb-10">Start orchestrating it.</p>
|
<span class="eyebrow">For serious admins</span>
|
||||||
<a :href="panelUrl + '/register'" class="inline-block px-10 py-4 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors text-lg">
|
<h2 class="title">Built for admins<br>who are done babysitting</h2>
|
||||||
Get Corrosion
|
</div>
|
||||||
|
<div class="admins reveal">
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />You run more than a toy server.</span>
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Your players expect uptime.</span>
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Wipe day needs a plan.</span>
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Mods and plugins need control.</span>
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Admin access needs boundaries.</span>
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Your community deserves better than guesswork.</span>
|
||||||
|
</div>
|
||||||
|
<p class="closing reveal accent">Stop babysitting your server. Start orchestrating it.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FINAL CTA -->
|
||||||
|
<section class="finalcta">
|
||||||
|
<div class="finalcta__atmo" />
|
||||||
|
<div class="wrap finalcta__in reveal">
|
||||||
|
<h2>Ready to run your servers<br>like an operation?</h2>
|
||||||
|
<div class="cta-row">
|
||||||
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
|
Join early access
|
||||||
|
</RouterLink>
|
||||||
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
|
<Icon name="key" :size="17" />Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,129 +1,429 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { Check } from 'lucide-vue-next'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
|
|
||||||
|
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
|
||||||
|
|
||||||
|
// Scroll-reveal
|
||||||
|
let io: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
function initReveal(): void {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add('in')
|
||||||
|
io?.unobserve(e.target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
)
|
||||||
|
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { initReveal() })
|
||||||
|
onUnmounted(() => { io?.disconnect() })
|
||||||
|
|
||||||
|
interface PlanFeature {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
name: string
|
||||||
|
price: string
|
||||||
|
period: string
|
||||||
|
scope: string
|
||||||
|
tag: string
|
||||||
|
featured: boolean
|
||||||
|
cta: string
|
||||||
|
ctaVariant: 'primary' | 'ghost'
|
||||||
|
features: PlanFeature[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const plans: Plan[] = [
|
||||||
|
{
|
||||||
|
name: 'Hobby',
|
||||||
|
price: '$9.99',
|
||||||
|
period: '/mo',
|
||||||
|
scope: '1–5 servers · non-commercial use',
|
||||||
|
tag: '',
|
||||||
|
featured: false,
|
||||||
|
cta: 'Join early access',
|
||||||
|
ctaVariant: 'ghost',
|
||||||
|
features: [
|
||||||
|
{ text: 'Up to 5 game server instances' },
|
||||||
|
{ text: 'Non-commercial servers only' },
|
||||||
|
{ text: 'Auto-wiper with rollback' },
|
||||||
|
{ text: 'Plugin management (Rust)' },
|
||||||
|
{ text: 'File manager + real-time console' },
|
||||||
|
{ text: 'Scheduled tasks' },
|
||||||
|
{ text: 'Public server page' },
|
||||||
|
{ text: 'Community support' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Community',
|
||||||
|
price: '$19.99',
|
||||||
|
period: '/mo',
|
||||||
|
scope: '6–10 servers · non-commercial use',
|
||||||
|
tag: '',
|
||||||
|
featured: false,
|
||||||
|
cta: 'Join early access',
|
||||||
|
ctaVariant: 'ghost',
|
||||||
|
features: [
|
||||||
|
{ text: 'Up to 10 game server instances' },
|
||||||
|
{ text: 'Non-commercial servers only' },
|
||||||
|
{ text: 'Auto-wiper with rollback' },
|
||||||
|
{ text: 'Plugin management (Rust)' },
|
||||||
|
{ text: 'File manager + real-time console' },
|
||||||
|
{ text: 'Scheduled tasks' },
|
||||||
|
{ text: 'Public server page' },
|
||||||
|
{ text: 'Community support' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Operator',
|
||||||
|
price: '$99.99',
|
||||||
|
period: '/mo',
|
||||||
|
scope: 'Commercial use · or 11–50 servers',
|
||||||
|
tag: 'Most popular',
|
||||||
|
featured: true,
|
||||||
|
cta: 'Get Operator',
|
||||||
|
ctaVariant: 'primary',
|
||||||
|
features: [
|
||||||
|
{ text: 'Up to 50 game server instances' },
|
||||||
|
{ text: 'Commercial use permitted' },
|
||||||
|
{ text: 'All games: Rust, Dune, Soulmask, Conan' },
|
||||||
|
{ text: 'Auto-wiper with rollback' },
|
||||||
|
{ text: 'Plugin + mod management' },
|
||||||
|
{ text: 'File manager + real-time console' },
|
||||||
|
{ text: 'Scheduled tasks + maintenance windows' },
|
||||||
|
{ text: 'Player management + RBAC team access' },
|
||||||
|
{ text: 'Public server page + storefront' },
|
||||||
|
{ text: 'Community support + priority bug triage' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Network',
|
||||||
|
price: '$99.99',
|
||||||
|
period: '/mo',
|
||||||
|
scope: '50+ servers · hosting partners + fleets',
|
||||||
|
tag: '',
|
||||||
|
featured: false,
|
||||||
|
cta: 'Join early access',
|
||||||
|
ctaVariant: 'ghost',
|
||||||
|
features: [
|
||||||
|
{ text: '50 servers base included' },
|
||||||
|
{ text: 'Fleet Blocks: +$49.99/mo per 50 servers' },
|
||||||
|
{ text: 'Commercial use permitted' },
|
||||||
|
{ text: 'All games + multi-game hosts' },
|
||||||
|
{ text: 'Full Operator feature set' },
|
||||||
|
{ text: 'Fleet-level management' },
|
||||||
|
{ text: 'Priority bug triage for platform issues' },
|
||||||
|
{ text: 'Community support' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<!-- PAGE HEADER -->
|
||||||
<!-- Header -->
|
<section class="hero" style="padding-bottom:0; border-bottom:none;">
|
||||||
<section class="pt-20 pb-12">
|
<div class="hero__atmo" />
|
||||||
<div class="max-w-4xl mx-auto px-6 text-center">
|
<div class="hero__grid" />
|
||||||
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">Pricing</h1>
|
<div class="hero__grain" />
|
||||||
<p class="text-lg text-neutral-400">Simple. Transparent. No hidden tiers.</p>
|
<div class="wrap hero__in" style="padding-bottom: 52px;">
|
||||||
|
<div class="hero__mark">
|
||||||
|
<CorrosionMark :size="56" />
|
||||||
|
</div>
|
||||||
|
<span class="eyebrow">Pricing</span>
|
||||||
|
<h1 style="font-size:var(--text-5xl)">
|
||||||
|
Scale from one server
|
||||||
|
<span class="accent">to a fleet.</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero__sub">
|
||||||
|
Simple tiers based on how many servers you run and whether you operate commercially.
|
||||||
|
No per-seat charges. No surprises.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Pricing Cards -->
|
<!-- PRICING CARDS -->
|
||||||
<section class="pb-20">
|
<section class="sec" id="plans">
|
||||||
<div class="max-w-5xl mx-auto px-6">
|
<div class="wrap">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div class="pricing reveal">
|
||||||
<!-- Base License -->
|
<div
|
||||||
<div class="bg-neutral-900 border-2 border-oxide-500/40 rounded-xl p-8 relative">
|
v-for="plan in plans"
|
||||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
|
:key="plan.name"
|
||||||
<span class="px-3 py-1 bg-oxide-600 text-white text-xs font-semibold rounded-full uppercase tracking-wider">Most Popular</span>
|
class="plan"
|
||||||
|
:class="plan.featured ? 'plan--feature' : ''"
|
||||||
|
>
|
||||||
|
<div class="plan__tag">{{ plan.tag }}</div>
|
||||||
|
<div class="plan__name">{{ plan.name }}</div>
|
||||||
|
<div class="plan__price">
|
||||||
|
{{ plan.price }}<small>{{ plan.period }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center mb-6">
|
<div class="plan__scope">{{ plan.scope }}</div>
|
||||||
<h3 class="text-xl font-bold text-neutral-100 mb-2">Base License</h3>
|
|
||||||
<div class="flex items-baseline justify-center gap-1">
|
<ul class="plan__feats">
|
||||||
<span class="text-4xl font-bold text-oxide-400">$50</span>
|
<li v-for="feat in plan.features" :key="feat.text">
|
||||||
</div>
|
<Icon name="check" :size="13" style="color:var(--accent-text);flex:none" />
|
||||||
<p class="text-sm text-neutral-500 mt-1">One server. Lifetime access.</p>
|
{{ feat.text }}
|
||||||
<p class="text-xs text-oxide-400/70 mt-1">Launch Price</p>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-3 mb-8">
|
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
|
||||||
Full control plane
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
|
||||||
Auto-Wiper with rollback
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
|
||||||
Plugin management
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
|
||||||
Public server site
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
|
||||||
Multi-admin RBAC
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<RouterLink to="/register" class="block w-full py-3 bg-oxide-600 hover:bg-oxide-700 text-white text-center font-semibold rounded-lg transition-colors">
|
|
||||||
Get Started
|
<RouterLink
|
||||||
|
class="btn"
|
||||||
|
:class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'"
|
||||||
|
:to="{ name: 'early-access' }"
|
||||||
|
>
|
||||||
|
{{ plan.cta }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Webstore Add-On -->
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
|
|
||||||
<div class="text-center mb-6">
|
|
||||||
<h3 class="text-xl font-bold text-neutral-100 mb-2">Webstore Add-On</h3>
|
|
||||||
<div class="flex items-baseline justify-center gap-1">
|
|
||||||
<span class="text-4xl font-bold text-neutral-200">$10</span>
|
|
||||||
<span class="text-neutral-500">/mo</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Integrated monetization platform.</p>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-3 mb-8">
|
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
|
||||||
Item catalog
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
|
||||||
Stripe + PayPal
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
|
||||||
Auto-delivery via RCON
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
|
||||||
Transaction history
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<button class="block w-full py-3 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 text-center font-semibold rounded-lg border border-neutral-700 transition-colors">
|
|
||||||
Coming Soon
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modules -->
|
<!-- Fleet Block -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
|
<div class="fleetblock reveal">
|
||||||
<div class="text-center mb-6">
|
<b>Fleet Block</b>
|
||||||
<h3 class="text-xl font-bold text-neutral-100 mb-2">Modules</h3>
|
<span class="p">+$49.99/mo</span>
|
||||||
<div class="flex items-baseline justify-center gap-1">
|
<span>each additional 50 servers — stack as many as your network needs.</span>
|
||||||
<span class="text-4xl font-bold text-neutral-200">$9.99</span>
|
|
||||||
<span class="text-neutral-500">+</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-neutral-500 mt-1">Optional feature expansions.</p>
|
|
||||||
|
<!-- Commercial use definition -->
|
||||||
|
<p class="commercial reveal">
|
||||||
|
<b>Commercial use</b> includes monetized communities, paid access, VIP slots, donations,
|
||||||
|
sponsorship-supported servers, hosting providers, or managing servers for others.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Support model -->
|
||||||
|
<p class="support-note reveal">
|
||||||
|
Community support is included with every plan — documentation, community forum,
|
||||||
|
diagnostics, and structured bug reports.
|
||||||
|
<b>Direct 1:1 support</b> is available separately at $125/hour in prepaid 1-hour blocks.
|
||||||
|
Corrosion is a tool, not a managed service.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-3 mb-8">
|
</section>
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
<!-- COMPARISON TABLE -->
|
||||||
Analytics & insights
|
<section class="sec" id="compare">
|
||||||
</li>
|
<div class="wrap">
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
<div class="sec__head reveal">
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
<span class="eyebrow">Feature breakdown</span>
|
||||||
Discord bot integration
|
<h2 class="title">What is included in each tier</h2>
|
||||||
</li>
|
</div>
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
<div class="compare-table reveal">
|
||||||
Cloud backups
|
<div class="compare-table__head">
|
||||||
</li>
|
<div class="compare-table__label">Feature</div>
|
||||||
<li class="flex items-center gap-3 text-sm text-neutral-300">
|
<div>Hobby</div>
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
|
<div>Community</div>
|
||||||
More on the roadmap
|
<div class="compare-table__featured">Operator</div>
|
||||||
</li>
|
<div>Network</div>
|
||||||
</ul>
|
</div>
|
||||||
<RouterLink to="/site/roadmap" class="block w-full py-3 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 text-center font-semibold rounded-lg border border-neutral-700 transition-colors">
|
|
||||||
View Roadmap
|
<div class="compare-table__row">
|
||||||
</RouterLink>
|
<div class="compare-table__label">Server instances</div>
|
||||||
|
<div>1–5</div>
|
||||||
|
<div>6–10</div>
|
||||||
|
<div class="compare-table__featured">Up to 50</div>
|
||||||
|
<div>50 + Fleet Blocks</div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-table__row">
|
||||||
|
<div class="compare-table__label">Commercial use</div>
|
||||||
|
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
|
||||||
|
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
|
||||||
|
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-table__row">
|
||||||
|
<div class="compare-table__label">Rust (Oxide/uMod)</div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-table__row">
|
||||||
|
<div class="compare-table__label">All supported games</div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-table__row">
|
||||||
|
<div class="compare-table__label">Auto-wiper + rollback</div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-table__row">
|
||||||
|
<div class="compare-table__label">Real-time console</div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-table__row">
|
||||||
|
<div class="compare-table__label">RBAC team access</div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-table__row">
|
||||||
|
<div class="compare-table__label">Public server page</div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-table__row">
|
||||||
|
<div class="compare-table__label">Priority bug triage</div>
|
||||||
|
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
|
||||||
|
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
|
||||||
|
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
|
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- SUPPORT MODEL -->
|
||||||
|
<section class="sec" id="support">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Support model</span>
|
||||||
|
<h2 class="title">Corrosion is a tool,<br>not a managed service</h2>
|
||||||
|
<p class="lead">
|
||||||
|
Every plan includes self-service support. Hands-on time is available separately — at
|
||||||
|
an honest rate, when you actually need it.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="infra reveal">
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="file-text" :size="16" /></div>
|
||||||
|
<b>Documentation</b>
|
||||||
|
<p>Setup guides, architecture reference, troubleshooting walkthroughs. Included on every plan.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="message-square" :size="16" /></div>
|
||||||
|
<b>Community forum</b>
|
||||||
|
<p>Operator-to-operator knowledge base. Questions, configs, and war stories. All plans.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
|
||||||
|
<b>Diagnostics</b>
|
||||||
|
<p>Built-in agent health checks, log access, and structured bug reports. All plans.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
||||||
|
<b>Priority bug triage</b>
|
||||||
|
<p>Platform bugs for Operator and Network customers go to the front of the queue.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="clock" :size="16" /></div>
|
||||||
|
<b>Direct 1:1 support</b>
|
||||||
|
<p>$125/hour, prepaid in 1-hour blocks. Available to any customer who needs it.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="closing reveal" style="font-size:var(--text-md);color:var(--text-tertiary);font-weight:500">
|
||||||
|
Direct server administration, firewall configuration, mod installation, and wipe-day
|
||||||
|
hand-holding are not included in any plan. Corrosion gives you the panel and the tools.
|
||||||
|
You run the operation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FINAL CTA -->
|
||||||
|
<section class="finalcta">
|
||||||
|
<div class="finalcta__atmo" />
|
||||||
|
<div class="wrap finalcta__in reveal">
|
||||||
|
<h2>Ready to stop babysitting<br>your servers?</h2>
|
||||||
|
<div class="cta-row">
|
||||||
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
|
Join early access
|
||||||
|
</RouterLink>
|
||||||
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
|
<Icon name="key" :size="17" />Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plan__feats {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
.plan__feats li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.plan__feats li svg { margin-top: 1px; }
|
||||||
|
|
||||||
|
/* Comparison table */
|
||||||
|
.compare-table {
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.compare-table__head,
|
||||||
|
.compare-table__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1.2fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.compare-table__head {
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-caps);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.compare-table__head > div,
|
||||||
|
.compare-table__row > div {
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.compare-table__head > div:first-child,
|
||||||
|
.compare-table__row > div:first-child {
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.compare-table__row {
|
||||||
|
background: var(--surface-base);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: 11px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.compare-table__row:last-child { border-bottom: none; }
|
||||||
|
.compare-table__label { color: var(--text-secondary); font-weight: 500; }
|
||||||
|
.compare-table__featured {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,146 +1,353 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Check, Circle } from 'lucide-vue-next'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
|
|
||||||
interface Phase {
|
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
|
||||||
name: string
|
|
||||||
label: string
|
type Status = 'shipped' | 'in-progress' | 'planned'
|
||||||
status: 'complete' | 'current' | 'upcoming'
|
|
||||||
items: { text: string; done: boolean }[]
|
interface RoadmapItem {
|
||||||
|
text: string
|
||||||
|
note?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const phases: Phase[] = [
|
interface RoadmapGroup {
|
||||||
|
status: Status
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
items: RoadmapItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: RoadmapGroup[] = [
|
||||||
{
|
{
|
||||||
name: 'Phase 1',
|
status: 'shipped',
|
||||||
label: 'Foundation',
|
label: 'Phase 1 — Foundation',
|
||||||
status: 'complete',
|
description:
|
||||||
|
'The core control plane is live. Rust server operators can install the agent, connect their server, and manage it entirely from the panel.',
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Core control plane', done: true },
|
{ text: 'Host agent (Windows + Linux)', note: 'Go binary, outbound NATS, zero inbound ports' },
|
||||||
{ text: 'Auto-Wiper with rollback', done: true },
|
{ text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' },
|
||||||
{ text: 'Plugin management', done: true },
|
{ text: 'Auto-wiper with rollback', note: 'Map, BP, and full wipes as verified sequences' },
|
||||||
{ text: 'Public server site', done: true },
|
{ text: 'Plugin management', note: 'Rust uMod/Oxide — browse, install, configure from the browser' },
|
||||||
|
{ text: 'Real-time console', note: 'NATS-bridged live output' },
|
||||||
|
{ text: 'File manager', note: 'Browser-based file access via the agent' },
|
||||||
|
{ text: 'Scheduled tasks and maintenance windows' },
|
||||||
|
{ text: 'Player management and RBAC team access' },
|
||||||
|
{ text: 'Public server page', note: 'Live status, wipe countdown, player count' },
|
||||||
|
{ text: 'SteamCMD game update automation' },
|
||||||
|
{ text: 'Discord and notification webhooks' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Phase 2',
|
status: 'in-progress',
|
||||||
label: 'Analytics',
|
label: 'Multi-game expansion',
|
||||||
status: 'current',
|
description:
|
||||||
|
'The agent and control plane are being extended with per-game blueprints. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same agent model with game-specific operational logic.',
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Player retention tracking', done: false },
|
{ text: 'Dune: Awakening blueprint', note: 'Deep Desert wipe scheduling, battlegroup lifecycle' },
|
||||||
{ text: 'Wipe performance insights', done: false },
|
{ text: 'Conan Exiles blueprint', note: 'Persistent world management, mod support, purge tracking' },
|
||||||
{ text: 'Population heatmaps', done: false },
|
{ text: 'Soulmask blueprint', note: 'Linked-world cluster deployment, port automation' },
|
||||||
|
{ text: 'Multi-instance host runtime', note: 'One agent managing N game processes on the same machine' },
|
||||||
|
{ text: 'Per-game wipe and event scheduling' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Phase 3',
|
status: 'planned',
|
||||||
label: 'Status Platform',
|
label: 'API access and integrations',
|
||||||
status: 'upcoming',
|
description:
|
||||||
|
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane.',
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Public uptime tracking', done: false },
|
{ text: 'Public REST API for server management' },
|
||||||
{ text: 'Server health dashboard', done: false },
|
{ text: 'Webhook events (wipe completed, server down, player banned)' },
|
||||||
|
{ text: 'API key management per license' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Phase 4',
|
status: 'planned',
|
||||||
label: 'Module Marketplace',
|
label: 'Integrated storefront',
|
||||||
status: 'upcoming',
|
description:
|
||||||
|
'A native store layer for server communities — item catalog, VIP packages, and automated in-game delivery. No Tebex dependency required.',
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Loot manager', done: false },
|
{ text: 'Item catalog and categories' },
|
||||||
{ text: 'Event systems', done: false },
|
{ text: 'PayPal and Stripe payment processing' },
|
||||||
{ text: 'Advanced gameplay modules', done: false },
|
{ text: 'Automated in-game delivery via RCON/agent' },
|
||||||
|
{ text: 'Transaction history and revenue dashboard' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Phase 5',
|
status: 'planned',
|
||||||
label: 'Integrated Webstore',
|
label: 'Fleet management for hosting partners',
|
||||||
status: 'upcoming',
|
description:
|
||||||
|
'Tools for hosting providers and multi-server operations running 50+ instances across multiple physical hosts.',
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Native item store', done: false },
|
{ text: 'Fleet-level dashboards and health monitoring' },
|
||||||
{ text: 'Automated delivery', done: false },
|
{ text: 'Multi-host agent orchestration' },
|
||||||
{ text: 'Revenue dashboard', done: false },
|
{ text: 'Bulk wipe and update scheduling across a fleet' },
|
||||||
|
{ text: 'Fleet Block capacity management' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Phase 6',
|
status: 'planned',
|
||||||
label: 'B2B Hosting Integration',
|
label: 'More games',
|
||||||
status: 'upcoming',
|
description:
|
||||||
|
'Corrosion\'s agent model is not Rust-specific. The platform is being designed to support any game that can be managed via process control, file operations, and RCON-style commands.',
|
||||||
items: [
|
items: [
|
||||||
{ text: 'White-label panel', done: false },
|
{ text: 'Additional survival and sandbox games' },
|
||||||
{ text: 'Bulk license provisioning', done: false },
|
{ text: 'Community-requested game blueprints' },
|
||||||
{ text: 'SSO integration', done: false },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function phaseStatusClass(status: string): string {
|
function statusLabel(s: Status): string {
|
||||||
switch (status) {
|
if (s === 'shipped') return 'Shipped'
|
||||||
case 'complete': return 'bg-green-500/10 text-green-400 border-green-500/20'
|
if (s === 'in-progress') return 'In progress'
|
||||||
case 'current': return 'bg-oxide-500/10 text-oxide-400 border-oxide-500/20'
|
return 'Planned'
|
||||||
default: return 'bg-neutral-800 text-neutral-500 border-neutral-700'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function phaseStatusLabel(status: string): string {
|
function statusIcon(s: Status): string {
|
||||||
switch (status) {
|
if (s === 'shipped') return 'check'
|
||||||
case 'complete': return 'Shipped'
|
if (s === 'in-progress') return 'refresh-cw'
|
||||||
case 'current': return 'In Progress'
|
return 'circle'
|
||||||
default: return 'Planned'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll-reveal
|
||||||
|
let io: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
function initReveal(): void {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add('in')
|
||||||
|
io?.unobserve(e.target)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
)
|
||||||
|
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { initReveal() })
|
||||||
|
onUnmounted(() => { io?.disconnect() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<!-- PAGE HEADER -->
|
||||||
<!-- Header -->
|
<section class="hero" style="padding-bottom:0; border-bottom:none;">
|
||||||
<section class="pt-20 pb-12">
|
<div class="hero__atmo" />
|
||||||
<div class="max-w-4xl mx-auto px-6 text-center">
|
<div class="hero__grid" />
|
||||||
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">Roadmap</h1>
|
<div class="hero__grain" />
|
||||||
<p class="text-lg text-neutral-400">
|
<div class="wrap hero__in" style="padding-bottom:52px;">
|
||||||
Corrosion isn't a single plugin release. It's infrastructure for the Rust ecosystem.
|
<div class="hero__mark">
|
||||||
|
<CorrosionMark :size="56" />
|
||||||
|
</div>
|
||||||
|
<span class="eyebrow">Roadmap</span>
|
||||||
|
<h1 style="font-size:var(--text-5xl)">
|
||||||
|
Where Corrosion
|
||||||
|
<span class="accent">is going.</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero__sub">
|
||||||
|
No specific dates. No fabricated percentages. Status labels only: Shipped, In progress,
|
||||||
|
Planned. This roadmap reflects what is actually being built.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Timeline -->
|
<!-- STATUS LEGEND -->
|
||||||
<section class="pb-20">
|
<section class="sec" style="padding:32px 0; border-bottom: 1px solid var(--border-subtle);">
|
||||||
<div class="max-w-3xl mx-auto px-6">
|
<div class="wrap">
|
||||||
<div class="space-y-6">
|
<div class="rm-legend reveal">
|
||||||
|
<div class="rm-badge rm-badge--shipped">
|
||||||
|
<Icon name="check" :size="13" />Shipped
|
||||||
|
</div>
|
||||||
|
<div class="rm-badge rm-badge--progress">
|
||||||
|
<Icon name="refresh-cw" :size="13" />In progress
|
||||||
|
</div>
|
||||||
|
<div class="rm-badge rm-badge--planned">
|
||||||
|
<Icon name="circle" :size="13" />Planned
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ROADMAP GROUPS -->
|
||||||
|
<section class="sec" id="roadmap">
|
||||||
|
<div class="wrap">
|
||||||
<div
|
<div
|
||||||
v-for="phase in phases"
|
v-for="group in groups"
|
||||||
:key="phase.name"
|
:key="group.label"
|
||||||
class="bg-neutral-900 border rounded-xl p-8 transition-colors"
|
class="rm-group reveal"
|
||||||
:class="phase.status === 'current' ? 'border-oxide-500/30' : 'border-neutral-800'"
|
:data-status="group.status"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-5">
|
<div class="rm-group__head">
|
||||||
<div>
|
<div
|
||||||
<span class="text-xs font-bold text-neutral-500 uppercase tracking-wider">{{ phase.name }}</span>
|
class="rm-group__badge"
|
||||||
<h3 class="text-xl font-bold text-neutral-100">{{ phase.label }}</h3>
|
:class="`rm-badge--${group.status}`"
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-xs font-semibold px-3 py-1 rounded-full border"
|
|
||||||
:class="phaseStatusClass(phase.status)"
|
|
||||||
>
|
>
|
||||||
{{ phaseStatusLabel(phase.status) }}
|
<Icon :name="statusIcon(group.status)" :size="13" />
|
||||||
</span>
|
{{ statusLabel(group.status) }}
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-3">
|
<h3 class="rm-group__title">{{ group.label }}</h3>
|
||||||
|
</div>
|
||||||
|
<p class="rm-group__desc">{{ group.description }}</p>
|
||||||
|
<ul class="rm-group__list">
|
||||||
<li
|
<li
|
||||||
v-for="item in phase.items"
|
v-for="item in group.items"
|
||||||
:key="item.text"
|
:key="item.text"
|
||||||
class="flex items-center gap-3"
|
class="rm-item"
|
||||||
>
|
|
||||||
<Check v-if="item.done" class="w-4 h-4 text-green-400 shrink-0" />
|
|
||||||
<Circle v-else class="w-4 h-4 text-neutral-600 shrink-0" />
|
|
||||||
<span
|
|
||||||
class="text-sm"
|
|
||||||
:class="item.done ? 'text-neutral-300' : 'text-neutral-500'"
|
|
||||||
>
|
>
|
||||||
|
<span class="rm-item__dot" :class="`rm-item__dot--${group.status}`" />
|
||||||
|
<span>
|
||||||
{{ item.text }}
|
{{ item.text }}
|
||||||
|
<span v-if="item.note" class="rm-item__note">— {{ item.note }}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HONEST NOTE -->
|
||||||
|
<section class="sec" style="padding:40px 0; border-bottom:none;">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="closing reveal">
|
||||||
|
This roadmap reflects real development priorities, not marketing promises.
|
||||||
|
Timelines are not published because they depend on real-world testing and operator
|
||||||
|
feedback. <span class="accent">Join early access to influence what gets built next.</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero__cta reveal" style="margin-top:28px">
|
||||||
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
|
Join early access
|
||||||
|
</RouterLink>
|
||||||
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
|
<Icon name="key" :size="17" />Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Legend */
|
||||||
|
.rm-legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.rm-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
.rm-badge--shipped {
|
||||||
|
background: color-mix(in srgb, var(--status-online) 14%, transparent);
|
||||||
|
color: var(--status-online);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--status-online) 28%, transparent);
|
||||||
|
}
|
||||||
|
.rm-badge--in-progress, .rm-badge--progress {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.rm-badge--planned {
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Groups */
|
||||||
|
.rm-group {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto 40px;
|
||||||
|
padding: 28px 28px 24px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.rm-group[data-status="in-progress"] {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.rm-group[data-status="shipped"] {
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--status-online) 22%, transparent);
|
||||||
|
}
|
||||||
|
.rm-group__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.rm-group__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.rm-group__title {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.rm-group__desc {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
max-width: 680px;
|
||||||
|
}
|
||||||
|
.rm-group__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.rm-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.rm-item__dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: none;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.rm-item__dot--shipped { background: var(--status-online); }
|
||||||
|
.rm-item__dot--in-progress { background: var(--accent); }
|
||||||
|
.rm-item__dot--planned {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--text-muted);
|
||||||
|
}
|
||||||
|
.rm-item__note {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -41,12 +41,8 @@ interface StatusResponse {
|
|||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const servers = ref<ServerStatus[]>([])
|
const servers = ref<ServerStatus[]>([])
|
||||||
const platformHealth = ref<PlatformHealth>({
|
// null until the first successful fetch — KPIs render '—', never fake zeros
|
||||||
total_servers: 0,
|
const platformHealth = ref<PlatformHealth | null>(null)
|
||||||
online_servers: 0,
|
|
||||||
total_players: 0,
|
|
||||||
uptime_percent: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -148,10 +144,10 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Platform KPIs -->
|
<!-- Platform KPIs -->
|
||||||
<div v-if="!loading" class="sp-kpis">
|
<div v-if="!loading" class="sp-kpis">
|
||||||
<StatCard icon="server" label="Total servers" :value="String(platformHealth.total_servers)" />
|
<StatCard icon="server" label="Total servers" :value="platformHealth ? String(platformHealth.total_servers) : '—'" />
|
||||||
<StatCard icon="activity" label="Online now" :value="String(platformHealth.online_servers)" />
|
<StatCard icon="activity" label="Online now" :value="platformHealth ? String(platformHealth.online_servers) : '—'" />
|
||||||
<StatCard icon="users" label="Total players" :value="String(platformHealth.total_players)" />
|
<StatCard icon="users" label="Total players" :value="platformHealth ? String(platformHealth.total_players) : '—'" />
|
||||||
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth.uptime_percent, 1)" unit="%" />
|
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth?.uptime_percent ?? null, 1, '—')" unit="%" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { defineConfig } from 'vite'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
|||||||
Reference in New Issue
Block a user