Compare commits
34 Commits
ef128b47d2
...
agent-v2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
769d75d937 | ||
|
|
f440fd7751 | ||
|
|
29615cb4f3 | ||
|
|
376ed9a98d | ||
|
|
b42a2d7ea7 | ||
|
|
560d023250 | ||
|
|
f91ef84832 |
@@ -42,3 +42,6 @@ FRONTEND_URL=http://localhost:5174
|
||||
|
||||
# Frontend (Vite — must be prefixed with VITE_)
|
||||
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:
|
||||
push:
|
||||
@@ -26,19 +26,19 @@ jobs:
|
||||
run: |
|
||||
cd companion-agent
|
||||
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
|
||||
chmod +x bin/corrosion-companion-linux-amd64
|
||||
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-host-agent-linux-amd64
|
||||
|
||||
- name: Build Windows AMD64
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
cd companion-agent/bin
|
||||
sha256sum corrosion-companion-linux-amd64 > checksums.txt
|
||||
sha256sum corrosion-companion-windows-amd64.exe >> checksums.txt
|
||||
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
|
||||
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
|
||||
cat checksums.txt
|
||||
|
||||
- name: Create Release
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-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")
|
||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
|
||||
@@ -68,15 +68,15 @@ jobs:
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @companion-agent/bin/corrosion-companion-linux-amd64 \
|
||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-linux-amd64"
|
||||
--data-binary @companion-agent/bin/corrosion-host-agent-linux-amd64 \
|
||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-linux-amd64"
|
||||
|
||||
# Upload Windows binary
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @companion-agent/bin/corrosion-companion-windows-amd64.exe \
|
||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=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-host-agent-windows-amd64.exe"
|
||||
|
||||
# Upload checksums
|
||||
curl -s -X POST \
|
||||
@@ -89,43 +89,43 @@ jobs:
|
||||
run: |
|
||||
CDN_URL="https://cdn.corrosionmgmt.com"
|
||||
|
||||
# Upload Linux binary to /companion/latest/
|
||||
# Upload Linux binary to /host-agent/latest/
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
|
||||
"${CDN_URL}/companion/latest/corrosion-companion-linux-amd64"
|
||||
-F "file=@companion-agent/bin/corrosion-host-agent-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 \
|
||||
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
|
||||
"${CDN_URL}/companion/latest/corrosion-companion-windows-amd64.exe"
|
||||
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
|
||||
"${CDN_URL}/host-agent/latest/corrosion-host-agent-windows-amd64.exe"
|
||||
|
||||
# Upload checksums
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/checksums.txt" \
|
||||
"${CDN_URL}/companion/latest/checksums.txt"
|
||||
"${CDN_URL}/host-agent/latest/checksums.txt"
|
||||
|
||||
# Also upload versioned copies
|
||||
VERSION=${{ steps.version.outputs.VERSION }}
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
|
||||
"${CDN_URL}/companion/${VERSION}/corrosion-companion-linux-amd64"
|
||||
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
|
||||
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-linux-amd64"
|
||||
curl -s -X POST \
|
||||
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
|
||||
"${CDN_URL}/companion/${VERSION}/corrosion-companion-windows-amd64.exe"
|
||||
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
|
||||
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-windows-amd64.exe"
|
||||
curl -s -X POST \
|
||||
-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
|
||||
run: |
|
||||
echo "## Companion Agent Build Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Corrosion Host Agent 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 "" >> $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 "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-windows-amd64.exe) 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-host-agent-windows-amd64.exe) bytes)" >> $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
|
||||
on: [push]
|
||||
# On-demand only — no reason to spin a container on every push.
|
||||
on: [workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -17,8 +18,15 @@ jobs:
|
||||
echo "Memory: $(free -h | grep Mem | awk '{print $2}')"
|
||||
echo "Disk: $(df -h / | tail -1 | awk '{print $4}')"
|
||||
echo "==========================================="
|
||||
echo "Go: $(go version)"
|
||||
echo "Rust: $(rustc --version)"
|
||||
echo "Docker: $(docker --version)"
|
||||
# Jobs run in a bare node:20-bullseye container: toolchains are NOT
|
||||
# preinstalled — workflows must bootstrap them (setup-go, rustup).
|
||||
# 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 "✅ 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)**
|
||||
|
||||
- **Model:** haiku
|
||||
- **Model:** sonnet[1m]
|
||||
|
||||
- **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]
|
||||
|
||||
### 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)
|
||||
|
||||
**Frontend:**
|
||||
|
||||
32
CLAUDE.md
32
CLAUDE.md
@@ -55,7 +55,12 @@ frontend/ # Vue 3 + TypeScript
|
||||
package.json
|
||||
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
|
||||
internal/ # Core agent logic (nats, commands, process)
|
||||
Makefile # Build for Linux/Windows
|
||||
@@ -91,14 +96,16 @@ cd backend-nest && npx tsc --noEmit # Type-check without building
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm run dev # Vite dev server (port 5174)
|
||||
cd frontend && npm run build # Production build → dist/
|
||||
cd frontend && npm run lint # ESLint
|
||||
cd frontend && npm run type-check # TypeScript checking (vue-tsc)
|
||||
cd frontend && npm run build # vue-tsc -b && vite build (type-check included; no separate lint/type-check scripts exist)
|
||||
|
||||
# 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 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 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`)
|
||||
- Document why, not just what, in commits and CHANGELOG
|
||||
- **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
|
||||
|
||||
@@ -431,3 +439,11 @@ Things I discovered about myself building a sister platform across multiple sess
|
||||
20. **Parallel state fields that track related things will drift apart — and the bugs are silent.** When two fields represent aspects of the same state (`captureMode` and `vkiMode`, or `isLoading` and `error`, or `connection_status` and `companion_last_seen`), every code path that mutates one must also update the other. But new code paths get added over time, and they only update the field they know about. Future me: when you see two fields tracking related state, grep for ALL mutation sites of each — if any path updates one but not the other, that's a bug waiting to happen. And when you add a new mutation path, check every sibling field, not just the obvious one.
|
||||
|
||||
21. **Route through the component that survives transitions, not the one that doesn't.** When two systems can handle the same job but one is resilient to failure modes and the other isn't, route through the survivor. Don't build infrastructure to prop up the fragile path when the robust path already exists. In this project: NATS request-reply through the companion agent is the robust path; direct WebSocket to the browser is the fragile one. If a feature can work through either, prefer the path that handles disconnects, reconnects, and restarts gracefully. One routing change beats an entire retry/recovery subsystem.
|
||||
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
@@ -44,10 +44,14 @@ import { FurnaceSplitterModule } from './modules/furnacesplitter/furnacesplitter
|
||||
import { BetterChatModule } from './modules/betterchat/betterchat.module';
|
||||
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
||||
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
||||
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||
|
||||
// Shared Services
|
||||
import { NatsService } from './services/nats.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 { SteamService } from './services/steam.service';
|
||||
|
||||
// Gateway
|
||||
@@ -90,6 +94,9 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
||||
// Scheduler
|
||||
ScheduleModule.forRoot(),
|
||||
|
||||
// Repositories for app-level shared services (host-agent consumer)
|
||||
TypeOrmModule.forFeature([ServerConnection, License]),
|
||||
|
||||
// Feature Modules
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
@@ -123,6 +130,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
||||
BetterChatModule,
|
||||
TimedExecuteModule,
|
||||
RaidableBasesModule,
|
||||
EarlyAccessModule,
|
||||
],
|
||||
providers: [
|
||||
// Global guards (order matters: auth first, then license, then permissions)
|
||||
@@ -132,6 +140,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
||||
// Shared services
|
||||
NatsService,
|
||||
NatsBridgeService,
|
||||
HostAgentConsumerService,
|
||||
SteamService,
|
||||
|
||||
// WebSocket gateway
|
||||
|
||||
@@ -71,7 +71,10 @@ export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconne
|
||||
|
||||
// Subscribe to NATS events for this license
|
||||
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({
|
||||
type: 'event',
|
||||
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 { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AdminSeedService } from './admin-seed.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
@@ -27,7 +28,7 @@ import { TeamMember } from '../../entities/team-member.entity';
|
||||
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
providers: [AuthService, AdminSeedService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -108,7 +108,9 @@ export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect
|
||||
|
||||
const message = JSON.stringify({ event, data });
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
154
backend-nest/src/services/host-agent-consumer.service.ts
Normal file
154
backend-nest/src/services/host-agent-consumer.service.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* Consumes Corrosion wire protocol v2 host-agent subjects
|
||||
* (corrosion-host-agent/PROTOCOL.md) and keeps server_connections truthful.
|
||||
*
|
||||
* Before this service existed, NOTHING persisted agent heartbeats:
|
||||
* companion_last_seen was written once at setup and connection_status stayed
|
||||
* 'connected' forever. Now: heartbeat -> last_seen + connected (row
|
||||
* auto-created on first contact), going_offline beacon -> offline, and a
|
||||
* staleness sweep marks hosts offline when heartbeats stop arriving.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HostAgentConsumerService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(HostAgentConsumerService.name);
|
||||
|
||||
/** licenseId -> cache expiry epoch-ms. Positive = exists, absent = unknown. */
|
||||
private knownLicenses = new Map<string, number>();
|
||||
/** Unknown/garbage license ids we already warned about (anti log-spam). */
|
||||
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;
|
||||
/** 3x the agent's default 60s heartbeat (which jitters to max 72s). */
|
||||
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>,
|
||||
) {}
|
||||
|
||||
// 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).catch((err) =>
|
||||
this.logger.error(`heartbeat handling failed for ${licenseId}: ${err.message}`, err.stack),
|
||||
);
|
||||
void data; // payload telemetry is bridged to the browser; persistence here is liveness only
|
||||
});
|
||||
|
||||
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): Promise<void> {
|
||||
if (!(await this.isValidTenant(licenseId))) return;
|
||||
|
||||
const now = new Date();
|
||||
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 },
|
||||
);
|
||||
if (existing.connection_status !== 'connected') {
|
||||
this.logger.log(`host agent for license ${licenseId} is back online`);
|
||||
}
|
||||
} else {
|
||||
// First contact from a host agent: auto-register the connection so the
|
||||
// panel lights up without a manual setup step.
|
||||
await this.connectionRepository.save(
|
||||
this.connectionRepository.create({
|
||||
license_id: licenseId,
|
||||
connection_type: 'bare_metal',
|
||||
connection_status: 'connected',
|
||||
companion_last_seen: now,
|
||||
}),
|
||||
);
|
||||
this.logger.log(`host agent registered for license ${licenseId} (first heartbeat)`);
|
||||
}
|
||||
}
|
||||
|
||||
private async onGoingOffline(licenseId: string): Promise<void> {
|
||||
if (!(await this.isValidTenant(licenseId))) return;
|
||||
|
||||
await this.connectionRepository.update(
|
||||
{ license_id: licenseId },
|
||||
{ connection_status: 'offline', updated_at: new Date() },
|
||||
);
|
||||
this.logger.log(`host agent 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.
|
||||
*/
|
||||
@Interval(60_000)
|
||||
async sweepStaleConnections(): Promise<void> {
|
||||
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
|
||||
const result = 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();
|
||||
|
||||
if (result.affected) {
|
||||
this.logger.warn(`marked ${result.affected} stale host connection(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 { NatsBridgeService } from './nats-bridge.service';
|
||||
export { HostAgentConsumerService } from './host-agent-consumer.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';
|
||||
|
||||
@Injectable()
|
||||
export class NatsBridgeService implements OnModuleInit {
|
||||
export class NatsBridgeService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(NatsBridgeService.name);
|
||||
private listeners: Map<string, Set<(event: string, data: unknown) => void>> = new Map();
|
||||
|
||||
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) => {
|
||||
const licenseId = subject.split('.')[1];
|
||||
this.emit(licenseId, 'heartbeat', data);
|
||||
@@ -44,6 +49,17 @@ export class NatsBridgeService implements OnModuleInit {
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.PHONY: all build build-linux build-windows clean test run
|
||||
|
||||
# Binary names
|
||||
BINARY_NAME=corrosion-companion
|
||||
BINARY_NAME=corrosion-host-agent
|
||||
BINARY_LINUX=$(BINARY_NAME)-linux-amd64
|
||||
BINARY_WINDOWS=$(BINARY_NAME)-windows-amd64.exe
|
||||
|
||||
@@ -66,10 +66,10 @@ run: build-local
|
||||
install-service:
|
||||
@echo "Installing systemd service..."
|
||||
@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 enable corrosion-companion
|
||||
@echo "Service installed. Configure /etc/corrosion-companion/.env then start with: sudo systemctl start corrosion-companion"
|
||||
@sudo systemctl enable corrosion-host-agent
|
||||
@echo "Service installed. Configure /etc/corrosion-host-agent/.env then start with: sudo systemctl start corrosion-host-agent"
|
||||
|
||||
# Development helpers
|
||||
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.3"
|
||||
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(())
|
||||
}
|
||||
527
corrosion-host-agent/src/filemanager.rs
Normal file
527
corrosion-host-agent/src/filemanager.rs
Normal file
@@ -0,0 +1,527 @@
|
||||
//! 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()))?;
|
||||
let meta = item.metadata().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 (mirrors Go's `copyRecursive`).
|
||||
fn copy_recursive(src: &Path, dest: &Path) -> anyhow::Result<()> {
|
||||
let meta = fs::metadata(src)
|
||||
.with_context(|| format!("stat source '{}'", 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})")
|
||||
}
|
||||
405
corrosion-host-agent/tests/filemanager.rs
Normal file
405
corrosion-host-agent/tests/filemanager.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
//! 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}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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}
|
||||
volumes:
|
||||
- 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:
|
||||
- "8101:5432"
|
||||
healthcheck:
|
||||
@@ -80,7 +87,10 @@ services:
|
||||
api:
|
||||
condition: service_started
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en" class="dark" data-theme="dark" data-game="rust">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
@@ -9,8 +9,35 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<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>
|
||||
/* 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. */
|
||||
(function () {
|
||||
try {
|
||||
var el = document.documentElement;
|
||||
var t = localStorage.getItem('cc-theme');
|
||||
var g = localStorage.getItem('cc-game');
|
||||
if (t === 'dark' || t === 'light') {
|
||||
el.setAttribute('data-theme', t);
|
||||
el.classList.toggle('dark', t === 'dark');
|
||||
}
|
||||
if (g) el.setAttribute('data-game', g);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-neutral-950">
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import ToastNotification from '@/components/ToastNotification.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>
|
||||
|
||||
<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
|
||||
8
frontend/src/assets/corrosion-mark.svg
Normal file
8
frontend/src/assets/corrosion-mark.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Corrosion">
|
||||
<path d="M40.2 9.45 A24 24 0 0 1 54.6 23.8" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||
<path d="M54.6 40.2 A24 24 0 0 1 40.2 54.6" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||
<path d="M23.8 54.6 A24 24 0 0 1 9.45 40.2" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||
<path d="M9.45 23.8 A24 24 0 0 1 23.8 9.45" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||
<path d="M32 16V24M32 40V48M16 32H24M40 32H48" stroke="currentColor" stroke-width="3.6" stroke-linecap="round"></path>
|
||||
<circle cx="32" cy="32" r="4.4" fill="currentColor"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 770 B |
@@ -1,7 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onErrorCaptured } from 'vue'
|
||||
import { AlertTriangle } from 'lucide-vue-next'
|
||||
import { ref, watch, onErrorCaptured } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Icon from '@/components/ds/core/Icon.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 errorMessage = ref('')
|
||||
|
||||
@@ -12,6 +20,12 @@ onErrorCaptured((err) => {
|
||||
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() {
|
||||
hasError.value = false
|
||||
errorMessage.value = ''
|
||||
@@ -20,18 +34,72 @@ function retry() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasError" class="min-h-screen bg-neutral-950 flex items-center justify-center p-6">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-8 max-w-md w-full text-center">
|
||||
<AlertTriangle class="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h1 class="text-xl font-bold text-neutral-100 mb-2">Something went wrong</h1>
|
||||
<p class="text-sm text-neutral-400 mb-6">{{ errorMessage }}</p>
|
||||
<button
|
||||
@click="retry"
|
||||
class="px-4 py-2 bg-oxide-500 hover:bg-oxide-600 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<div v-if="hasError" class="eb-screen" :class="{ 'eb-screen--content': variant === 'content' }">
|
||||
<div class="eb-card">
|
||||
<div class="eb-icon-wrap">
|
||||
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
|
||||
</div>
|
||||
<h1 class="eb-title">Something went wrong</h1>
|
||||
<p class="eb-msg">{{ errorMessage }}</p>
|
||||
<Button icon="refresh-cw" @click="retry">Retry</Button>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.eb-screen {
|
||||
min-height: 100vh;
|
||||
background: var(--surface-canvas);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.eb-screen--content {
|
||||
min-height: 60vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.eb-card {
|
||||
background: var(--surface-base);
|
||||
box-shadow: var(--ring-default), var(--shadow-md);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-8);
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eb-icon-wrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--status-offline-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--status-offline-border);
|
||||
color: var(--status-offline);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.eb-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.eb-msg {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.55;
|
||||
max-width: 300px;
|
||||
}
|
||||
</style>
|
||||
|
||||
29
frontend/src/components/brand/CorrosionMark.vue
Normal file
29
frontend/src/components/brand/CorrosionMark.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Corrosion brand mark — segmented "C-core" reticle.
|
||||
* A bold ring split into four arc segments around a centered control node,
|
||||
* with N/E/S/W targeting ticks. Drawn in `currentColor` so it themes to the
|
||||
* active accent (set `color: var(--accent)` on a parent) and stays crisp to ~12px.
|
||||
* Source: design-system assets/mark.svg (64×64 viewBox).
|
||||
*/
|
||||
withDefaults(defineProps<{ size?: number | string }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Corrosion"
|
||||
>
|
||||
<path d="M40.2 9.45 A24 24 0 0 1 54.6 23.8" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||
<path d="M54.6 40.2 A24 24 0 0 1 40.2 54.6" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||
<path d="M23.8 54.6 A24 24 0 0 1 9.45 40.2" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||
<path d="M9.45 23.8 A24 24 0 0 1 23.8 9.45" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||
<path d="M32 16V24M32 40V48M16 32H24M40 32H48" stroke="currentColor" stroke-width="3.6" stroke-linecap="round" />
|
||||
<circle cx="32" cy="32" r="4.4" fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
92
frontend/src/components/ds/brand/Logo.vue
Normal file
92
frontend/src/components/ds/brand/Logo.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Logo — Corrosion brand lockup.
|
||||
* Composes the CorrosionMark SVG + Oxanium wordmark + optional tagline.
|
||||
*
|
||||
* The mark renders in `currentColor`, so set `color: var(--accent)` on a
|
||||
* parent (or pass `markColor`) to theme it per active game.
|
||||
*
|
||||
* Props mirror Logo.jsx exactly:
|
||||
* size — base px size; drives mark em-size + wordmark scaling
|
||||
* wordmark — show the "Corrosion" text (default true)
|
||||
* tagline — false | true (→ "Management Panel") | custom string
|
||||
* glow — accent drop-shadow for marketing / login hero use
|
||||
* markColor — force a fixed color on the mark (bypasses currentColor theming)
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
wordmark?: boolean
|
||||
tagline?: boolean | string
|
||||
glow?: boolean
|
||||
markColor?: string
|
||||
}>(),
|
||||
{ size: 26, wordmark: true, tagline: false, glow: false },
|
||||
)
|
||||
|
||||
const gap = computed(() => Math.round(props.size * 0.4) + 'px')
|
||||
const wordmarkGap = computed(() => Math.round(props.size * 0.14) + 'px')
|
||||
const wordmarkFontSize = computed(() => (props.size * 0.62) + 'px')
|
||||
const taglineFontSize = computed(() => Math.max(8, props.size * 0.26) + 'px')
|
||||
const glowFilter = computed(() =>
|
||||
props.glow ? `drop-shadow(0 0 ${props.size * 0.5}px var(--accent-glow))` : 'none'
|
||||
)
|
||||
const tagText = computed(() =>
|
||||
typeof props.tagline === 'string' ? props.tagline : 'Management Panel'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="cc-logo"
|
||||
:style="{ display: 'inline-flex', alignItems: 'center', gap, lineHeight: 1 }"
|
||||
>
|
||||
<!-- Mark wrapper: sets font-size so CorrosionMark's 1em sizing works; applies glow -->
|
||||
<span
|
||||
:style="{
|
||||
fontSize: size + 'px',
|
||||
display: 'inline-flex',
|
||||
filter: glowFilter,
|
||||
color: markColor ?? undefined,
|
||||
}"
|
||||
>
|
||||
<CorrosionMark :size="size" />
|
||||
</span>
|
||||
|
||||
<!-- Wordmark + optional tagline -->
|
||||
<span
|
||||
v-if="wordmark"
|
||||
:style="{ display: 'inline-flex', flexDirection: 'column', gap: wordmarkGap }"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
fontFamily: 'var(--font-brand)',
|
||||
fontWeight: 800,
|
||||
fontSize: wordmarkFontSize,
|
||||
letterSpacing: '0.005em',
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1,
|
||||
}"
|
||||
>Corrosion</span>
|
||||
<span
|
||||
v-if="tagline"
|
||||
:style="{
|
||||
fontFamily: 'var(--font-brand)',
|
||||
fontWeight: 600,
|
||||
fontSize: taglineFontSize,
|
||||
letterSpacing: '0.26em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--accent-text)',
|
||||
lineHeight: 1,
|
||||
}"
|
||||
>{{ tagText }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-logo { user-select: none; }
|
||||
</style>
|
||||
62
frontend/src/components/ds/core/Badge.vue
Normal file
62
frontend/src/components/ds/core/Badge.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
/** Badge — compact status/label chip. Tone drives fg/soft-bg/border; `solid` fills. */
|
||||
import { computed } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import StatusDot from './StatusDot.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tone?: 'neutral' | 'accent' | 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping'
|
||||
solid?: boolean
|
||||
dot?: boolean
|
||||
pulse?: boolean
|
||||
icon?: string
|
||||
size?: 'md' | 'lg'
|
||||
mono?: boolean
|
||||
uppercase?: boolean
|
||||
}>(),
|
||||
{ tone: 'neutral', solid: false, dot: false, pulse: false, size: 'md', mono: false, uppercase: false },
|
||||
)
|
||||
|
||||
const NEUTRAL: [string, string, string] = ['var(--text-secondary)', 'var(--surface-raised-2)', 'var(--border-default)']
|
||||
const TONES: Record<string, [string, string, string]> = {
|
||||
neutral: NEUTRAL,
|
||||
accent: ['var(--accent-text)', 'var(--accent-soft)', 'var(--accent-border)'],
|
||||
online: ['var(--status-online)', 'var(--status-online-soft)', 'var(--status-online-border)'],
|
||||
offline: ['var(--status-offline)', 'var(--status-offline-soft)', 'var(--status-offline-border)'],
|
||||
warn: ['var(--status-warn)', 'var(--status-warn-soft)', 'var(--status-warn-border)'],
|
||||
info: ['var(--status-info)', 'var(--status-info-soft)', 'var(--status-info-border)'],
|
||||
starting: ['var(--status-starting)', 'var(--status-starting-soft)', 'var(--status-starting-border)'],
|
||||
wiping: ['var(--status-wiping)', 'var(--status-wiping-soft)', 'var(--status-wiping-border)'],
|
||||
}
|
||||
|
||||
const styleObj = computed(() => {
|
||||
const [fg, soft, border] = TONES[props.tone] ?? NEUTRAL
|
||||
return props.solid
|
||||
? { background: fg, color: 'var(--surface-canvas)', boxShadow: 'none' }
|
||||
: { background: soft, color: fg, boxShadow: `inset 0 0 0 1px ${border}` }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="cc-badge"
|
||||
:class="[size === 'lg' && 'cc-badge--lg', mono && 'cc-badge--mono', uppercase && 'cc-badge--uppercase']"
|
||||
:style="styleObj"
|
||||
>
|
||||
<StatusDot v-if="dot" :tone="tone" :size="6" :pulse="pulse" />
|
||||
<Icon v-if="icon" :name="icon" :size="size === 'lg' ? 13 : 12" :stroke-width="2.5" />
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-badge {
|
||||
display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 8px;
|
||||
font-family: var(--font-sans); font-weight: 600; font-size: var(--text-xs); line-height: 1;
|
||||
border-radius: var(--radius-sm); white-space: nowrap; letter-spacing: 0.005em;
|
||||
}
|
||||
.cc-badge--lg { height: 24px; padding: 0 10px; font-size: var(--text-sm); }
|
||||
.cc-badge--mono { font-family: var(--font-mono); font-weight: 500; letter-spacing: 0; font-variant-numeric: tabular-nums; }
|
||||
.cc-badge--uppercase { text-transform: uppercase; letter-spacing: var(--tracking-wider); font-size: var(--text-2xs); }
|
||||
</style>
|
||||
82
frontend/src/components/ds/core/Button.vue
Normal file
82
frontend/src/components/ds/core/Button.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Button — primary action control; `variant="primary"` carries the live game accent.
|
||||
* Variants: primary | secondary | ghost | outline | danger | danger-soft.
|
||||
* Sizes: sm | md | lg. Pass Lucide names via `icon` / `iconRight`.
|
||||
* Native click bubbles via attribute fall-through (root is the <button>).
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'outline' | 'danger' | 'danger-soft'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
icon?: string
|
||||
iconRight?: string
|
||||
loading?: boolean
|
||||
block?: boolean
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}>(),
|
||||
{ variant: 'primary', size: 'md', loading: false, block: false, disabled: false, type: 'button' },
|
||||
)
|
||||
|
||||
const iconSize = computed(() => (props.size === 'lg' ? 17 : props.size === 'sm' ? 14 : 15))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
:class="[
|
||||
'cc-btn',
|
||||
`cc-btn--${variant}`,
|
||||
size !== 'md' && `cc-btn--${size}`,
|
||||
block && 'cc-btn--block',
|
||||
]"
|
||||
>
|
||||
<span v-if="loading" class="cc-btn__spin" />
|
||||
<Icon v-else-if="icon" :name="icon" :size="iconSize" :stroke-width="2.25" />
|
||||
<span v-if="$slots.default"><slot /></span>
|
||||
<Icon v-if="iconRight && !loading" :name="iconRight" :size="iconSize" :stroke-width="2.25" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
font-family: var(--font-sans); font-weight: 600; font-size: var(--text-sm); line-height: 1;
|
||||
white-space: nowrap; height: var(--control-h-md); padding: 0 14px;
|
||||
border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; user-select: none;
|
||||
transition: var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.cc-btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.cc-btn:active { transform: translateY(0.5px); }
|
||||
.cc-btn[disabled], .cc-btn[aria-disabled="true"] { opacity: 0.45; pointer-events: none; }
|
||||
.cc-btn--block { width: 100%; }
|
||||
.cc-btn--sm { height: var(--control-h-sm); padding: 0 10px; font-size: var(--text-xs); border-radius: var(--radius-sm); gap: 6px; }
|
||||
.cc-btn--lg { height: var(--control-h-lg); padding: 0 18px; font-size: var(--text-base); gap: 9px; }
|
||||
|
||||
.cc-btn--primary { background: var(--accent); color: var(--accent-contrast); }
|
||||
.cc-btn--primary:hover { background: var(--accent-hover); }
|
||||
.cc-btn--primary:active { background: var(--accent-press); }
|
||||
|
||||
.cc-btn--secondary { background: var(--surface-raised-2); color: var(--text-primary); box-shadow: var(--ring-default); }
|
||||
.cc-btn--secondary:hover { background: var(--surface-active); }
|
||||
|
||||
.cc-btn--ghost { background: transparent; color: var(--text-secondary); }
|
||||
.cc-btn--ghost:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
|
||||
.cc-btn--outline { background: transparent; color: var(--accent-text); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||
.cc-btn--outline:hover { background: var(--accent-soft); }
|
||||
|
||||
.cc-btn--danger { background: var(--danger); color: #fff; }
|
||||
.cc-btn--danger:hover { filter: brightness(1.1); }
|
||||
|
||||
.cc-btn--danger-soft { background: var(--status-offline-soft); color: var(--danger); box-shadow: inset 0 0 0 1px var(--status-offline-border); }
|
||||
.cc-btn--danger-soft:hover { background: var(--danger); color: #fff; }
|
||||
|
||||
.cc-btn__spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid currentColor; border-top-color: transparent; animation: cc-btn-spin 0.6s linear infinite; }
|
||||
@keyframes cc-btn-spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
88
frontend/src/components/ds/core/Icon.vue
Normal file
88
frontend/src/components/ds/core/Icon.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Icon — renders a Lucide icon by kebab-case name (matches the design system's
|
||||
* string `icon` prop API, e.g. <Icon name="refresh-cw" />). Maps to
|
||||
* `lucide-vue-next` via a registry so the bundle only ships icons we use.
|
||||
* Lucide icons render with `currentColor`, so they theme to the parent's color.
|
||||
* Add new icons to `registry` as the port grows.
|
||||
*/
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Play, Pause, RefreshCw, Trash2, Settings, Terminal, Power, Box, Sun, Moon,
|
||||
Loader, LoaderCircle, TrendingUp, TrendingDown, Minus, Plus, Server, Users,
|
||||
Puzzle, FolderOpen, Cpu, BarChart3, Rocket, TriangleAlert, Bell, Search,
|
||||
ChevronDown, ChevronRight, ChevronLeft, ChevronUp, Check, X, Calendar, Clock,
|
||||
ShoppingCart, CreditCard, HardDrive, Activity, Shield, Download, Upload,
|
||||
Wifi, WifiOff, Map, Gauge, Gift, Flame, DoorOpen, Pickaxe, Swords, Crosshair,
|
||||
Navigation, MessageSquare, FileText, Bookmark, ExternalLink, Copy, LogOut,
|
||||
Eye, EyeOff, Globe, Key, Layers, List, MoreVertical, Zap,
|
||||
Info, OctagonAlert, CircleCheck, Sparkles, Inbox,
|
||||
LayoutDashboard, CalendarClock, Drama, ChevronsUpDown, ServerCog,
|
||||
LayoutGrid, SquareDashed, MemoryStick, CornerDownLeft,
|
||||
Ban, Flag,
|
||||
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
|
||||
Pencil, Save, ShoppingBag, Target, User,
|
||||
// Marketing site additions
|
||||
Route, Timer, Megaphone, DatabaseBackup, Store, Undo2,
|
||||
Circle, Send, HelpCircle,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ name: string; size?: number; strokeWidth?: number }>(),
|
||||
{ size: 16, strokeWidth: 2 },
|
||||
)
|
||||
|
||||
const registry: Record<string, Component> = {
|
||||
play: Play, pause: Pause, 'refresh-cw': RefreshCw, 'trash-2': Trash2,
|
||||
settings: Settings, terminal: Terminal, power: Power, box: Box, sun: Sun,
|
||||
moon: Moon, loader: LoaderCircle, 'loader-2': Loader, 'trending-up': TrendingUp,
|
||||
'trending-down': TrendingDown, minus: Minus, plus: Plus, server: Server,
|
||||
users: Users, puzzle: Puzzle, 'folder-open': FolderOpen, cpu: Cpu,
|
||||
'bar-chart-3': BarChart3, rocket: Rocket, 'triangle-alert': TriangleAlert,
|
||||
bell: Bell, search: Search, 'chevron-down': ChevronDown,
|
||||
'chevron-right': ChevronRight, 'chevron-left': ChevronLeft, 'chevron-up': ChevronUp,
|
||||
check: Check, x: X, calendar: Calendar, clock: Clock,
|
||||
'shopping-cart': ShoppingCart, 'credit-card': CreditCard, 'hard-drive': HardDrive,
|
||||
activity: Activity, shield: Shield, download: Download, upload: Upload,
|
||||
wifi: Wifi, 'wifi-off': WifiOff, map: Map, gauge: Gauge, gift: Gift,
|
||||
flame: Flame, 'door-open': DoorOpen, pickaxe: Pickaxe, swords: Swords,
|
||||
crosshair: Crosshair, navigation: Navigation, 'message-square': MessageSquare,
|
||||
'file-text': FileText, bookmark: Bookmark, 'external-link': ExternalLink,
|
||||
copy: Copy, 'log-out': LogOut, eye: Eye, 'eye-off': EyeOff, globe: Globe,
|
||||
key: Key, layers: Layers, list: List, 'more-vertical': MoreVertical, zap: Zap,
|
||||
info: Info, 'octagon-alert': OctagonAlert, 'circle-check': CircleCheck,
|
||||
sparkles: Sparkles, inbox: Inbox,
|
||||
'layout-dashboard': LayoutDashboard, 'calendar-clock': CalendarClock, drama: Drama,
|
||||
'chevrons-up-down': ChevronsUpDown, 'server-cog': ServerCog, 'layout-grid': LayoutGrid,
|
||||
'square-dashed': SquareDashed, 'memory-stick': MemoryStick, 'corner-down-left': CornerDownLeft,
|
||||
ban: Ban, flag: Flag,
|
||||
'alert-circle': CircleAlert, 'arrow-down': ArrowDown, award: Award,
|
||||
'dollar-sign': DollarSign, 'flask-conical': FlaskConical, mail: Mail,
|
||||
package: Package, pencil: Pencil, save: Save, 'shopping-bag': ShoppingBag,
|
||||
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)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="cmp"
|
||||
v-if="cmp"
|
||||
class="cc-icon"
|
||||
:size="size"
|
||||
:width="size"
|
||||
:height="size"
|
||||
:stroke-width="strokeWidth"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-icon { display: inline-block; flex: none; vertical-align: middle; }
|
||||
</style>
|
||||
62
frontend/src/components/ds/core/IconButton.vue
Normal file
62
frontend/src/components/ds/core/IconButton.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* IconButton — square icon-only action button.
|
||||
* Variants: ghost | solid | accent | danger.
|
||||
* Sizes: sm | md | lg.
|
||||
* Native click bubbles via attribute fall-through (root is <button>).
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon: string
|
||||
variant?: 'ghost' | 'solid' | 'accent' | 'danger'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
active?: boolean
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ variant: 'ghost', size: 'md', active: false, disabled: false },
|
||||
)
|
||||
|
||||
const iconPx = computed(() => (props.size === 'lg' ? 19 : props.size === 'sm' ? 15 : 17))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
'cc-iconbtn',
|
||||
variant !== 'ghost' && `cc-iconbtn--${variant}`,
|
||||
size !== 'md' && `cc-iconbtn--${size}`,
|
||||
active && 'cc-iconbtn--active',
|
||||
]"
|
||||
>
|
||||
<Icon :name="icon" :size="iconPx" :stroke-width="2" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-iconbtn {
|
||||
display:inline-flex; align-items:center; justify-content:center; flex:none;
|
||||
width:var(--control-h-md); height:var(--control-h-md); border-radius:var(--radius-md);
|
||||
border:1px solid transparent; background:transparent; color:var(--text-secondary);
|
||||
cursor:pointer; transition:var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.cc-iconbtn:hover { background:var(--surface-hover); color:var(--text-primary); }
|
||||
.cc-iconbtn:active { transform: translateY(0.5px); }
|
||||
.cc-iconbtn:focus-visible { outline:none; box-shadow:var(--focus-ring); }
|
||||
.cc-iconbtn[disabled] { opacity:.4; pointer-events:none; }
|
||||
.cc-iconbtn--sm { width:var(--control-h-sm); height:var(--control-h-sm); border-radius:var(--radius-sm); }
|
||||
.cc-iconbtn--lg { width:var(--control-h-lg); height:var(--control-h-lg); }
|
||||
.cc-iconbtn--solid { background:var(--surface-raised-2); box-shadow:var(--ring-default); }
|
||||
.cc-iconbtn--solid:hover { background:var(--surface-active); }
|
||||
.cc-iconbtn--accent { background:var(--accent); color:var(--accent-contrast); }
|
||||
.cc-iconbtn--accent:hover { background:var(--accent-hover); }
|
||||
.cc-iconbtn--danger:hover { background:var(--status-offline-soft); color:var(--danger); }
|
||||
.cc-iconbtn--active { background:var(--accent-soft); color:var(--accent-text); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||
</style>
|
||||
20
frontend/src/components/ds/core/Kbd.vue
Normal file
20
frontend/src/components/ds/core/Kbd.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Kbd — keyboard shortcut key chip, rendered as <kbd>.
|
||||
* Uses mono font and inset border + bottom shadow to mimic a physical key.
|
||||
* No props — purely a presentational slot wrapper; native attrs fall through.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<kbd class="cc-kbd"><slot /></kbd>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-kbd {
|
||||
display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 5px;
|
||||
font-family:var(--font-mono); font-size:11px; font-weight:500; line-height:1; color:var(--text-secondary);
|
||||
background:var(--surface-raised-2); border-radius:var(--radius-sm);
|
||||
box-shadow: inset 0 0 0 1px var(--border-default), 0 1px 0 var(--border-default);
|
||||
}
|
||||
</style>
|
||||
48
frontend/src/components/ds/core/StatusDot.vue
Normal file
48
frontend/src/components/ds/core/StatusDot.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
/** StatusDot — small live-status dot; pulses when live. Tone maps to status tokens. */
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
tone?: 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping' | 'neutral' | 'accent'
|
||||
size?: number
|
||||
pulse?: boolean
|
||||
}>(),
|
||||
{ tone: 'online', size: 8, pulse: false },
|
||||
)
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
online: 'var(--status-online)',
|
||||
offline: 'var(--status-offline)',
|
||||
warn: 'var(--status-warn)',
|
||||
info: 'var(--status-info)',
|
||||
starting: 'var(--status-starting)',
|
||||
wiping: 'var(--status-wiping)',
|
||||
neutral: 'var(--text-muted)',
|
||||
accent: 'var(--accent)',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="cc-dot"
|
||||
:class="pulse && 'cc-dot--pulse'"
|
||||
:style="{
|
||||
width: size + 'px',
|
||||
height: size + 'px',
|
||||
background: TONE[tone] || TONE.neutral,
|
||||
boxShadow: pulse ? '0 0 8px -1px ' + (TONE[tone] || TONE.neutral) : 'none',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-dot { display: inline-block; flex: none; border-radius: 50%; position: relative; }
|
||||
.cc-dot--pulse::after {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 50%; background: inherit;
|
||||
animation: cc-dot-pulse 1.8s var(--ease-out) infinite;
|
||||
}
|
||||
@keyframes cc-dot-pulse {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
70%, 100% { transform: scale(2.6); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .cc-dot--pulse::after { animation: none; } }
|
||||
</style>
|
||||
52
frontend/src/components/ds/core/Tag.vue
Normal file
52
frontend/src/components/ds/core/Tag.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Tag — removable or static label chip.
|
||||
* Set `removable` to show the dismiss ×; emit `remove` is fired when clicked.
|
||||
* Optional `icon` prefix via Icon registry.
|
||||
*/
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon?: string
|
||||
removable?: boolean
|
||||
}>(),
|
||||
{ removable: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['cc-tag', !removable && 'cc-tag--static']">
|
||||
<Icon v-if="icon" :name="icon" :size="12" :stroke-width="2.25" />
|
||||
<span><slot /></span>
|
||||
<button
|
||||
v-if="removable"
|
||||
type="button"
|
||||
class="cc-tag__x"
|
||||
aria-label="Remove"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<Icon name="x" :size="11" :stroke-width="2.5" />
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-tag {
|
||||
display:inline-flex; align-items:center; gap:6px; height:24px; padding:0 4px 0 9px;
|
||||
font-family:var(--font-sans); font-size:var(--text-xs); font-weight:500; line-height:1;
|
||||
color:var(--text-secondary); background:var(--surface-raised-2);
|
||||
border-radius:var(--radius-sm); box-shadow:var(--ring-default);
|
||||
}
|
||||
.cc-tag--static { padding:0 9px; }
|
||||
.cc-tag__x {
|
||||
display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px;
|
||||
border-radius:var(--radius-xs); color:var(--text-tertiary); cursor:pointer; border:0; background:transparent;
|
||||
transition:var(--transition-colors);
|
||||
}
|
||||
.cc-tag__x:hover { background:var(--surface-active); color:var(--text-primary); }
|
||||
</style>
|
||||
72
frontend/src/components/ds/data/Avatar.vue
Normal file
72
frontend/src/components/ds/data/Avatar.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Avatar — player / operator avatar. Renders an image, or falls back to initials.
|
||||
* Optional status dot (online / offline / warn / idle) sits bottom-right.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name?: string
|
||||
src?: string
|
||||
size?: number
|
||||
shape?: 'rounded' | 'circle'
|
||||
status?: 'online' | 'offline' | 'warn' | 'idle'
|
||||
}>(),
|
||||
{ name: '', size: 32, shape: 'rounded' },
|
||||
)
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
online: 'var(--status-online)',
|
||||
offline: 'var(--status-offline)',
|
||||
warn: 'var(--status-warn)',
|
||||
idle: 'var(--text-muted)',
|
||||
}
|
||||
|
||||
const initials = computed(() =>
|
||||
props.name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map(w => w[0] ?? '')
|
||||
.join('')
|
||||
.toUpperCase() || '?',
|
||||
)
|
||||
|
||||
const dotSize = computed(() => Math.max(7, Math.round(props.size * 0.28)))
|
||||
const dotColor = computed(() => (props.status ? (TONE[props.status] ?? TONE.idle) : ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="cc-avatar"
|
||||
:class="shape === 'circle' && 'cc-avatar--circle'"
|
||||
:style="{
|
||||
width: size + 'px',
|
||||
height: size + 'px',
|
||||
fontSize: Math.round(size * 0.4) + 'px',
|
||||
}"
|
||||
>
|
||||
<img v-if="src" :src="src" :alt="name" />
|
||||
<template v-else>{{ initials }}</template>
|
||||
<span
|
||||
v-if="status"
|
||||
class="cc-avatar__status"
|
||||
:style="{ width: dotSize + 'px', height: dotSize + 'px', background: dotColor }"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-avatar {
|
||||
position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none;
|
||||
border-radius: var(--radius-md); background: var(--surface-active); color: var(--text-secondary);
|
||||
font-family: var(--font-mono); font-weight: 600; overflow: visible; box-shadow: var(--ring-default);
|
||||
}
|
||||
.cc-avatar--circle { border-radius: 50%; }
|
||||
.cc-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
||||
.cc-avatar__status {
|
||||
position: absolute; right: -2px; bottom: -2px; border-radius: 50%;
|
||||
box-shadow: 0 0 0 2px var(--surface-base);
|
||||
}
|
||||
</style>
|
||||
54
frontend/src/components/ds/data/ConsoleLine.vue
Normal file
54
frontend/src/components/ds/data/ConsoleLine.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ConsoleLine — one line in the RCON / server log stream.
|
||||
* Monospace, color-coded by level. Optional timestamp and actor (who).
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
time?: string
|
||||
level?: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
|
||||
who?: string
|
||||
}>(),
|
||||
{ level: 'info' },
|
||||
)
|
||||
|
||||
const LABEL: Record<string, string> = {
|
||||
cmd: 'cmd',
|
||||
chat: 'chat',
|
||||
info: 'info',
|
||||
warn: 'warn',
|
||||
error: 'err',
|
||||
connect: 'join',
|
||||
kill: 'kill',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['cc-line', 'cc-line--' + level]">
|
||||
<span v-if="time" class="cc-line__time">{{ time }}</span>
|
||||
<span class="cc-line__tag">{{ LABEL[level ?? 'info'] ?? level }}</span>
|
||||
<span class="cc-line__msg">
|
||||
<span v-if="who" class="cc-line__who">{{ who }} </span>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-line { display: flex; gap: 10px; padding: 2px 12px; font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.65; align-items: baseline; }
|
||||
.cc-line:hover { background: var(--surface-hover); }
|
||||
.cc-line__time { color: var(--text-muted); flex: none; font-variant-numeric: tabular-nums; }
|
||||
.cc-line__tag { flex: none; text-transform: uppercase; font-weight: 600; font-size: 10px; letter-spacing: .05em; padding: 0 5px; border-radius: var(--radius-xs); height: 15px; display: inline-flex; align-items: center; }
|
||||
.cc-line__msg { color: var(--text-secondary); white-space: pre-wrap; word-break: break-word; min-width: 0; }
|
||||
.cc-line__who { color: var(--accent-text); }
|
||||
.cc-line--cmd .cc-line__tag { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.cc-line--cmd .cc-line__msg { color: var(--text-primary); }
|
||||
.cc-line--chat .cc-line__tag { background: var(--surface-active); color: var(--text-secondary); }
|
||||
.cc-line--info .cc-line__tag { background: var(--status-info-soft); color: var(--status-info); }
|
||||
.cc-line--warn .cc-line__tag { background: var(--status-warn-soft); color: var(--status-warn); }
|
||||
.cc-line--warn .cc-line__msg { color: var(--status-warn); }
|
||||
.cc-line--error .cc-line__tag { background: var(--status-offline-soft); color: var(--status-offline); }
|
||||
.cc-line--error .cc-line__msg { color: var(--status-offline); }
|
||||
.cc-line--connect .cc-line__tag { background: var(--status-online-soft); color: var(--status-online); }
|
||||
.cc-line--kill .cc-line__tag { background: var(--status-wiping-soft); color: var(--status-wiping); }
|
||||
</style>
|
||||
57
frontend/src/components/ds/data/Panel.vue
Normal file
57
frontend/src/components/ds/data/Panel.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Panel — standard section container.
|
||||
* Header: optional eyebrow / title / subtitle / right-aligned actions.
|
||||
* Body: padding removed when flushBody=true (tables / lists manage their own).
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
eyebrow?: string
|
||||
variant?: 'base' | 'raised' | 'flush'
|
||||
flushBody?: boolean
|
||||
}>(),
|
||||
{ variant: 'base', flushBody: false },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="[
|
||||
'cc-panel',
|
||||
variant === 'raised' && 'cc-panel--raised',
|
||||
variant === 'flush' && 'cc-panel--flush',
|
||||
]"
|
||||
>
|
||||
<header v-if="title || subtitle || eyebrow || $slots.actions" class="cc-panel__head">
|
||||
<div class="cc-panel__titles">
|
||||
<div v-if="eyebrow" class="t-eyebrow">{{ eyebrow }}</div>
|
||||
<div v-if="title" class="cc-panel__title">
|
||||
{{ title }}
|
||||
<slot name="title-append" />
|
||||
</div>
|
||||
<div v-if="subtitle" class="cc-panel__sub">{{ subtitle }}</div>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="cc-panel__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
<div :class="['cc-panel__body', flushBody && 'cc-panel__body--flush']">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-panel { background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); display: flex; flex-direction: column; min-width: 0; }
|
||||
.cc-panel--raised { background: var(--surface-raised); }
|
||||
.cc-panel--flush { box-shadow: none; background: transparent; }
|
||||
.cc-panel__head { display: flex; align-items: center; gap: 12px; padding: 13px 16px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.cc-panel__titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.cc-panel__title { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 8px; }
|
||||
.cc-panel__sub { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.cc-panel__actions { display: flex; align-items: center; gap: 6px; flex: none; }
|
||||
.cc-panel__body { padding: 16px; min-width: 0; }
|
||||
.cc-panel__body--flush { padding: 0; }
|
||||
</style>
|
||||
129
frontend/src/components/ds/data/PlayersChart.vue
Normal file
129
frontend/src/components/ds/data/PlayersChart.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* PlayersChart — themed ECharts area chart of players online.
|
||||
*
|
||||
* 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 { computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ height?: number; data?: number[]; max?: number }>(),
|
||||
{ height: 200, max: 200 },
|
||||
)
|
||||
|
||||
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0)
|
||||
|
||||
const el = useTemplateRef<HTMLDivElement>('el')
|
||||
let chart: echarts.ECharts | null = null
|
||||
let ro: ResizeObserver | null = null
|
||||
let mo: MutationObserver | null = null
|
||||
|
||||
function cssVar(name: string, node?: HTMLElement): string {
|
||||
return getComputedStyle(node || document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
if (!chart || !el.value || !hasData.value) return
|
||||
const node = el.value
|
||||
const accent = cssVar('--accent', node) || '#f26622'
|
||||
const grid = cssVar('--border-subtle', node) || 'rgba(255,255,255,0.06)'
|
||||
const text = cssVar('--text-tertiary', node) || '#767d89'
|
||||
const mono = 'JetBrains Mono, monospace'
|
||||
const hours = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
|
||||
const series = props.data as number[]
|
||||
|
||||
chart.setOption({
|
||||
animationDuration: 700,
|
||||
grid: { left: 8, right: 12, top: 14, bottom: 22, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: cssVar('--surface-overlay', node) || '#1f2329',
|
||||
borderColor: cssVar('--border-default', node) || 'rgba(255,255,255,0.1)',
|
||||
borderWidth: 1,
|
||||
textStyle: { color: cssVar('--text-primary', node) || '#fff', fontFamily: mono, fontSize: 11 },
|
||||
axisPointer: { type: 'line', lineStyle: { color: accent, opacity: 0.5 } },
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category', data: hours, boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: grid } }, axisTick: { show: false },
|
||||
axisLabel: { color: text, fontFamily: mono, fontSize: 10, interval: 3 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', max: props.max,
|
||||
splitLine: { lineStyle: { color: grid } },
|
||||
axisLabel: { color: text, fontFamily: mono, fontSize: 10 },
|
||||
},
|
||||
series: [{
|
||||
type: 'line', smooth: 0.4, symbol: 'none', data: series,
|
||||
lineStyle: { color: accent, width: 2 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: accent + '55' },
|
||||
{ offset: 1, color: accent + '00' },
|
||||
]),
|
||||
},
|
||||
markLine: {
|
||||
silent: true, symbol: 'none',
|
||||
lineStyle: { color: text, type: 'dashed', opacity: 0.5 },
|
||||
data: [{ yAxis: props.max, label: { formatter: `cap ${props.max}`, color: text, fontFamily: mono, fontSize: 9 } }],
|
||||
},
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value) return
|
||||
if (!hasData.value) return // empty-state slot renders instead
|
||||
chart = echarts.init(el.value, undefined, { renderer: 'canvas' })
|
||||
render()
|
||||
ro = new ResizeObserver(() => chart?.resize())
|
||||
ro.observe(el.value)
|
||||
mo = new MutationObserver(render)
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-game', 'data-theme'] })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect()
|
||||
mo?.disconnect()
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
53
frontend/src/components/ds/data/ResourceMeter.vue
Normal file
53
frontend/src/components/ds/data/ResourceMeter.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ResourceMeter — labeled utilization bar (CPU, RAM, disk, network).
|
||||
* tone="auto" colors by threshold: green <70%, amber <90%, red ≥90%.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
icon?: string
|
||||
value?: number
|
||||
sub?: string
|
||||
tone?: 'auto' | 'ok' | 'warn' | 'danger' | 'accent'
|
||||
}>(),
|
||||
{ value: 0, tone: 'auto' },
|
||||
)
|
||||
|
||||
const pct = computed(() => Math.max(0, Math.min(100, props.value)))
|
||||
|
||||
const resolvedTone = computed(() => {
|
||||
if (props.tone !== 'auto') return props.tone
|
||||
return pct.value >= 90 ? 'danger' : pct.value >= 70 ? 'warn' : 'ok'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['cc-meter', 'cc-meter--' + resolvedTone]">
|
||||
<div class="cc-meter__top">
|
||||
<span class="cc-meter__label">{{ label }}</span>
|
||||
<span class="cc-meter__val">
|
||||
{{ pct }}%<span v-if="sub" class="cc-meter__sub">{{ sub }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="cc-meter__track">
|
||||
<div class="cc-meter__fill" :style="{ width: pct + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-meter { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.cc-meter__top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.cc-meter__label { font-size: var(--text-xs); color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
|
||||
.cc-meter__val { font-family: var(--font-mono); font-size: var(--text-xs); font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||
.cc-meter__sub { color: var(--text-muted); font-weight: 400; margin-left: 4px; }
|
||||
.cc-meter__track { height: 6px; border-radius: var(--radius-pill); background: var(--surface-active); overflow: hidden; }
|
||||
.cc-meter__fill { height: 100%; border-radius: var(--radius-pill); transition: width var(--dur-slow) var(--ease-out), background var(--dur-base); }
|
||||
.cc-meter--accent .cc-meter__fill { background: var(--accent); }
|
||||
.cc-meter--ok .cc-meter__fill { background: var(--status-online); }
|
||||
.cc-meter--warn .cc-meter__fill { background: var(--status-warn); }
|
||||
.cc-meter--danger .cc-meter__fill { background: var(--status-offline); }
|
||||
</style>
|
||||
166
frontend/src/components/ds/data/ServerCard.vue
Normal file
166
frontend/src/components/ds/data/ServerCard.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ServerCard — server instance summary card.
|
||||
* Sets :data-game so per-game accent token re-skins apply via the global [data-game] selector.
|
||||
* Status drives the dot + left rail color.
|
||||
* `offline` dims the card and swaps the power IconButton to a Start action.
|
||||
* Pending state shows when status==='online' && cpu==null && ram==null.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import ResourceMeter from './ResourceMeter.vue'
|
||||
|
||||
export interface StatItem {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
game?: string
|
||||
gameIcon?: string
|
||||
name: string
|
||||
region?: string
|
||||
map?: string
|
||||
version?: string
|
||||
status?: 'online' | 'offline' | 'starting' | 'wiping' | 'updating'
|
||||
players?: { cur: number; max: number }
|
||||
cpu?: number
|
||||
ram?: number
|
||||
ramSub?: string
|
||||
ip?: string
|
||||
stats?: StatItem[]
|
||||
}>(),
|
||||
{
|
||||
game: 'rust',
|
||||
gameIcon: 'box',
|
||||
status: 'online',
|
||||
players: () => ({ cur: 0, max: 0 }),
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
console: []
|
||||
settings: []
|
||||
power: []
|
||||
}>()
|
||||
|
||||
interface StatusEntry {
|
||||
tone: 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping' | 'neutral' | 'accent'
|
||||
label: string
|
||||
pulse: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS: StatusEntry = { tone: 'online', label: 'Online', pulse: true }
|
||||
|
||||
const STATUS_MAP: Record<string, StatusEntry> = {
|
||||
online: { tone: 'online', label: 'Online', pulse: true },
|
||||
offline: { tone: 'offline', label: 'Offline', pulse: false },
|
||||
starting: { tone: 'starting', label: 'Booting', pulse: true },
|
||||
wiping: { tone: 'wiping', label: 'Wiping', pulse: true },
|
||||
updating: { tone: 'starting', label: 'Updating', pulse: true },
|
||||
}
|
||||
|
||||
const st = computed<StatusEntry>(() => STATUS_MAP[props.status ?? 'online'] ?? DEFAULT_STATUS)
|
||||
const offline = computed(() => props.status === 'offline')
|
||||
|
||||
const statList = computed<StatItem[]>(() => {
|
||||
if (props.stats) return props.stats
|
||||
const items: StatItem[] = [
|
||||
{ label: 'Players', value: `${props.players?.cur ?? 0} / ${props.players?.max ?? 0}` },
|
||||
]
|
||||
if (props.version) {
|
||||
items.push({ label: 'Build', value: props.version })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const pending = computed(
|
||||
() => props.status === 'online' && props.cpu == null && props.ram == null,
|
||||
)
|
||||
|
||||
const showMeters = computed(
|
||||
() => !offline.value && (props.cpu != null || props.ram != null),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:data-game="game"
|
||||
:class="['cc-server', offline && 'cc-server--offline']"
|
||||
>
|
||||
<!-- Head -->
|
||||
<div class="cc-server__head">
|
||||
<div class="cc-server__game">
|
||||
<Icon :name="gameIcon" :size="18" :stroke-width="2" />
|
||||
</div>
|
||||
<div class="cc-server__id">
|
||||
<div class="cc-server__name">
|
||||
{{ name }}
|
||||
<Badge :tone="st.tone" dot :pulse="st.pulse">{{ st.label }}</Badge>
|
||||
</div>
|
||||
<div class="cc-server__meta">
|
||||
<span v-if="region">{{ region }}</span>
|
||||
<span v-if="map">{{ map }}</span>
|
||||
<span v-if="ip">{{ ip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-server__actions">
|
||||
<IconButton icon="terminal" variant="ghost" size="sm" label="Console" @click="emit('console')" />
|
||||
<IconButton icon="settings" variant="ghost" size="sm" label="Settings" @click="emit('settings')" />
|
||||
<IconButton
|
||||
:icon="offline ? 'play' : 'power'"
|
||||
:variant="offline ? 'accent' : 'ghost'"
|
||||
size="sm"
|
||||
:label="offline ? 'Start' : 'Power'"
|
||||
@click="emit('power')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="cc-server__body">
|
||||
<div class="cc-server__stats">
|
||||
<div v-for="(s, i) in statList" :key="i" class="cc-server__stat">
|
||||
<b>{{ s.value }}</b>
|
||||
<span>{{ s.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showMeters" class="cc-server__meters">
|
||||
<ResourceMeter v-if="cpu != null" label="CPU" :value="cpu" />
|
||||
<ResourceMeter v-if="ram != null" label="RAM" :value="ram" :sub="ramSub" />
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="cc-server__pending">
|
||||
<Icon name="loader" :size="13" :stroke-width="2.5" />
|
||||
Telemetry pending · agent monitoring
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-server { position: relative; background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); overflow: hidden; transition: var(--transition-colors); }
|
||||
.cc-server::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); opacity: .9; }
|
||||
.cc-server:hover { box-shadow: inset 0 0 0 1px var(--border-strong); }
|
||||
.cc-server--offline::before { background: var(--status-offline); }
|
||||
.cc-server--offline { opacity: .82; }
|
||||
.cc-server__head { display: flex; align-items: center; gap: 12px; padding: 14px 14px 12px 17px; }
|
||||
.cc-server__game { width: 34px; height: 34px; border-radius: var(--radius-md); flex: none; display: flex; align-items: center; justify-content: center; color: var(--accent); background: var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||
.cc-server__id { flex: 1; min-width: 0; }
|
||||
.cc-server__name { font-size: var(--text-base); font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 8px; }
|
||||
.cc-server__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; display: flex; gap: 10px; }
|
||||
.cc-server__body { padding: 0 14px 13px 17px; display: flex; flex-direction: column; gap: 11px; }
|
||||
.cc-server__stats { display: flex; gap: 18px; }
|
||||
.cc-server__stat { display: flex; flex-direction: column; gap: 1px; }
|
||||
.cc-server__stat b { font-family: var(--font-mono); font-weight: 600; font-size: var(--text-sm); color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||
.cc-server__stat span { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.cc-server__meters { display: flex; gap: 14px; }
|
||||
.cc-server__meters > * { flex: 1; }
|
||||
.cc-server__pending { display: flex; align-items: center; gap: 7px; font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
|
||||
.cc-server__pending .cc-icon { color: var(--status-starting); }
|
||||
.cc-server__actions { display: flex; gap: 5px; }
|
||||
</style>
|
||||
62
frontend/src/components/ds/data/StatCard.vue
Normal file
62
frontend/src/components/ds/data/StatCard.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StatCard — KPI tile with icon, big mono value, and optional delta + note row.
|
||||
* Green delta = up/good, red = down/bad, muted = flat.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
value: string | number
|
||||
unit?: string
|
||||
icon?: string
|
||||
delta?: string | number
|
||||
deltaDir?: 'up' | 'down' | 'flat'
|
||||
note?: string
|
||||
}>(),
|
||||
{ deltaDir: 'up' },
|
||||
)
|
||||
|
||||
const deltaIcon = computed(() =>
|
||||
props.deltaDir === 'up' ? 'trending-up' : props.deltaDir === 'down' ? 'trending-down' : 'minus',
|
||||
)
|
||||
|
||||
const showFoot = computed(() => props.delta != null || !!props.note)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cc-stat">
|
||||
<div class="cc-stat__top">
|
||||
<div v-if="icon" class="cc-stat__ico">
|
||||
<Icon :name="icon" :size="15" :stroke-width="2.25" />
|
||||
</div>
|
||||
<div class="cc-stat__label">{{ label }}</div>
|
||||
</div>
|
||||
<div class="cc-stat__value">
|
||||
{{ value }}<span v-if="unit" class="cc-stat__unit">{{ unit }}</span>
|
||||
</div>
|
||||
<div v-if="showFoot" class="cc-stat__foot">
|
||||
<span v-if="delta != null" :class="['cc-stat__delta', 'cc-stat__delta--' + deltaDir]">
|
||||
<Icon :name="deltaIcon" :size="13" :stroke-width="2.5" />{{ delta }}
|
||||
</span>
|
||||
<span v-if="note" class="cc-stat__note">{{ note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-stat { background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; min-width: 0; position: relative; overflow: hidden; }
|
||||
.cc-stat__top { display: flex; align-items: center; gap: 8px; }
|
||||
.cc-stat__ico { width: 28px; height: 28px; border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; background: var(--accent-soft); color: var(--accent-text); flex: none; }
|
||||
.cc-stat__label { font-size: var(--text-xs); font-weight: 500; color: var(--text-tertiary); letter-spacing: .01em; }
|
||||
.cc-stat__value { font-family: var(--font-mono); font-weight: 600; font-size: 28px; letter-spacing: -0.02em; color: var(--text-primary); font-variant-numeric: tabular-nums; line-height: 1; display: flex; align-items: baseline; gap: 4px; }
|
||||
.cc-stat__unit { font-size: 14px; color: var(--text-muted); font-weight: 500; }
|
||||
.cc-stat__foot { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
.cc-stat__delta { display: inline-flex; align-items: center; gap: 3px; font-weight: 600; }
|
||||
.cc-stat__delta--up { color: var(--status-online); }
|
||||
.cc-stat__delta--down { color: var(--status-offline); }
|
||||
.cc-stat__delta--flat { color: var(--text-tertiary); }
|
||||
.cc-stat__note { color: var(--text-tertiary); }
|
||||
</style>
|
||||
86
frontend/src/components/ds/feedback/Alert.vue
Normal file
86
frontend/src/components/ds/feedback/Alert.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Alert — contextual inline alert strip.
|
||||
* Tones: info | warn | danger | online | accent | neutral.
|
||||
* Pass `title` for a bold heading, default slot for body text, `actions` slot
|
||||
* for inline action buttons. Set `dismissible` to show an × ghost button that
|
||||
* emits `dismiss`.
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
type Tone = 'info' | 'warn' | 'danger' | 'online' | 'accent' | 'neutral'
|
||||
|
||||
const ICONS: Record<Tone, string> = {
|
||||
info: 'info',
|
||||
warn: 'triangle-alert',
|
||||
danger: 'octagon-alert',
|
||||
online: 'circle-check',
|
||||
accent: 'sparkles',
|
||||
neutral: 'info',
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tone?: Tone
|
||||
title?: string
|
||||
dismissible?: boolean
|
||||
icon?: string
|
||||
}>(),
|
||||
{ tone: 'info', dismissible: false },
|
||||
)
|
||||
|
||||
defineEmits<{ dismiss: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="['cc-alert', 'cc-alert--' + tone]"
|
||||
role="status"
|
||||
>
|
||||
<span class="cc-alert__icon">
|
||||
<Icon :name="icon ?? ICONS[tone]" :size="17" :stroke-width="2" />
|
||||
</span>
|
||||
<div class="cc-alert__main">
|
||||
<div v-if="title" class="cc-alert__title">{{ title }}</div>
|
||||
<div v-if="$slots.default" class="cc-alert__body"><slot /></div>
|
||||
<div v-if="$slots.actions" class="cc-alert__actions"><slot name="actions" /></div>
|
||||
</div>
|
||||
<button
|
||||
v-if="dismissible"
|
||||
class="cc-alert__dismiss"
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
@click="$emit('dismiss')"
|
||||
>
|
||||
<Icon name="x" :size="15" :stroke-width="2.25" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-alert { display:flex; gap:11px; padding:12px 13px; border-radius:var(--radius-md); background:var(--surface-raised); box-shadow:var(--ring-default); }
|
||||
.cc-alert__icon { flex:none; margin-top:1px; }
|
||||
.cc-alert__main { flex:1; min-width:0; display:flex; flex-direction:column; gap:3px; }
|
||||
.cc-alert__title { font-size:var(--text-sm); font-weight:600; color:var(--text-primary); }
|
||||
.cc-alert__body { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.5; }
|
||||
.cc-alert__actions { display:flex; gap:8px; margin-top:8px; }
|
||||
.cc-alert--info { background:var(--status-info-soft); box-shadow: inset 0 0 0 1px var(--status-info-border); }
|
||||
.cc-alert--info .cc-alert__icon { color:var(--status-info); }
|
||||
.cc-alert--warn { background:var(--status-warn-soft); box-shadow: inset 0 0 0 1px var(--status-warn-border); }
|
||||
.cc-alert--warn .cc-alert__icon { color:var(--status-warn); }
|
||||
.cc-alert--danger { background:var(--status-offline-soft); box-shadow: inset 0 0 0 1px var(--status-offline-border); }
|
||||
.cc-alert--danger .cc-alert__icon { color:var(--status-offline); }
|
||||
.cc-alert--online { background:var(--status-online-soft); box-shadow: inset 0 0 0 1px var(--status-online-border); }
|
||||
.cc-alert--online .cc-alert__icon { color:var(--status-online); }
|
||||
.cc-alert--accent { background:var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||
.cc-alert--accent .cc-alert__icon { color:var(--accent-text); }
|
||||
.cc-alert__dismiss {
|
||||
flex: none; display:inline-flex; align-items:center; justify-content:center;
|
||||
width:26px; height:26px; border-radius:var(--radius-sm); border:none; cursor:pointer;
|
||||
background:transparent; color:var(--text-secondary);
|
||||
transition: var(--transition-colors);
|
||||
margin-top:-3px; margin-right:-3px;
|
||||
}
|
||||
.cc-alert__dismiss:hover { background:var(--surface-hover); color:var(--text-primary); }
|
||||
.cc-alert__dismiss:focus-visible { outline:none; box-shadow:var(--focus-ring); }
|
||||
</style>
|
||||
39
frontend/src/components/ds/feedback/EmptyState.vue
Normal file
39
frontend/src/components/ds/feedback/EmptyState.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* EmptyState — zero-data placeholder with icon, title, description, and an
|
||||
* optional action slot (pass a Button or link).
|
||||
*
|
||||
* Icon registry note: default icon 'inbox' is not in the registry — it will
|
||||
* silently not render per Icon.vue's null guard. Callers should pass a
|
||||
* registered icon name (e.g. icon="server", icon="folder-open").
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon?: string
|
||||
title?: string
|
||||
description?: string
|
||||
}>(),
|
||||
{ icon: 'inbox' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cc-empty">
|
||||
<div class="cc-empty__icon">
|
||||
<Icon :name="icon" :size="22" :stroke-width="1.75" />
|
||||
</div>
|
||||
<div v-if="title" class="cc-empty__title">{{ title }}</div>
|
||||
<div v-if="description" class="cc-empty__desc">{{ description }}</div>
|
||||
<div v-if="$slots.action" class="cc-empty__action"><slot name="action" /></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-empty { display:flex; flex-direction:column; align-items:center; text-align:center; gap:5px; padding:36px 24px; }
|
||||
.cc-empty__icon { width:46px; height:46px; border-radius:var(--radius-lg); display:flex; align-items:center; justify-content:center; margin-bottom:8px; color:var(--text-tertiary); background:var(--surface-raised-2); box-shadow:var(--ring-default); }
|
||||
.cc-empty__title { font-size:var(--text-base); font-weight:600; color:var(--text-primary); }
|
||||
.cc-empty__desc { font-size:var(--text-sm); color:var(--text-tertiary); max-width:340px; line-height:1.5; }
|
||||
.cc-empty__action { margin-top:12px; }
|
||||
</style>
|
||||
50
frontend/src/components/ds/forms/Checkbox.vue
Normal file
50
frontend/src/components/ds/forms/Checkbox.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Checkbox — square toggle; checked/indeterminate state carries the live game accent.
|
||||
* v-model binds to boolean checked state via defineModel<boolean>().
|
||||
* The hidden <input type="checkbox"> drives CSS :checked/:indeterminate/:focus-visible/:disabled.
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}>(),
|
||||
{ disabled: false },
|
||||
)
|
||||
|
||||
const model = defineModel<boolean>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="cc-check" :for="id">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="id"
|
||||
:checked="model"
|
||||
:disabled="disabled"
|
||||
@change="model = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<span class="cc-check__box">
|
||||
<Icon name="check" :size="12" :stroke-width="3" />
|
||||
</span>
|
||||
<span v-if="label" class="cc-check__label">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-check { display:inline-flex; align-items:center; gap:9px; cursor:pointer; user-select:none; }
|
||||
.cc-check input { position:absolute; opacity:0; width:0; height:0; }
|
||||
.cc-check__box {
|
||||
width:17px; height:17px; flex:none; border-radius:var(--radius-xs); background:var(--surface-inset);
|
||||
box-shadow: inset 0 0 0 1px var(--border-strong); display:flex; align-items:center; justify-content:center;
|
||||
color:transparent; transition:var(--transition-colors);
|
||||
}
|
||||
.cc-check input:checked + .cc-check__box { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color:var(--accent-contrast); }
|
||||
.cc-check input:indeterminate + .cc-check__box { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color:var(--accent-contrast); }
|
||||
.cc-check input:focus-visible + .cc-check__box { box-shadow: var(--focus-ring); }
|
||||
.cc-check input:disabled + .cc-check__box { opacity:.5; }
|
||||
.cc-check__label { font-size:var(--text-sm); color:var(--text-primary); }
|
||||
</style>
|
||||
90
frontend/src/components/ds/forms/Input.vue
Normal file
90
frontend/src/components/ds/forms/Input.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Input — text field with label, hint/error, leading icon and affixes.
|
||||
* Reach for `mono` on any technical value (ports, tokens, IDs).
|
||||
* v-model binds to the inner <input> value via defineModel<string>().
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
icon?: string
|
||||
prefix?: string
|
||||
suffix?: string
|
||||
size?: 'md' | 'sm'
|
||||
mono?: boolean
|
||||
required?: boolean
|
||||
id?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
type?: string
|
||||
}>(),
|
||||
{ size: 'md', mono: false, required: false, disabled: false, type: 'text' },
|
||||
)
|
||||
|
||||
const model = defineModel<string>()
|
||||
|
||||
const invalid = () => !!props.error
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
class="cc-field"
|
||||
:for="id"
|
||||
>
|
||||
<span v-if="label" class="cc-field__label">
|
||||
{{ label }}<span v-if="required" class="req">*</span>
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'cc-input',
|
||||
size === 'sm' && 'cc-input--sm',
|
||||
mono && 'cc-input--mono',
|
||||
invalid() && 'cc-input--invalid',
|
||||
]"
|
||||
>
|
||||
<Icon v-if="icon" :name="icon" :size="15" />
|
||||
<span v-if="prefix" class="cc-input__affix">{{ prefix }}</span>
|
||||
<input
|
||||
:id="id"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="model"
|
||||
@input="model = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<span v-if="suffix" class="cc-input__affix">{{ suffix }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="hint || error"
|
||||
:class="['cc-field__hint', invalid() && 'cc-field__hint--error']"
|
||||
>{{ error ?? hint }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-field { display:flex; flex-direction:column; gap:6px; }
|
||||
.cc-field__label { font-size:var(--text-xs); font-weight:600; color:var(--text-secondary); }
|
||||
.cc-field__label .req { color:var(--accent-text); margin-left:2px; }
|
||||
.cc-input {
|
||||
display:flex; align-items:center; gap:8px; height:var(--control-h-md); padding:0 11px;
|
||||
background:var(--surface-inset); border-radius:var(--radius-md); box-shadow:var(--ring-default);
|
||||
transition:var(--transition-colors); color:var(--text-tertiary);
|
||||
}
|
||||
.cc-input:focus-within { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
.cc-input--sm { height:var(--control-h-sm); padding:0 9px; }
|
||||
.cc-input--invalid { box-shadow: inset 0 0 0 1px var(--status-offline-border); }
|
||||
.cc-input--invalid:focus-within { box-shadow: inset 0 0 0 1px var(--danger); }
|
||||
.cc-input input {
|
||||
flex:1; min-width:0; background:transparent; border:0; outline:0; padding:0; margin:0;
|
||||
font-family:var(--font-sans); font-size:var(--text-sm); color:var(--text-primary);
|
||||
}
|
||||
.cc-input input::placeholder { color:var(--text-muted); }
|
||||
.cc-input--mono input { font-family:var(--font-mono); }
|
||||
.cc-input__affix { font-family:var(--font-mono); font-size:var(--text-xs); color:var(--text-muted); white-space:nowrap; }
|
||||
.cc-field__hint { font-size:var(--text-xs); color:var(--text-tertiary); }
|
||||
.cc-field__hint--error { color:var(--danger); }
|
||||
</style>
|
||||
86
frontend/src/components/ds/forms/Select.vue
Normal file
86
frontend/src/components/ds/forms/Select.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Select — styled native <select> with chevron overlay.
|
||||
* With `label` the root becomes a <label> wrapping the control.
|
||||
* v-model binds to the selected value via defineModel<string>().
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
type SelectOption = string | { value: string; label: string }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
options?: SelectOption[]
|
||||
size?: 'md' | 'sm'
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ options: () => [], size: 'md', disabled: false },
|
||||
)
|
||||
|
||||
const model = defineModel<string>()
|
||||
|
||||
function optionValue(o: SelectOption): string {
|
||||
return typeof o === 'string' ? o : o.value
|
||||
}
|
||||
function optionLabel(o: SelectOption): string {
|
||||
return typeof o === 'string' ? o : o.label
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- With label: wrap in <label> matching React's cc-field layout -->
|
||||
<label v-if="label" class="cc-field" :for="id">
|
||||
<span class="cc-field__label">{{ label }}</span>
|
||||
<span :class="['cc-select', size === 'sm' && 'cc-select--sm']">
|
||||
<select
|
||||
:id="id"
|
||||
:disabled="disabled"
|
||||
:value="model"
|
||||
@change="model = ($event.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option
|
||||
v-for="(o, i) in options"
|
||||
:key="i"
|
||||
:value="optionValue(o)"
|
||||
>{{ optionLabel(o) }}</option>
|
||||
</select>
|
||||
<span class="cc-select__chev">
|
||||
<Icon name="chevron-down" :size="15" />
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Without label: bare control -->
|
||||
<span v-else :class="['cc-select', size === 'sm' && 'cc-select--sm']">
|
||||
<select
|
||||
:id="id"
|
||||
:disabled="disabled"
|
||||
:value="model"
|
||||
@change="model = ($event.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option
|
||||
v-for="(o, i) in options"
|
||||
:key="i"
|
||||
:value="optionValue(o)"
|
||||
>{{ optionLabel(o) }}</option>
|
||||
</select>
|
||||
<span class="cc-select__chev">
|
||||
<Icon name="chevron-down" :size="15" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-select { position:relative; display:flex; align-items:center; }
|
||||
.cc-select select {
|
||||
appearance:none; width:100%; height:var(--control-h-md); padding:0 32px 0 11px;
|
||||
background:var(--surface-inset); color:var(--text-primary); border:0; border-radius:var(--radius-md);
|
||||
box-shadow:var(--ring-default); font-family:var(--font-sans); font-size:var(--text-sm); cursor:pointer;
|
||||
transition:var(--transition-colors); outline:0;
|
||||
}
|
||||
.cc-select select:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
.cc-select--sm select { height:var(--control-h-sm); padding:0 28px 0 9px; }
|
||||
.cc-select__chev { position:absolute; right:9px; pointer-events:none; color:var(--text-tertiary); display:flex; }
|
||||
</style>
|
||||
59
frontend/src/components/ds/forms/Switch.vue
Normal file
59
frontend/src/components/ds/forms/Switch.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Switch — toggle control; checked state carries the live game accent.
|
||||
* v-model binds to boolean checked state via defineModel<boolean>().
|
||||
* The hidden <input type="checkbox"> drives CSS :checked/:focus-visible/:disabled.
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
size?: 'md' | 'sm'
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}>(),
|
||||
{ size: 'md', disabled: false },
|
||||
)
|
||||
|
||||
const model = defineModel<boolean>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
:class="['cc-switch', size === 'sm' && 'cc-switch--sm']"
|
||||
:for="id"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="id"
|
||||
:checked="model"
|
||||
:disabled="disabled"
|
||||
@change="model = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<span class="cc-switch__track">
|
||||
<span class="cc-switch__thumb" />
|
||||
</span>
|
||||
<span v-if="label" class="cc-switch__label">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-switch { display:inline-flex; align-items:center; gap:10px; cursor:pointer; user-select:none; }
|
||||
.cc-switch input { position:absolute; opacity:0; width:0; height:0; }
|
||||
.cc-switch__track {
|
||||
position:relative; width:36px; height:20px; border-radius:var(--radius-pill); flex:none;
|
||||
background:var(--surface-active); box-shadow: inset 0 0 0 1px var(--border-default);
|
||||
transition: background var(--dur-base) var(--ease-standard), box-shadow var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
.cc-switch__thumb {
|
||||
position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%;
|
||||
background:var(--text-secondary); transition: transform var(--dur-base) var(--ease-emphasized), background var(--dur-base);
|
||||
}
|
||||
.cc-switch input:checked + .cc-switch__track { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
.cc-switch input:checked + .cc-switch__track .cc-switch__thumb { transform:translateX(16px); background:var(--accent-contrast); }
|
||||
.cc-switch input:focus-visible + .cc-switch__track { box-shadow: var(--focus-ring); }
|
||||
.cc-switch input:disabled + .cc-switch__track { opacity:.5; }
|
||||
.cc-switch__label { font-size:var(--text-sm); color:var(--text-primary); }
|
||||
.cc-switch--sm .cc-switch__track { width:30px; height:17px; }
|
||||
.cc-switch--sm .cc-switch__thumb { width:13px; height:13px; }
|
||||
.cc-switch--sm input:checked + .cc-switch__track .cc-switch__thumb { transform:translateX(13px); }
|
||||
</style>
|
||||
71
frontend/src/components/ds/navigation/GameSwitcher.vue
Normal file
71
frontend/src/components/ds/navigation/GameSwitcher.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* GameSwitcher — segmented control for switching the active game context.
|
||||
* Set `data-game` on a root shell element to the chosen key so the global
|
||||
* [data-game] CSS custom properties re-skin the entire panel.
|
||||
* Per-game accent comes from var(--accent) which is resolved by the [data-game] token scope.
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
export interface GameOption {
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
games: (string | GameOption)[]
|
||||
showLabels?: boolean
|
||||
class?: string
|
||||
}>(),
|
||||
{ showLabels: true },
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
function normalise(g: string | GameOption): GameOption {
|
||||
return typeof g === 'string' ? { key: g, label: g } : g
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['cc-gameswitch', props.class]" role="group">
|
||||
<button
|
||||
v-for="raw in games"
|
||||
:key="normalise(raw).key"
|
||||
type="button"
|
||||
:aria-pressed="normalise(raw).key === model"
|
||||
:data-game="normalise(raw).key"
|
||||
class="cc-gameswitch__opt"
|
||||
:title="normalise(raw).label"
|
||||
@click="model = normalise(raw).key"
|
||||
>
|
||||
<Icon
|
||||
v-if="normalise(raw).icon"
|
||||
:name="normalise(raw).icon ?? ''"
|
||||
:size="14"
|
||||
:stroke-width="2.25"
|
||||
:style="{ color: normalise(raw).key === model ? 'var(--accent)' : 'inherit' }"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="cc-gameswitch__dot"
|
||||
:style="{ background: normalise(raw).key === model ? 'var(--accent)' : 'var(--text-muted)' }"
|
||||
/>
|
||||
<span v-if="showLabels">{{ normalise(raw).label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-gameswitch { display:inline-flex; align-items:center; gap:3px; padding:3px; background:var(--surface-inset); border-radius:var(--radius-md); box-shadow:var(--ring-default); }
|
||||
.cc-gameswitch__opt {
|
||||
display:inline-flex; align-items:center; gap:7px; height:28px; padding:0 11px; border:0; background:transparent;
|
||||
font-family:var(--font-sans); font-size:var(--text-xs); font-weight:600; color:var(--text-tertiary);
|
||||
border-radius:var(--radius-sm); cursor:pointer; transition:var(--transition-colors); white-space:nowrap;
|
||||
}
|
||||
.cc-gameswitch__opt:hover { color:var(--text-primary); }
|
||||
.cc-gameswitch__dot { width:8px; height:8px; border-radius:50%; background:var(--accent); flex:none; }
|
||||
.cc-gameswitch__opt[aria-pressed="true"] { background:var(--surface-raised-2); color:var(--text-primary); box-shadow:var(--ring-default); }
|
||||
</style>
|
||||
50
frontend/src/components/ds/navigation/NavItem.vue
Normal file
50
frontend/src/components/ds/navigation/NavItem.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* NavItem — sidebar navigation row: icon + label, active state with accent rail,
|
||||
* optional trailing count. Collapsed mode renders icon-only at 40 px wide.
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon: string
|
||||
label: string
|
||||
active?: boolean
|
||||
count?: number | string
|
||||
collapsed?: boolean
|
||||
}>(),
|
||||
{ active: false, collapsed: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="['cc-nav', active && 'cc-nav--active', collapsed && 'cc-nav--collapsed']"
|
||||
role="button"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
:title="collapsed ? label : undefined"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<span class="cc-nav__icon">
|
||||
<Icon :name="icon" :size="17" :stroke-width="2" />
|
||||
</span>
|
||||
<span v-if="!collapsed" class="cc-nav__label">{{ label }}</span>
|
||||
<span v-if="!collapsed && count != null" class="cc-nav__count">{{ count }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-nav { display:flex; align-items:center; gap:10px; height:34px; padding:0 10px; border-radius:var(--radius-md);
|
||||
color:var(--text-secondary); cursor:pointer; transition:var(--transition-colors); position:relative; user-select:none; }
|
||||
.cc-nav:hover { background:var(--surface-hover); color:var(--text-primary); }
|
||||
.cc-nav__icon { flex:none; color:var(--text-tertiary); display:flex; transition:var(--transition-colors); }
|
||||
.cc-nav__label { flex:1; font-size:var(--text-sm); font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.cc-nav__count { font-family:var(--font-mono); font-size:11px; color:var(--text-tertiary); padding:1px 6px; border-radius:var(--radius-pill); background:var(--surface-active); }
|
||||
.cc-nav--active { background:var(--accent-soft); color:var(--accent-text); }
|
||||
.cc-nav--active .cc-nav__icon { color:var(--accent-text); }
|
||||
.cc-nav--active::before { content:''; position:absolute; left:-10px; top:7px; bottom:7px; width:3px; border-radius:var(--radius-pill); background:var(--accent); }
|
||||
.cc-nav--active .cc-nav__count { background:var(--accent-soft-strong); color:var(--accent-text); }
|
||||
.cc-nav--collapsed { justify-content:center; padding:0; width:40px; }
|
||||
</style>
|
||||
67
frontend/src/components/ds/navigation/Tabs.vue
Normal file
67
frontend/src/components/ds/navigation/Tabs.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Tabs — horizontal tab bar. variant="pill" fills active tab with accent-soft;
|
||||
* variant="line" underlines with accent. Items can be bare strings or TabItem objects.
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
export interface TabItem {
|
||||
value: string
|
||||
label: string
|
||||
icon?: string
|
||||
count?: number | string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: (string | TabItem)[]
|
||||
variant?: 'pill' | 'line'
|
||||
class?: string
|
||||
}>(),
|
||||
{ variant: 'pill' },
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
function normalise(it: string | TabItem): TabItem {
|
||||
return typeof it === 'string' ? { value: it, label: it } : it
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="['cc-tabs', `cc-tabs--${variant}`, props.class]"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
v-for="raw in items"
|
||||
:key="normalise(raw).value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="normalise(raw).value === model"
|
||||
class="cc-tab"
|
||||
@click="model = normalise(raw).value"
|
||||
>
|
||||
<Icon v-if="normalise(raw).icon" :name="normalise(raw).icon ?? ''" :size="15" />
|
||||
{{ normalise(raw).label }}
|
||||
<span v-if="normalise(raw).count != null" class="cc-tab__count">{{ normalise(raw).count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-tabs { display:flex; align-items:center; gap:2px; position:relative; }
|
||||
.cc-tabs--line { box-shadow: inset 0 -1px 0 var(--border-subtle); gap:4px; }
|
||||
.cc-tab {
|
||||
display:inline-flex; align-items:center; gap:7px; height:32px; padding:0 11px; border:0; background:transparent;
|
||||
font-family:var(--font-sans); font-size:var(--text-sm); font-weight:500; color:var(--text-tertiary);
|
||||
cursor:pointer; border-radius:var(--radius-sm); transition:var(--transition-colors); white-space:nowrap; position:relative;
|
||||
}
|
||||
.cc-tab:hover { color:var(--text-primary); background:var(--surface-hover); }
|
||||
.cc-tabs--pill .cc-tab[aria-selected="true"] { color:var(--accent-text); background:var(--accent-soft); }
|
||||
.cc-tabs--line .cc-tab { border-radius:0; height:38px; padding:0 4px; margin:0 7px; }
|
||||
.cc-tabs--line .cc-tab:hover { background:transparent; }
|
||||
.cc-tabs--line .cc-tab[aria-selected="true"] { color:var(--text-primary); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
.cc-tab__count { font-family:var(--font-mono); font-size:11px; padding:1px 6px; border-radius:var(--radius-pill); background:var(--surface-active); color:var(--text-tertiary); }
|
||||
.cc-tab[aria-selected="true"] .cc-tab__count { background:var(--accent-soft); color:var(--accent-text); }
|
||||
</style>
|
||||
@@ -1,103 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
/**
|
||||
* DashboardLayout — game-aware app shell (Phase C redesign).
|
||||
* Nav is driven by GAME_PROFILES[activeGame].nav — switching the GameSwitcher
|
||||
* visibly changes nav items, labels, and sections per game.
|
||||
* Preserves: permission gating, super-admin section, logout, mobile sidebar,
|
||||
* GameSwitcher, agent-health footer, topbar.
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Terminal,
|
||||
Users,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Map,
|
||||
MessageSquare,
|
||||
BarChart3,
|
||||
Bell,
|
||||
UserPlus,
|
||||
ShoppingBag,
|
||||
Package,
|
||||
Settings,
|
||||
LogOut,
|
||||
Shield,
|
||||
Key,
|
||||
CreditCard,
|
||||
Network,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
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 Badge from '@/components/ds/core/Badge.vue'
|
||||
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Avatar from '@/components/ds/data/Avatar.vue'
|
||||
import NavItem from '@/components/ds/navigation/NavItem.vue'
|
||||
import GameSwitcher from '@/components/ds/navigation/GameSwitcher.vue'
|
||||
import type { GameOption } from '@/components/ds/navigation/GameSwitcher.vue'
|
||||
import type { ActiveGame } from '@/composables/useThemeGame'
|
||||
|
||||
// ---- Stores / composables ----
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const server = useServerStore()
|
||||
const { theme, activeGame, setActiveGame, toggleTheme } = useThemeGame()
|
||||
|
||||
// ---- Mobile sidebar ----
|
||||
const sidebarOpen = ref(false)
|
||||
function closeSidebar() { sidebarOpen.value = false }
|
||||
|
||||
type NavItem = { name: string; path: string; icon: any; permission: string | null }
|
||||
type NavSection = { label: string; items: NavItem[] }
|
||||
// ---- App version ----
|
||||
const APP_VERSION = __APP_VERSION__
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
label: '',
|
||||
items: [
|
||||
{ name: 'Dashboard', path: '/', icon: LayoutDashboard, 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: FolderOpen, permission: 'files.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Plugin Configs',
|
||||
items: [
|
||||
{ name: 'Plugin Configs', path: '/plugin-configs', icon: Puzzle, permission: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Operations',
|
||||
items: [
|
||||
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
|
||||
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
|
||||
{ name: 'Schedules', path: '/schedules', icon: Clock, permission: 'schedules.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Monitoring',
|
||||
items: [
|
||||
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
|
||||
{ name: 'Analytics', path: '/analytics', icon: BarChart3, permission: 'analytics.view' },
|
||||
{ name: 'Alerts', path: '/alerts', icon: AlertTriangle, permission: 'alerts.view' },
|
||||
{ name: 'Notifications', path: '/notifications', icon: Bell, permission: 'notifications.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Management',
|
||||
items: [
|
||||
{ name: 'Team', path: '/team', icon: UserPlus, permission: null },
|
||||
{ name: 'Store', path: '/store/config', icon: ShoppingBag, permission: 'store.view' },
|
||||
{ name: 'Modules', path: '/modules', icon: Package, permission: 'modules.view' },
|
||||
{ name: 'Changelog', path: '/changelog', icon: FileText, permission: 'changelog.view' },
|
||||
{ name: 'Settings', path: '/settings', icon: Settings, permission: 'settings.view' },
|
||||
],
|
||||
},
|
||||
// ---- Game switcher ----
|
||||
const GAME_OPTIONS: GameOption[] = [
|
||||
{ key: 'all', label: 'All games', icon: 'layers' },
|
||||
{ key: 'rust', label: 'Rust', icon: 'box' },
|
||||
{ key: 'dune', label: 'Dune', icon: 'sun' },
|
||||
{ key: 'conan', label: 'Conan Exiles', icon: 'swords' },
|
||||
{ key: 'soulmask', label: 'Soulmask', icon: 'drama' },
|
||||
]
|
||||
|
||||
const GAME_LABEL: Record<string, string> = {
|
||||
all: 'All games', rust: 'Rust', dune: 'Dune',
|
||||
conan: 'Conan Exiles', soulmask: 'Soulmask',
|
||||
}
|
||||
|
||||
function onActiveGame(val: string) {
|
||||
setActiveGame(val as ActiveGame)
|
||||
}
|
||||
|
||||
// ---- Navigation — driven by the game profile registry ----
|
||||
/**
|
||||
* For 'all', fall back to rust (superset nav). For a specific game, look up
|
||||
* its profile. noUncheckedIndexedAccess-safe: always ?? GAME_PROFILES.rust.
|
||||
*/
|
||||
const activeNavSections = computed<NavSection[]>(() => {
|
||||
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
|
||||
return (useGameProfile(game)).nav
|
||||
})
|
||||
|
||||
const adminNavItems = [
|
||||
{ name: 'Admin Home', path: '/admin', icon: Shield },
|
||||
{ name: 'Licenses', path: '/admin/licenses', icon: Key },
|
||||
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: CreditCard },
|
||||
{ name: 'Users', path: '/admin/users', icon: Users },
|
||||
{ name: 'Server Fleet', path: '/admin/servers', icon: Network },
|
||||
{ name: 'Admin home', path: '/admin', icon: 'shield' },
|
||||
{ name: 'Licenses', path: '/admin/licenses', icon: 'key' },
|
||||
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: 'credit-card' },
|
||||
{ name: 'Users', path: '/admin/users', icon: 'users' },
|
||||
{ name: 'Server fleet', path: '/admin/servers', icon: 'server' },
|
||||
]
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
@@ -105,16 +81,12 @@ function isActive(path: string): boolean {
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
function navigate(path: string) {
|
||||
router.push(path)
|
||||
closeSidebar()
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
function canShowNavItem(item: NavItem): boolean {
|
||||
function canShowNavItem(item: NavItemDef): boolean {
|
||||
if (!item.permission) return true
|
||||
return auth.hasPermission(item.permission)
|
||||
}
|
||||
@@ -122,134 +94,508 @@ function canShowNavItem(item: NavItem): boolean {
|
||||
function hasVisibleItems(section: NavSection): boolean {
|
||||
return section.items.some(canShowNavItem)
|
||||
}
|
||||
|
||||
// ---- Agent health ----
|
||||
const hasAgent = computed(() => server.connection !== null)
|
||||
|
||||
const agentTone = computed(() => {
|
||||
const cs = server.connection?.connection_status
|
||||
if (cs === 'connected') return 'online' as const
|
||||
if (cs === 'degraded') return 'warn' as const
|
||||
return 'offline' as const
|
||||
})
|
||||
const agentLabel = computed(() => {
|
||||
const cs = server.connection?.connection_status
|
||||
if (cs === 'connected') return 'Healthy'
|
||||
if (cs === 'degraded') return 'Degraded'
|
||||
return 'Offline'
|
||||
})
|
||||
const agentName = computed(() => server.connection?.server_ip ?? 'Host agent')
|
||||
|
||||
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 ----
|
||||
const serverName = computed(() => auth.license?.server_name ?? 'Your servers')
|
||||
const userName = computed(() => auth.user?.username ?? '')
|
||||
const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen bg-neutral-950">
|
||||
<!-- Mobile Hamburger -->
|
||||
<button
|
||||
@click="sidebarOpen = true"
|
||||
class="md:hidden fixed top-4 left-4 z-40 p-2 bg-neutral-900 border border-neutral-800 rounded-lg text-neutral-300 hover:text-oxide-400 transition-colors"
|
||||
>
|
||||
<Menu class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Sidebar Overlay (Mobile) -->
|
||||
<!-- Outer app grid: sidebar | main -->
|
||||
<div class="app">
|
||||
<!-- ===================================================== SIDEBAR ===== -->
|
||||
<!-- Mobile overlay -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="sidebar-overlay"
|
||||
@click="closeSidebar"
|
||||
class="md:hidden fixed inset-0 bg-black/50 z-40"
|
||||
/>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="w-64 bg-neutral-900 border-r border-neutral-800 flex flex-col fixed inset-y-0 left-0 z-50 transform transition-transform"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
|
||||
class="app__sidebar"
|
||||
:class="sidebarOpen ? 'app__sidebar--open' : ''"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="p-4 border-b border-neutral-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="/logo.png" alt="Corrosion" class="h-8 w-8" />
|
||||
<div>
|
||||
<h1 class="text-sm font-bold text-oxide-500 tracking-wider">CORROSION</h1>
|
||||
<p class="text-xs text-neutral-500">{{ auth.license?.server_name || 'Server Management' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="closeSidebar"
|
||||
class="md:hidden text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Brand -->
|
||||
<div class="side__brand">
|
||||
<Logo :size="22" />
|
||||
<Badge tone="neutral" :mono="true" class="side__ver">{{ APP_VERSION }}</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Server Status Indicator -->
|
||||
<div class="px-4 py-3 border-b border-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'bg-green-500': server.connection?.connection_status === 'connected',
|
||||
'bg-yellow-500': server.connection?.connection_status === 'degraded',
|
||||
'bg-red-500': server.connection?.connection_status === 'offline' || !server.connection,
|
||||
}"
|
||||
<!-- Active game switcher -->
|
||||
<div class="side__game">
|
||||
<div class="t-eyebrow side__lbl">
|
||||
Active game · {{ GAME_LABEL[activeGame] ?? 'All games' }}
|
||||
</div>
|
||||
<GameSwitcher
|
||||
:model-value="activeGame"
|
||||
:games="GAME_OPTIONS"
|
||||
:show-labels="false"
|
||||
@update:model-value="onActiveGame"
|
||||
/>
|
||||
<span class="text-sm text-neutral-400">
|
||||
{{ server.stats?.player_count ?? 0 }}/{{ server.stats?.max_players ?? 0 }} players
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto py-2">
|
||||
<template v-for="section in navSections" :key="section.label">
|
||||
<!-- Navigation — sections driven by GAME_PROFILES[activeGame].nav -->
|
||||
<nav class="side__nav">
|
||||
<template v-for="section in activeNavSections" :key="section.label">
|
||||
<template v-if="hasVisibleItems(section)">
|
||||
<!-- Section Header -->
|
||||
<div v-if="section.label" class="mt-4 mb-1 px-4">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-neutral-500">{{ section.label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Section Items -->
|
||||
<RouterLink
|
||||
<div class="side__sec">
|
||||
<div v-if="section.label" class="t-eyebrow side__lbl">{{ section.label }}</div>
|
||||
<NavItem
|
||||
v-for="item in section.items"
|
||||
v-show="canShowNavItem(item)"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
@click="closeSidebar"
|
||||
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
||||
:class="isActive(item.path)
|
||||
? 'bg-oxide-500/10 text-oxide-400'
|
||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
||||
>
|
||||
<component :is="item.icon" class="w-4 h-4" />
|
||||
{{ item.name }}
|
||||
</RouterLink>
|
||||
:key="item.route"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:active="isActive(item.route)"
|
||||
@click="navigate(item.route)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Platform Admin Section (super-admin only) -->
|
||||
<template v-if="auth.isSuperAdmin">
|
||||
<div class="mt-4 mb-1 px-4">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-oxide-500">Platform</span>
|
||||
</div>
|
||||
<RouterLink
|
||||
<!-- Platform admin section (super-admin only) -->
|
||||
<div v-if="auth.isSuperAdmin" class="side__sec">
|
||||
<div class="t-eyebrow side__lbl side__lbl--platform">Platform</div>
|
||||
<NavItem
|
||||
v-for="item in adminNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
@click="closeSidebar"
|
||||
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
||||
:class="isActive(item.path)
|
||||
? 'bg-oxide-500/10 text-oxide-400'
|
||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
||||
>
|
||||
<component :is="item.icon" class="w-4 h-4" />
|
||||
{{ item.name }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
:icon="item.icon"
|
||||
:label="item.name"
|
||||
:active="isActive(item.path)"
|
||||
@click="navigate(item.path)"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- User -->
|
||||
<div class="p-4 border-t border-neutral-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-300">{{ auth.user?.username }}</p>
|
||||
<p class="text-xs text-neutral-500">{{ auth.user?.email }}</p>
|
||||
<!-- Host agent footer -->
|
||||
<div class="side__foot">
|
||||
<!-- Connected: real IP + status badge + meta line -->
|
||||
<div v-if="hasAgent" class="agent">
|
||||
<div class="agent__row">
|
||||
<StatusDot :tone="agentTone" :pulse="agentTone === 'online'" />
|
||||
<span class="agent__name">{{ agentName }}</span>
|
||||
<Badge :tone="agentTone" size="md">{{ agentLabel }}</Badge>
|
||||
</div>
|
||||
<div class="agent__meta">{{ agentMetaLine }}</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>
|
||||
<!-- User / logout row -->
|
||||
<div class="side__user">
|
||||
<span class="side__user-name">{{ auth.user?.username ?? '' }}</span>
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="text-neutral-500 hover:text-oxide-400 transition-colors"
|
||||
type="button"
|
||||
class="side__logout"
|
||||
title="Sign out"
|
||||
@click="() => { auth.logout(); router.push('/login') }"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content (offset by sidebar width on desktop) -->
|
||||
<main class="flex-1 overflow-y-auto md:pl-64">
|
||||
<!-- ======================================================= MAIN ===== -->
|
||||
<div class="app__main">
|
||||
<!-- Topbar -->
|
||||
<header class="app__topbar">
|
||||
<!-- Mobile hamburger (left of topbar on small screens) -->
|
||||
<button
|
||||
class="topbar-hamburger"
|
||||
type="button"
|
||||
aria-label="Open navigation"
|
||||
@click="sidebarOpen = true"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<div class="top__crumbs">
|
||||
<span class="crumb">Corrosion</span>
|
||||
<span class="crumb__sep">/</span>
|
||||
<span class="crumb crumb--cluster">{{ serverName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="top__search">
|
||||
<svg class="top__search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input placeholder="Search servers, players, configs…" readonly />
|
||||
<span class="top__kbd">
|
||||
<kbd class="cc-kbd">⌘</kbd><kbd class="cc-kbd">K</kbd>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="top__actions">
|
||||
<IconButton
|
||||
:icon="themeIcon"
|
||||
label="Toggle theme"
|
||||
@click="toggleTheme"
|
||||
/>
|
||||
<IconButton icon="bell" label="Alerts" @click="router.push('/alerts')" />
|
||||
<Button size="sm" icon="rocket">Deploy server</Button>
|
||||
<Avatar
|
||||
:name="userName"
|
||||
:size="30"
|
||||
status="online"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page content — boundary keeps sidebar/topbar alive when a view fails -->
|
||||
<main class="app__content">
|
||||
<ErrorBoundary variant="content">
|
||||
<RouterView />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* ============================================================ SHELL ===== */
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; overflow: hidden; }
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w, 228px) 1fr;
|
||||
height: 100vh;
|
||||
background: var(--surface-canvas, #0a0a0b);
|
||||
}
|
||||
|
||||
/* ---- Sidebar ---- */
|
||||
.app__sidebar {
|
||||
background: var(--surface-base);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.side__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 15px 16px 12px;
|
||||
}
|
||||
|
||||
.side__ver {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.side__game {
|
||||
padding: 2px 14px 13px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.side__lbl {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.side__lbl--platform {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.side__nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 13px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.side__sec {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.side__sec .t-eyebrow {
|
||||
margin: 0 0 5px 10px;
|
||||
}
|
||||
|
||||
.side__foot {
|
||||
padding: 11px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.agent {
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 9px 11px;
|
||||
}
|
||||
|
||||
.agent__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent__meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 5px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.agent--empty { opacity: 0.7; }
|
||||
|
||||
.agent__name--muted {
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.side__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
|
||||
.side__user-name {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.side__logout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
flex: none;
|
||||
}
|
||||
.side__logout:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
|
||||
/* ---- Main ---- */
|
||||
.app__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app__topbar {
|
||||
height: var(--topbar-h, 52px);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
.top__crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.crumb { color: var(--text-tertiary); }
|
||||
.crumb__sep { color: var(--text-muted); }
|
||||
.crumb--cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
background: var(--surface-raised-2);
|
||||
border: 0;
|
||||
box-shadow: var(--ring-default);
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.top__search {
|
||||
flex: 1;
|
||||
max-width: 440px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
height: 34px;
|
||||
padding: 0 11px;
|
||||
background: var(--surface-inset);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.top__search-icon { flex: none; }
|
||||
|
||||
.top__search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.top__search input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.top__kbd { display: flex; gap: 3px; }
|
||||
|
||||
.top__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.app__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 22px 24px 40px;
|
||||
}
|
||||
|
||||
/* ---- Mobile hamburger ---- */
|
||||
.topbar-hamburger {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
.topbar-hamburger:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
|
||||
/* ---- Sidebar overlay (mobile) ---- */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 49;
|
||||
}
|
||||
|
||||
/* ---- Kbd styling ---- */
|
||||
.cc-kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
background: var(--surface-active);
|
||||
border-radius: var(--radius-xs, 3px);
|
||||
box-shadow: var(--ring-default);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 900px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app__sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 228px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 220ms cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.app__sidebar--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.topbar-hamburger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.top__search {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,76 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-neutral-950 flex flex-col">
|
||||
<!-- Navigation -->
|
||||
<nav class="border-b border-neutral-800 bg-neutral-950/80 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<RouterLink :to="{ name: 'landing' }" class="flex items-center gap-3">
|
||||
<img src="/logo.png" alt="Corrosion" class="h-8 w-8" />
|
||||
<span class="text-lg font-bold text-neutral-100">Corrosion</span>
|
||||
<div>
|
||||
<!-- Nav -->
|
||||
<nav class="mkt-nav">
|
||||
<div class="wrap mkt-nav__in">
|
||||
<RouterLink :to="{ name: 'landing' }" class="brand">
|
||||
<span class="mark"><CorrosionMark :size="26" /></span>
|
||||
<b>Corrosion</b>
|
||||
</RouterLink>
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<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: 'pricing' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Pricing</RouterLink>
|
||||
<RouterLink :to="{ name: 'roadmap' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Roadmap</RouterLink>
|
||||
<RouterLink :to="{ name: 'faq' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">FAQ</RouterLink>
|
||||
<div class="mkt-nav__links">
|
||||
<RouterLink :to="{ name: 'landing' }" class="scroll-link">Features</RouterLink>
|
||||
<RouterLink :to="{ name: 'pricing' }">Pricing</RouterLink>
|
||||
<RouterLink :to="{ name: 'how-it-works' }">How it works</RouterLink>
|
||||
<RouterLink :to="{ name: 'faq' }">FAQ</RouterLink>
|
||||
<RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a :href="panelUrl + '/login'" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">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>
|
||||
<div class="mkt-nav__cta">
|
||||
<a class="mkt-nav__signin" :href="panelUrl + '/login'">Sign in</a>
|
||||
<RouterLink class="btn btn--primary btn--sm" :to="{ name: 'early-access' }">
|
||||
Early access
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Page content -->
|
||||
<main class="flex-1">
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="border-t border-neutral-800 py-12">
|
||||
<div class="max-w-6xl mx-auto px-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Product</h4>
|
||||
<div class="space-y-2">
|
||||
<RouterLink :to="{ name: 'how-it-works' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">How It Works</RouterLink>
|
||||
<RouterLink :to="{ name: 'pricing' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Pricing</RouterLink>
|
||||
<RouterLink :to="{ name: 'roadmap' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Roadmap</RouterLink>
|
||||
<footer class="mkt-footer">
|
||||
<div class="wrap">
|
||||
<div class="footer__cols">
|
||||
<div class="footer__brand">
|
||||
<RouterLink :to="{ name: 'landing' }" class="brand">
|
||||
<span class="mark"><CorrosionMark :size="24" /></span>
|
||||
<b>Corrosion</b>
|
||||
</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>
|
||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Support</h4>
|
||||
<div class="space-y-2">
|
||||
<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 class="footer__bar">
|
||||
<span>© 2026 Corrosion. All rights reserved.</span>
|
||||
<span>One control plane. Every game.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import Logo from '@/components/ds/brand/Logo.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-neutral-950">
|
||||
<div class="pub-shell">
|
||||
<RouterView />
|
||||
|
||||
<footer class="py-6 text-center text-neutral-600 text-sm border-t border-neutral-800">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<img src="/logo.png" alt="Corrosion" class="h-4 w-4 opacity-60" />
|
||||
<span>Powered by <span class="text-oxide-500 font-semibold">Corrosion</span></span>
|
||||
</div>
|
||||
<footer class="pub-footer">
|
||||
<Logo :size="18" :wordmark="true" />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pub-shell {
|
||||
min-height: 100vh;
|
||||
background: var(--surface-canvas);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pub-footer {
|
||||
margin-top: auto;
|
||||
padding: var(--space-6) var(--space-6);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { rustContainers, containerCategories } from '@/data/rust-containers'
|
||||
import { Search, Box, Cylinder, Shield, Users, HelpCircle } from 'lucide-vue-next'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import DsInput from '@/components/ds/forms/Input.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
lootTable: Record<string, any>
|
||||
@@ -14,20 +15,21 @@ const emit = defineEmits<{
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
const categoryIcons: Record<string, any> = {
|
||||
crates: Box,
|
||||
barrels: Cylinder,
|
||||
military: Shield,
|
||||
npcs: Users,
|
||||
other: HelpCircle,
|
||||
// Map container categories to DS icon names
|
||||
const categoryIcons: Record<string, string> = {
|
||||
crates: 'box',
|
||||
barrels: 'flask-conical',
|
||||
military: 'shield',
|
||||
npcs: 'users',
|
||||
other: 'info',
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
crates: 'CRATES',
|
||||
barrels: 'BARRELS',
|
||||
military: 'MILITARY',
|
||||
crates: 'Crates',
|
||||
barrels: 'Barrels',
|
||||
military: 'Military',
|
||||
npcs: 'NPCs',
|
||||
other: 'OTHER',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
const filteredContainers = computed(() => {
|
||||
@@ -56,48 +58,136 @@ function isConfigured(prefab: string): boolean {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-64 shrink-0 bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden flex flex-col">
|
||||
<aside class="lcs-root">
|
||||
<!-- Search -->
|
||||
<div class="p-3 border-b border-neutral-800">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
<div class="lcs-search">
|
||||
<DsInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Search containers..."
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200 placeholder-neutral-500"
|
||||
icon="search"
|
||||
placeholder="Search containers…"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Container list -->
|
||||
<div class="lcs-list">
|
||||
<template v-for="(containers, category) in groupedContainers" :key="category">
|
||||
<!-- Category heading -->
|
||||
<div class="lcs-cat">
|
||||
<Icon
|
||||
:name="categoryIcons[category] ?? 'box'"
|
||||
:size="12"
|
||||
class="lcs-cat__icon"
|
||||
/>
|
||||
<span class="lcs-cat__label">{{ categoryLabels[category] ?? category }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Container List -->
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<template v-for="(containers, category) in groupedContainers" :key="category">
|
||||
<div class="px-3 pt-3 pb-1">
|
||||
<div class="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
|
||||
<component :is="categoryIcons[category]" class="w-3 h-3" />
|
||||
{{ categoryLabels[category] || category }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Container rows -->
|
||||
<button
|
||||
v-for="c in containers"
|
||||
:key="c.prefab"
|
||||
class="lcs-item"
|
||||
:class="{ 'lcs-item--active': selected === c.prefab }"
|
||||
@click="emit('select', c.prefab)"
|
||||
class="w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 transition-colors"
|
||||
:class="selected === c.prefab
|
||||
? 'bg-oxide-500/10 text-oxide-400'
|
||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
||||
>
|
||||
<span class="truncate flex-1">{{ c.name }}</span>
|
||||
<span
|
||||
v-if="isConfigured(c.prefab)"
|
||||
class="w-1.5 h-1.5 rounded-full bg-oxide-500 shrink-0"
|
||||
/>
|
||||
<span class="lcs-item__name">{{ c.name }}</span>
|
||||
<span v-if="isConfigured(c.prefab)" class="lcs-item__dot" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="Object.keys(groupedContainers).length === 0" class="px-3 py-6 text-center text-neutral-500 text-sm">
|
||||
<div v-if="Object.keys(groupedContainers).length === 0" class="lcs-empty">
|
||||
No containers match
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lcs-root {
|
||||
width: 240px;
|
||||
flex: none;
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lcs-search {
|
||||
padding: 10px 10px 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.lcs-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* Category heading */
|
||||
.lcs-cat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 4px;
|
||||
}
|
||||
.lcs-cat__icon {
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
}
|
||||
.lcs-cat__label {
|
||||
font-size: var(--text-2xs, 10px);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Container row */
|
||||
.lcs-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
transition: var(--transition-colors);
|
||||
border-radius: 0;
|
||||
}
|
||||
.lcs-item:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lcs-item--active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.lcs-item__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lcs-item__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.lcs-empty {
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { rustItems } from '@/data/rust-items'
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import DsInput from '@/components/ds/forms/Input.vue'
|
||||
import type { LootGroupProfile } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -65,120 +71,260 @@ function updateGroupItemField(groupName: string, shortname: string, field: strin
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Add Group -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
<div class="lge-root">
|
||||
<!-- Add group panel -->
|
||||
<Panel>
|
||||
<div class="lge-add">
|
||||
<DsInput
|
||||
v-model="newGroupName"
|
||||
placeholder="New group name..."
|
||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||
placeholder="New group name…"
|
||||
@keydown.enter="addGroup"
|
||||
/>
|
||||
<button
|
||||
@click="addGroup"
|
||||
<Button
|
||||
icon="plus"
|
||||
:disabled="!newGroupName.trim()"
|
||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
@click="addGroup"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Group
|
||||
</button>
|
||||
</div>
|
||||
Add group
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Group List -->
|
||||
<div v-if="groupEntries.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
||||
No loot groups defined. Groups let you create reusable item pools that can be assigned to multiple containers.
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<Panel v-if="groupEntries.length === 0">
|
||||
<EmptyState
|
||||
icon="layers"
|
||||
title="No loot groups"
|
||||
description="Groups let you create reusable item pools that can be assigned to multiple containers."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<!-- Group cards -->
|
||||
<div
|
||||
v-for="entry in groupEntries"
|
||||
:key="entry.name"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden"
|
||||
class="lge-card"
|
||||
>
|
||||
<!-- Group Header -->
|
||||
<!-- Group header -->
|
||||
<button
|
||||
class="lge-card__head"
|
||||
@click="toggleGroup(entry.name)"
|
||||
class="w-full flex items-center justify-between px-4 py-3 hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<component
|
||||
:is="expandedGroup === entry.name ? ChevronDown : ChevronRight"
|
||||
class="w-4 h-4 text-neutral-500"
|
||||
<Icon
|
||||
:name="expandedGroup === entry.name ? 'chevron-down' : 'chevron-right'"
|
||||
:size="16"
|
||||
class="lge-card__chevron"
|
||||
/>
|
||||
<span class="text-neutral-200 font-medium">{{ entry.name }}</span>
|
||||
<span class="text-xs text-neutral-500">{{ entry.itemCount }} items</span>
|
||||
</div>
|
||||
<button
|
||||
<span class="lge-card__name">{{ entry.name }}</span>
|
||||
<Badge tone="neutral" mono>{{ entry.itemCount }}</Badge>
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Delete group"
|
||||
class="lge-card__del"
|
||||
@click.stop="deleteGroup(entry.name)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Group Items -->
|
||||
<div v-if="expandedGroup === entry.name" class="border-t border-neutral-800 p-4">
|
||||
<table v-if="entry.itemCount > 0" class="w-full text-sm">
|
||||
<!-- Expanded items -->
|
||||
<div v-if="expandedGroup === entry.name" class="lge-card__body">
|
||||
<table v-if="entry.itemCount > 0" class="lge-table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800">
|
||||
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
||||
<th class="w-10"></th>
|
||||
<tr>
|
||||
<th class="lge-th">Item</th>
|
||||
<th class="lge-th lge-th--num">Min</th>
|
||||
<th class="lge-th lge-th--num">Max</th>
|
||||
<th class="lge-th lge-th--num">Prob %</th>
|
||||
<th class="lge-th lge-th--action"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(itemData, shortname) in entry.data.ItemList"
|
||||
:key="shortname"
|
||||
class="border-b border-neutral-800/50"
|
||||
class="lge-tr"
|
||||
>
|
||||
<td class="py-2 px-2 text-neutral-200">{{ getItemName(shortname as string) }}</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lge-td">{{ getItemName(shortname as string) }}</td>
|
||||
<td class="lge-td lge-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="(itemData as any).Min ?? 1"
|
||||
@input="updateGroupItemField(entry.name, shortname as string, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lge-td lge-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="(itemData as any).Max ?? 1"
|
||||
@input="updateGroupItemField(entry.name, shortname as string, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lge-td lge-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="(itemData as any).Probability ?? 100"
|
||||
@input="updateGroupItemField(entry.name, shortname as string, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<button
|
||||
<td class="lge-td lge-td--action">
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Remove item"
|
||||
@click="removeItemFromGroup(entry.name, shortname as string)"
|
||||
class="text-neutral-600 hover:text-red-400"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="text-neutral-500 text-sm text-center py-4">
|
||||
<p v-else class="lge-card__empty">
|
||||
No items in this group yet. Add items from the container editor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lge-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Add group row */
|
||||
.lge-add {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.lge-add > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Group card */
|
||||
.lge-card {
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
.lge-card__head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
text-align: left;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.lge-card__head:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.lge-card__chevron {
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
}
|
||||
.lge-card__name {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lge-card__del {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Expanded body */
|
||||
.lge-card__body {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 12px;
|
||||
}
|
||||
.lge-card__empty {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
padding: 16px 0 8px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.lge-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.lge-th {
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lge-th--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lge-th--action {
|
||||
width: 40px;
|
||||
}
|
||||
.lge-tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.lge-tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.lge-tr:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.lge-td {
|
||||
padding: 7px 10px;
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.lge-td--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lge-td--action {
|
||||
width: 40px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/* Shared number input (same as LootItemEditor) */
|
||||
.cc-num-input {
|
||||
width: 100%;
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 5px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-num-input:focus {
|
||||
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||
}
|
||||
.cc-num-input--center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import { computed } from 'vue'
|
||||
import { rustItems } from '@/data/rust-items'
|
||||
import { rustContainers } from '@/data/rust-containers'
|
||||
import { Trash2, Plus, Settings2 } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Switch from '@/components/ds/forms/Switch.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import type { PrefabLoot } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -76,157 +81,273 @@ const ungroupedItems = computed(() => {
|
||||
...(data as any),
|
||||
}))
|
||||
})
|
||||
|
||||
// Computed boolean for the Switch v-model
|
||||
const isEnabled = computed({
|
||||
get: () => containerData.value?.Enabled ?? true,
|
||||
set: () => toggleEnabled(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Container Header -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold text-neutral-100">{{ containerName }}</h2>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="containerData?.Enabled ?? true"
|
||||
@change="toggleEnabled"
|
||||
class="rounded bg-neutral-800 border-neutral-600 text-oxide-500 focus:ring-oxide-500"
|
||||
/>
|
||||
<span class="text-sm text-neutral-400">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings2 class="w-4 h-4 text-neutral-500" />
|
||||
<span class="text-xs text-neutral-500 font-mono">{{ containerKey.split('/').pop() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lie-root">
|
||||
<!-- Container settings panel -->
|
||||
<Panel :title="containerName">
|
||||
<template #actions>
|
||||
<Badge tone="neutral" mono class="lie-prefab">
|
||||
{{ containerKey.split('/').pop() }}
|
||||
</Badge>
|
||||
<Switch v-model="isEnabled" label="Enabled" size="sm" />
|
||||
</template>
|
||||
|
||||
<!-- Item Settings -->
|
||||
<div class="grid grid-cols-4 gap-3" v-if="containerData">
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Items Min</label>
|
||||
<!-- Item settings grid -->
|
||||
<div v-if="containerData" class="lie-settings">
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">Items min</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.ItemsMin ?? 1"
|
||||
@input="updateSettings('ItemsMin', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Items Max</label>
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">Items max</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.ItemsMax ?? 6"
|
||||
@input="updateSettings('ItemsMax', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Min Scrap</label>
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">Min scrap</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.MinScrap ?? 0"
|
||||
@input="updateSettings('MinScrap', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Max Scrap</label>
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">Max scrap</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.MaxScrap ?? 0"
|
||||
@input="updateSettings('MaxScrap', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="lie-unconfigured">
|
||||
Container not yet configured. Add an item to initialise its settings.
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
<!-- Ungrouped Items Table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-neutral-300">Ungrouped Items</h3>
|
||||
<button
|
||||
@click="emit('add-item')"
|
||||
class="flex items-center gap-1 px-3 py-1.5 bg-oxide-500/10 text-oxide-400 rounded-lg hover:bg-oxide-500/20 text-sm"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
<!-- Ungrouped items panel -->
|
||||
<Panel title="Ungrouped items" :flush-body="ungroupedItems.length > 0">
|
||||
<template #actions>
|
||||
<Button size="sm" variant="outline" icon="plus" @click="emit('add-item')">
|
||||
Add item
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div v-if="ungroupedItems.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<div v-if="ungroupedItems.length > 0" class="lie-table-wrap">
|
||||
<table class="lie-table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800">
|
||||
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
||||
<th class="w-10"></th>
|
||||
<tr>
|
||||
<th class="lie-th">Item</th>
|
||||
<th class="lie-th lie-th--num">Min</th>
|
||||
<th class="lie-th lie-th--num">Max</th>
|
||||
<th class="lie-th lie-th--num">Prob %</th>
|
||||
<th class="lie-th lie-th--action"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in ungroupedItems"
|
||||
:key="item.shortname"
|
||||
class="border-b border-neutral-800/50 hover:bg-neutral-800/30"
|
||||
class="lie-tr"
|
||||
>
|
||||
<td class="py-2 px-2">
|
||||
<div>
|
||||
<span class="text-neutral-200">{{ item.name }}</span>
|
||||
<span class="text-neutral-600 text-xs ml-2">{{ item.shortname }}</span>
|
||||
</div>
|
||||
<td class="lie-td">
|
||||
<span class="lie-item-name">{{ item.name }}</span>
|
||||
<span class="lie-item-short">{{ item.shortname }}</span>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lie-td lie-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="item.Min"
|
||||
@input="updateItemField(item.shortname, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lie-td lie-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="item.Max"
|
||||
@input="updateItemField(item.shortname, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lie-td lie-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="item.Probability ?? 100"
|
||||
@input="updateItemField(item.shortname, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<button
|
||||
<td class="lie-td lie-td--action">
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Remove item"
|
||||
@click="removeItem(item.shortname)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-6 text-neutral-500 text-sm">
|
||||
No items configured for this container.
|
||||
<button @click="emit('add-item')" class="text-oxide-400 hover:underline ml-1">Add one</button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
icon="package"
|
||||
title="No items configured"
|
||||
description="Add items to configure what this container can spawn."
|
||||
>
|
||||
<template #action>
|
||||
<Button size="sm" variant="outline" icon="plus" @click="emit('add-item')">
|
||||
Add item
|
||||
</Button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lie-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Badge for prefab key */
|
||||
.lie-prefab {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.lie-unconfigured {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Settings grid */
|
||||
.lie-settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.lie-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.lie-setting__label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.lie-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.lie-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.lie-th {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lie-th--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lie-th--action {
|
||||
width: 40px;
|
||||
}
|
||||
.lie-tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.lie-tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.lie-tr:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.lie-td {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.lie-td--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lie-td--action {
|
||||
width: 40px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.lie-item-name {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lie-item-short {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Shared number input */
|
||||
.cc-num-input {
|
||||
width: 100%;
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 5px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-num-input:focus {
|
||||
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||
}
|
||||
.cc-num-input--center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { rustItems, itemCategories } from '@/data/rust-items'
|
||||
import { Search, X } from 'lucide-vue-next'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import DsInput from '@/components/ds/forms/Input.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [shortname: string]
|
||||
@@ -25,64 +27,200 @@ const filteredItems = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="emit('close')">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<Teleport to="body">
|
||||
<div class="lip-overlay" @click.self="emit('close')">
|
||||
<div class="lip-modal">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-neutral-800 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-neutral-100">Add Item</h2>
|
||||
<button @click="emit('close')" class="text-neutral-500 hover:text-neutral-300">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
<div class="lip-head">
|
||||
<span class="lip-head__title">Add item</span>
|
||||
<IconButton icon="x" size="sm" label="Close" @click="emit('close')" />
|
||||
</div>
|
||||
|
||||
<!-- Search + Filter -->
|
||||
<div class="p-4 space-y-3 border-b border-neutral-800">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
<!-- Search + category filter -->
|
||||
<div class="lip-filters">
|
||||
<DsInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Search items..."
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200"
|
||||
autofocus
|
||||
icon="search"
|
||||
placeholder="Search items…"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="lip-cats">
|
||||
<button
|
||||
class="lip-cat"
|
||||
:class="{ 'lip-cat--active': selectedCategory === 'all' }"
|
||||
@click="selectedCategory = 'all'"
|
||||
class="px-2 py-1 rounded text-xs"
|
||||
:class="selectedCategory === 'all' ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in itemCategories"
|
||||
:key="cat"
|
||||
class="lip-cat"
|
||||
:class="{ 'lip-cat--active': selectedCategory === cat }"
|
||||
@click="selectedCategory = cat"
|
||||
class="px-2 py-1 rounded text-xs capitalize"
|
||||
:class="selectedCategory === cat ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ cat }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Grid -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<!-- Item grid -->
|
||||
<div class="lip-grid-wrap">
|
||||
<div v-if="filteredItems.length > 0" class="lip-grid">
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.shortname"
|
||||
class="lip-item"
|
||||
@click="emit('select', item.shortname)"
|
||||
class="text-left px-3 py-2 bg-neutral-800 rounded-lg hover:bg-neutral-700 transition-colors group"
|
||||
>
|
||||
<div class="text-sm text-neutral-200 group-hover:text-oxide-400">{{ item.name }}</div>
|
||||
<div class="text-xs text-neutral-500">{{ item.shortname }}</div>
|
||||
<span class="lip-item__name">{{ item.name }}</span>
|
||||
<span class="lip-item__short">{{ item.shortname }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="filteredItems.length === 0" class="text-center py-8 text-neutral-500">
|
||||
No items found
|
||||
<div v-else class="lip-empty">
|
||||
<Icon name="search" :size="20" class="lip-empty__icon" />
|
||||
<span>No items found</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lip-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.lip-modal {
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.lip-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex: none;
|
||||
}
|
||||
.lip-head__title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.lip-filters {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
.lip-cats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.lip-cat {
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 3px 10px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.lip-cat:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lip-cat--active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
box-shadow: none;
|
||||
}
|
||||
.lip-cat--active:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.lip-grid-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.lip-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.lip-item {
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 9px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.lip-item:hover {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.lip-item__name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.lip-item:hover .lip-item__name {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.lip-item__short {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Empty */
|
||||
.lip-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 48px 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.lip-empty__icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
configData: Record<string, any>
|
||||
@@ -47,7 +49,6 @@ function ensurePaths(data: Record<string, any>) {
|
||||
function addGroup() {
|
||||
const name = newGroupName.value.trim()
|
||||
if (!name) return
|
||||
// Check if already exists
|
||||
if (groups.value.some(g => g.name === name)) return
|
||||
|
||||
const updated = { ...props.configData }
|
||||
@@ -95,96 +96,95 @@ function updateField(groupName: string, field: string, value: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">VIP Permission Groups</h3>
|
||||
<div class="pge">
|
||||
<div class="pge__head">
|
||||
<div class="pge__section-label">VIP permission groups</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Group -->
|
||||
<div class="flex gap-2">
|
||||
<!-- Add group row -->
|
||||
<div class="pge__add-row">
|
||||
<input
|
||||
v-model="newGroupName"
|
||||
placeholder="New group name (e.g. vip, vip+, mvp)..."
|
||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||
type="text"
|
||||
class="pge__name-input"
|
||||
placeholder="New group name (e.g. vip, vip+, mvp)…"
|
||||
@keydown.enter="addGroup"
|
||||
/>
|
||||
<button
|
||||
@click="addGroup"
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
:disabled="!newGroupName.trim()"
|
||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Group
|
||||
</button>
|
||||
@click="addGroup"
|
||||
>Add group</Button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="groups.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
||||
No VIP groups defined. Add groups to configure per-permission teleport limits, cooldowns, and countdowns.
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<EmptyState
|
||||
v-if="groups.length === 0"
|
||||
icon="users"
|
||||
title="No VIP groups defined"
|
||||
description="Add groups to configure per-permission teleport limits, cooldowns, and countdowns."
|
||||
/>
|
||||
|
||||
<!-- Groups Table -->
|
||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<!-- Groups table -->
|
||||
<div v-else class="pge__table-wrap">
|
||||
<table class="pge__table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800">
|
||||
<th class="text-left py-3 px-4 text-neutral-500 font-medium">Group Name</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Homes Limit</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Cooldown (s)</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Countdown (s)</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Daily Limit</th>
|
||||
<th class="w-12"></th>
|
||||
<tr>
|
||||
<th class="pge__th pge__th--left">Group name</th>
|
||||
<th class="pge__th">Homes limit</th>
|
||||
<th class="pge__th">Cooldown (s)</th>
|
||||
<th class="pge__th">Countdown (s)</th>
|
||||
<th class="pge__th">Daily limit</th>
|
||||
<th class="pge__th pge__th--action" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="group in groups"
|
||||
:key="group.name"
|
||||
class="border-b border-neutral-800/50"
|
||||
class="pge__tr"
|
||||
>
|
||||
<td class="py-3 px-4 text-neutral-200 font-medium">{{ group.name }}</td>
|
||||
<td class="py-3 px-4">
|
||||
<td class="pge__td pge__td--name">{{ group.name }}</td>
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.homesLimit"
|
||||
min="0"
|
||||
@input="updateField(group.name, 'homesLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.cooldown"
|
||||
min="0"
|
||||
@input="updateField(group.name, 'cooldown', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.countdown"
|
||||
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.dailyLimit"
|
||||
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<button
|
||||
@click="removeGroup(group.name)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
<td class="pge__td pge__td--action">
|
||||
<button class="pge__del" type="button" @click="removeGroup(group.name)">
|
||||
<Icon name="trash-2" :size="15" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -193,3 +193,63 @@ function updateField(groupName: string, field: string, value: number) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ---------- Shell ---------- */
|
||||
.pge { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
/* ---------- Head ---------- */
|
||||
.pge__head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.pge__section-label {
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
|
||||
/* ---------- Add row ---------- */
|
||||
.pge__add-row { display: flex; gap: 8px; align-items: center; }
|
||||
.pge__name-input {
|
||||
flex: 1; height: var(--control-h-sm); padding: 0 10px;
|
||||
background: var(--surface-inset); border: 0; border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default); font-family: var(--font-sans);
|
||||
font-size: var(--text-sm); color: var(--text-primary);
|
||||
}
|
||||
.pge__name-input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
.pge__name-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* ---------- Table ---------- */
|
||||
.pge__table-wrap { border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--ring-default); }
|
||||
.pge__table { width: 100%; border-collapse: collapse; }
|
||||
.pge__th {
|
||||
padding: 9px 12px; font-size: var(--text-xs); font-weight: 600;
|
||||
color: var(--text-tertiary); text-align: center;
|
||||
background: var(--surface-raised); border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.pge__th--left { text-align: left; }
|
||||
.pge__th--action { width: 44px; }
|
||||
.pge__tr { border-bottom: 1px solid var(--border-subtle); }
|
||||
.pge__tr:last-child { border-bottom: 0; }
|
||||
.pge__tr:hover { background: var(--surface-hover); }
|
||||
.pge__td { padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.pge__td--name { font-weight: 500; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.pge__td--num { text-align: center; }
|
||||
.pge__td--action { text-align: center; }
|
||||
|
||||
/* ---------- Number input (table cell) ---------- */
|
||||
.pge__num-input {
|
||||
width: 80px; height: 28px; padding: 0 8px; text-align: center;
|
||||
background: var(--surface-inset); border: 0; border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default); font-family: var(--font-mono);
|
||||
font-size: var(--text-sm); color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pge__num-input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
|
||||
/* ---------- Delete button ---------- */
|
||||
.pge__del {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm); border: none;
|
||||
background: transparent; color: var(--text-muted); cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.pge__del:hover { color: var(--danger); background: var(--status-offline-soft); }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
warps: Record<string, { x: number; y: number; z: number }>
|
||||
@@ -28,49 +30,139 @@ function removeWarp(name: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Warps</h3>
|
||||
<div class="warp-editor">
|
||||
<div class="warp-editor__label">Warps</div>
|
||||
|
||||
<!-- Add Warp -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
<!-- Add warp row -->
|
||||
<div class="warp-editor__add">
|
||||
<Input
|
||||
v-model="newWarpName"
|
||||
placeholder="Warp name..."
|
||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||
:mono="true"
|
||||
style="flex: 1"
|
||||
@keydown.enter="addWarp"
|
||||
/>
|
||||
<button
|
||||
@click="addWarp"
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
:disabled="!newWarpName.trim()"
|
||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
@click="addWarp"
|
||||
>Add</Button>
|
||||
</div>
|
||||
|
||||
<!-- Warp List -->
|
||||
<div v-if="Object.keys(warps).length === 0" class="text-neutral-500 text-sm text-center py-4">
|
||||
<!-- Empty state -->
|
||||
<div v-if="Object.keys(warps).length === 0" class="warp-editor__empty">
|
||||
No warps defined. Add warps here and set coordinates in-game.
|
||||
</div>
|
||||
|
||||
<!-- Warp list -->
|
||||
<div
|
||||
v-for="(coords, name) in warps"
|
||||
:key="name"
|
||||
class="flex items-center justify-between bg-neutral-800/50 border border-neutral-700/50 rounded-lg px-4 py-3"
|
||||
class="warp-row"
|
||||
>
|
||||
<div>
|
||||
<span class="text-neutral-200 font-medium">{{ name }}</span>
|
||||
<span class="text-neutral-500 text-xs ml-3">
|
||||
{{ coords.x.toFixed(1) }}, {{ coords.y.toFixed(1) }}, {{ coords.z.toFixed(1) }}
|
||||
<div class="warp-row__id">
|
||||
<span class="warp-row__name">{{ name }}</span>
|
||||
<span class="warp-row__coords">
|
||||
{{ (coords as { x: number; y: number; z: number }).x.toFixed(1) }},
|
||||
{{ (coords as { x: number; y: number; z: number }).y.toFixed(1) }},
|
||||
{{ (coords as { x: number; y: number; z: number }).z.toFixed(1) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="warp-row__remove"
|
||||
type="button"
|
||||
:aria-label="`Remove warp ${name}`"
|
||||
@click="removeWarp(name as string)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
<Icon name="trash-2" :size="14" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.warp-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.warp-editor__label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.warp-editor__add {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.warp-editor__empty {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.warp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--ring-default);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.warp-row__id {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.warp-row__name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.warp-row__coords {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.warp-row__remove {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
transition: background var(--dur-fast) var(--ease-standard),
|
||||
color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.warp-row__remove:hover {
|
||||
background: var(--status-offline-soft);
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.warp-row__remove:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
</style>
|
||||
|
||||
114
frontend/src/composables/useThemeGame.ts
Normal file
114
frontend/src/composables/useThemeGame.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* useThemeGame — the Corrosion design-system theming contract.
|
||||
*
|
||||
* Drives `data-theme` and `data-game` on <html>, the two attributes the token
|
||||
* system keys off (see styles/tokens/colors.css + game-themes.css):
|
||||
* <html data-theme="dark|light" data-game="rust|dune|conan|soulmask|...">
|
||||
*
|
||||
* Dark is primary; Rust (Oxide Orange) is the default/brand accent.
|
||||
*
|
||||
* Runtime swaps add the `cc-skin-swap` class for one frame so every
|
||||
* accent-consuming surface repaints immediately — without it Chrome leaves
|
||||
* elements that read var(--accent) AND have a color/bg transition on the old
|
||||
* accent until the next reflow (see styles/tokens/base.css + readme).
|
||||
*/
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
export type Game =
|
||||
| 'rust'
|
||||
| 'dune'
|
||||
| 'conan'
|
||||
| 'soulmask'
|
||||
| 'ark'
|
||||
| 'valheim'
|
||||
| 'palworld'
|
||||
|
||||
/** The fleet filter: 'all' (every game) plus each individual game. */
|
||||
export type ActiveGame = 'all' | Game
|
||||
|
||||
const THEME_KEY = 'cc-theme'
|
||||
const GAME_KEY = 'cc-game'
|
||||
const ACTIVE_GAME_KEY = 'cc-active-game'
|
||||
|
||||
const VALID_THEMES: readonly Theme[] = ['dark', 'light']
|
||||
const VALID_GAMES: readonly Game[] = [
|
||||
'rust',
|
||||
'dune',
|
||||
'conan',
|
||||
'soulmask',
|
||||
'ark',
|
||||
'valheim',
|
||||
'palworld',
|
||||
]
|
||||
|
||||
// Module-scope singletons so every caller shares one reactive source.
|
||||
const theme = ref<Theme>('dark')
|
||||
const game = ref<Game>('rust')
|
||||
// Fleet filter: 'all' shows every game and uses the neutral house skin (Oxide);
|
||||
// a specific game both filters the fleet AND re-skins the shell (the drill-in rule).
|
||||
const activeGame = ref<ActiveGame>('all')
|
||||
|
||||
function apply(): void {
|
||||
const el = document.documentElement
|
||||
el.classList.add('cc-skin-swap')
|
||||
el.setAttribute('data-theme', theme.value)
|
||||
el.setAttribute('data-game', game.value)
|
||||
// Keep Tailwind's `dark` class in sync — existing views may use `dark:` utilities.
|
||||
el.classList.toggle('dark', theme.value === 'dark')
|
||||
// Drop the swap guard after the paint that picked up the new accent.
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => el.classList.remove('cc-skin-swap')),
|
||||
)
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
|
||||
/**
|
||||
* Read persisted prefs and apply them to <html>. Call once at app start
|
||||
* (after a tiny inline FOUC guard in index.html has set the initial attrs).
|
||||
*/
|
||||
export function initThemeGame(): void {
|
||||
if (initialized) return
|
||||
const t = localStorage.getItem(THEME_KEY)
|
||||
if (t && (VALID_THEMES as string[]).includes(t)) theme.value = t as Theme
|
||||
const ag = localStorage.getItem(ACTIVE_GAME_KEY)
|
||||
if (ag && (ag === 'all' || (VALID_GAMES as string[]).includes(ag))) {
|
||||
activeGame.value = ag as ActiveGame
|
||||
}
|
||||
// Skin follows the filter: 'all' -> neutral house (rust/oxide), else the game.
|
||||
game.value = activeGame.value === 'all' ? 'rust' : activeGame.value
|
||||
apply()
|
||||
initialized = true
|
||||
}
|
||||
|
||||
export function useThemeGame() {
|
||||
function setTheme(t: Theme): void {
|
||||
theme.value = t
|
||||
localStorage.setItem(THEME_KEY, t)
|
||||
apply()
|
||||
}
|
||||
function setGame(g: Game): void {
|
||||
game.value = g
|
||||
localStorage.setItem(GAME_KEY, g)
|
||||
apply()
|
||||
}
|
||||
function setActiveGame(g: ActiveGame): void {
|
||||
activeGame.value = g
|
||||
localStorage.setItem(ACTIVE_GAME_KEY, g)
|
||||
// 'all' uses the neutral house skin (rust/oxide); a game re-skins to itself.
|
||||
setGame(g === 'all' ? 'rust' : g)
|
||||
}
|
||||
function toggleTheme(): void {
|
||||
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
return {
|
||||
theme: readonly(theme),
|
||||
game: readonly(game),
|
||||
activeGame: readonly(activeGame),
|
||||
setTheme,
|
||||
setGame,
|
||||
setActiveGame,
|
||||
toggleTheme,
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import { VueFinderPlugin } from 'vuefinder'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { initThemeGame } from './composables/useThemeGame'
|
||||
import './style.css'
|
||||
import 'vuefinder/dist/vuefinder.css'
|
||||
|
||||
@@ -17,4 +18,7 @@ app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(VueFinderPlugin)
|
||||
|
||||
// Apply the design-system theming contract (data-theme/data-game on <html>).
|
||||
initThemeGame()
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
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
|
||||
// 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 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
|
||||
@@ -15,31 +32,55 @@ const marketingChildren: RouteRecordRaw[] = [
|
||||
path: '',
|
||||
name: 'landing',
|
||||
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',
|
||||
name: 'pricing',
|
||||
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',
|
||||
name: 'how-it-works',
|
||||
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',
|
||||
name: 'faq',
|
||||
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',
|
||||
name: 'roadmap',
|
||||
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',
|
||||
name: 'early-access',
|
||||
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',
|
||||
name: 'login',
|
||||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
meta: { guest: true },
|
||||
meta: { guest: true, title: 'Sign in — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
meta: { guest: true },
|
||||
meta: { guest: true, title: 'Create account — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||
meta: { guest: true },
|
||||
meta: { guest: true, title: 'Reset password — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
name: 'setup-wizard',
|
||||
component: () => import('@/views/auth/SetupWizardView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true, title: 'Setup — Corrosion' },
|
||||
},
|
||||
|
||||
// Admin dashboard routes (with sidebar layout)
|
||||
@@ -84,217 +125,254 @@ const panelRoutes: RouteRecordRaw[] = [
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/admin/DashboardView.vue'),
|
||||
meta: { title: 'Dashboard — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'server',
|
||||
name: 'server',
|
||||
component: () => import('@/views/admin/ServerView.vue'),
|
||||
meta: { title: 'Server — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'console',
|
||||
name: 'console',
|
||||
component: () => import('@/views/admin/ConsoleView.vue'),
|
||||
meta: { title: 'Console — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'players',
|
||||
name: 'players',
|
||||
component: () => import('@/views/admin/PlayersView.vue'),
|
||||
meta: { title: 'Players — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('@/views/admin/PluginsView.vue'),
|
||||
meta: { title: 'Plugins — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'files',
|
||||
component: () => import('@/views/admin/FileManagerView.vue'),
|
||||
meta: { title: 'Files — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'plugin-configs',
|
||||
name: 'plugin-configs',
|
||||
component: () => import('@/views/admin/PluginConfigsView.vue'),
|
||||
meta: { title: 'Plugin Configs — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'loot-builder',
|
||||
name: 'loot-builder',
|
||||
component: () => import('@/views/admin/LootBuilderView.vue'),
|
||||
meta: { title: 'Loot Builder — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'teleport-config',
|
||||
name: 'teleport-config',
|
||||
component: () => import('@/views/admin/TeleportConfigView.vue'),
|
||||
meta: { title: 'Teleport Config — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'gather-manager',
|
||||
name: 'gather-manager',
|
||||
component: () => import('@/views/admin/GatherManagerView.vue'),
|
||||
meta: { title: 'Gather Manager — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'autodoors',
|
||||
name: 'autodoors',
|
||||
component: () => import('@/views/admin/AutoDoorsView.vue'),
|
||||
meta: { title: 'Auto Doors — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'kits',
|
||||
name: 'kits-config',
|
||||
component: () => import('@/views/admin/KitsView.vue'),
|
||||
meta: { title: 'Kits — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'furnace-splitter',
|
||||
name: 'furnace-splitter',
|
||||
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
|
||||
meta: { title: 'Furnace Splitter — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'better-chat',
|
||||
name: 'better-chat',
|
||||
component: () => import('@/views/admin/BetterChatView.vue'),
|
||||
meta: { title: 'Better Chat — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'timed-execute',
|
||||
name: 'timed-execute',
|
||||
component: () => import('@/views/admin/TimedExecuteView.vue'),
|
||||
meta: { title: 'Timed Execute — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'raidable-bases',
|
||||
name: 'raidable-bases',
|
||||
component: () => import('@/views/admin/RaidableBasesView.vue'),
|
||||
meta: { title: 'Raidable Bases — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'wipes',
|
||||
name: 'wipes',
|
||||
component: () => import('@/views/admin/WipesView.vue'),
|
||||
meta: { title: 'Wipes — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'wipes/profiles',
|
||||
name: 'wipe-profiles',
|
||||
component: () => import('@/views/admin/WipeProfilesView.vue'),
|
||||
meta: { title: 'Wipe Profiles — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'wipes/calendar',
|
||||
name: 'wipe-calendar',
|
||||
component: () => import('@/views/admin/WipeCalendarView.vue'),
|
||||
meta: { title: 'Wipe Calendar — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'wipes/history',
|
||||
name: 'wipe-history',
|
||||
component: () => import('@/views/admin/WipeHistoryView.vue'),
|
||||
meta: { title: 'Wipe History — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'wipes/analytics',
|
||||
name: 'wipe-analytics',
|
||||
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
|
||||
meta: { title: 'Wipe Analytics — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'maps',
|
||||
name: 'maps',
|
||||
component: () => import('@/views/admin/MapsView.vue'),
|
||||
meta: { title: 'Maps — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'maps/analytics',
|
||||
name: 'map-analytics',
|
||||
component: () => import('@/views/admin/MapAnalyticsView.vue'),
|
||||
meta: { title: 'Map Analytics — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'chat',
|
||||
name: 'chat',
|
||||
component: () => import('@/views/admin/ChatLogView.vue'),
|
||||
meta: { title: 'Chat Log — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'analytics',
|
||||
name: 'analytics',
|
||||
component: () => import('@/views/admin/AnalyticsView.vue'),
|
||||
meta: { title: 'Analytics — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'retention',
|
||||
name: 'retention',
|
||||
component: () => import('@/views/admin/PlayerRetentionView.vue'),
|
||||
meta: { title: 'Player Retention — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('@/views/admin/NotificationsView.vue'),
|
||||
meta: { title: 'Notifications — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'team',
|
||||
name: 'team',
|
||||
component: () => import('@/views/admin/TeamView.vue'),
|
||||
meta: { title: 'Team — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'store/config',
|
||||
name: 'store-config',
|
||||
component: () => import('@/views/admin/StoreConfigView.vue'),
|
||||
meta: { title: 'Store Config — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'store/items',
|
||||
name: 'store-items',
|
||||
component: () => import('@/views/admin/StoreItemsView.vue'),
|
||||
meta: { title: 'Store Items — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'store/revenue',
|
||||
name: 'store-revenue',
|
||||
component: () => import('@/views/admin/StoreRevenueView.vue'),
|
||||
meta: { title: 'Store Revenue — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'modules',
|
||||
name: 'modules',
|
||||
component: () => import('@/views/admin/ModuleStoreView.vue'),
|
||||
meta: { title: 'Modules — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/admin/SettingsView.vue'),
|
||||
meta: { title: 'Settings — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'schedules',
|
||||
name: 'schedules',
|
||||
component: () => import('@/views/admin/SchedulesView.vue'),
|
||||
meta: { title: 'Schedules — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'migration',
|
||||
name: 'migration',
|
||||
component: () => import('@/views/admin/MigrationView.vue'),
|
||||
meta: { title: 'Migration — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'changelog',
|
||||
name: 'changelog',
|
||||
component: () => import('@/views/admin/ChangelogView.vue'),
|
||||
meta: { title: 'Changelog — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'alerts',
|
||||
name: 'alerts',
|
||||
component: () => import('@/views/admin/AlertsView.vue'),
|
||||
meta: { title: 'Alerts — Corrosion' },
|
||||
},
|
||||
// Platform Admin views (super-admin only)
|
||||
{
|
||||
path: 'admin',
|
||||
name: 'platform-admin',
|
||||
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
|
||||
meta: { superAdmin: true },
|
||||
meta: { superAdmin: true, title: 'Admin — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'admin/licenses',
|
||||
name: 'platform-licenses',
|
||||
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
|
||||
meta: { superAdmin: true },
|
||||
meta: { superAdmin: true, title: 'Admin: Licenses — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'admin/subscriptions',
|
||||
name: 'platform-subscriptions',
|
||||
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
|
||||
meta: { superAdmin: true },
|
||||
meta: { superAdmin: true, title: 'Admin: Subscriptions — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
name: 'platform-users',
|
||||
component: () => import('@/views/platform-admin/AdminUsers.vue'),
|
||||
meta: { superAdmin: true },
|
||||
meta: { superAdmin: true, title: 'Admin: Users — Corrosion' },
|
||||
},
|
||||
{
|
||||
path: 'admin/servers',
|
||||
name: 'platform-servers',
|
||||
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',
|
||||
name: 'status',
|
||||
component: () => import('@/views/public/StatusPageView.vue'),
|
||||
meta: { title: 'Status — Corrosion' },
|
||||
},
|
||||
|
||||
// Catch-all
|
||||
@@ -366,6 +445,7 @@ const marketingRoutes: RouteRecordRaw[] = [
|
||||
path: '/status',
|
||||
name: 'status',
|
||||
component: () => import('@/views/public/StatusPageView.vue'),
|
||||
meta: { title: 'Status — Corrosion' },
|
||||
},
|
||||
|
||||
// Catch-all: unknown routes → landing page
|
||||
@@ -383,6 +463,38 @@ const router = createRouter({
|
||||
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)
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -58,6 +58,27 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
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 {
|
||||
return license.value?.modules_enabled?.includes(moduleSlug) ?? false
|
||||
}
|
||||
@@ -92,6 +113,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
setAuth,
|
||||
setLicense,
|
||||
logout,
|
||||
validateSession,
|
||||
hasModule,
|
||||
hasPermission,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Corrosion Brand — Oxide Orange #F26622 */
|
||||
/* Corrosion design tokens — load order matters: fonts → primitives → colors →
|
||||
game themes → elevation → base (base references color/accent tokens).
|
||||
Imported DIRECTLY here (not via a nested ./styles/corrosion.css barrel): a
|
||||
nested @import barrel placed after `@import "tailwindcss"` gets its inner
|
||||
@imports dropped, because once Tailwind v4 expands in place they no longer
|
||||
precede all other statements. Keeping them flat + contiguous fixes that. */
|
||||
@import "./styles/tokens/fonts.css";
|
||||
@import "./styles/tokens/spacing.css";
|
||||
@import "./styles/tokens/typography.css";
|
||||
@import "./styles/tokens/motion.css";
|
||||
@import "./styles/tokens/colors.css";
|
||||
@import "./styles/tokens/game-themes.css";
|
||||
@import "./styles/tokens/elevation.css";
|
||||
@import "./styles/tokens/base.css";
|
||||
|
||||
/* Tailwind utility colors — Oxide ramp (existing views use bg-oxide-*).
|
||||
The full design-token system (neutral ramp, surfaces, per-game accents,
|
||||
typography, spacing, elevation, motion) lives in ./styles/ and is the
|
||||
source of truth for the redesign. */
|
||||
@theme {
|
||||
--color-oxide-50: #FEF3EB;
|
||||
--color-oxide-100: #FDE3D0;
|
||||
@@ -15,7 +33,8 @@
|
||||
--color-oxide-950: #3D1506;
|
||||
}
|
||||
|
||||
/* Dark mode is default — Rust servers run at night */
|
||||
/* Legacy brand vars — retained during the redesign port so any view still
|
||||
referencing them keeps working; superseded by the ./styles tokens. */
|
||||
:root {
|
||||
--corrosion-accent: #F26622;
|
||||
--corrosion-dark: #000000;
|
||||
@@ -24,12 +43,8 @@
|
||||
--corrosion-border: #2a2a2a;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-neutral-950 text-neutral-100 antialiased;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Body background / text / font now come from the design system
|
||||
(./styles/tokens/base.css → var(--surface-canvas), var(--text-primary)). */
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
15
frontend/src/styles/corrosion.css
Normal file
15
frontend/src/styles/corrosion.css
Normal file
@@ -0,0 +1,15 @@
|
||||
/* ============================================================
|
||||
Corrosion Control — Design System
|
||||
Root stylesheet. Consumers link THIS one file.
|
||||
Import order matters: fonts → primitives → colors → game themes
|
||||
→ base. (base.css references color/accent tokens.)
|
||||
============================================================ */
|
||||
|
||||
@import url("tokens/fonts.css");
|
||||
@import url("tokens/spacing.css");
|
||||
@import url("tokens/typography.css");
|
||||
@import url("tokens/motion.css");
|
||||
@import url("tokens/colors.css");
|
||||
@import url("tokens/game-themes.css");
|
||||
@import url("tokens/elevation.css");
|
||||
@import url("tokens/base.css");
|
||||
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; }
|
||||
}
|
||||
75
frontend/src/styles/tokens/base.css
Normal file
75
frontend/src/styles/tokens/base.css
Normal file
@@ -0,0 +1,75 @@
|
||||
/* ============================================================
|
||||
Corrosion Control — Base / Reset
|
||||
Minimal, opinionated. Applies the token system to bare HTML so
|
||||
specimen cards and kits inherit the look without boilerplate.
|
||||
============================================================ */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--surface-canvas);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: "cv01", "ss01";
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, p, figure { margin: 0; }
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Tabular numbers everywhere numbers matter */
|
||||
.tnum { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Custom scrollbars — quiet, on-brand */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--text-muted); background-clip: content-box; }
|
||||
|
||||
/* Focus-visible default */
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* Reduced motion guard */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Skin-swap repaint guard ----
|
||||
When flipping data-game or data-theme on the root, add the .cc-skin-swap
|
||||
class for ONE frame. This suppresses transitions during the swap so every
|
||||
accent-consuming surface repaints immediately — works around a Chrome
|
||||
custom-property + transition staleness where elements that both read
|
||||
var(--accent)/var(--accent-text) AND have a color/background transition keep
|
||||
the old accent until the next reflow. See readme "Theming contract". */
|
||||
.cc-skin-swap *,
|
||||
.cc-skin-swap *::before,
|
||||
.cc-skin-swap *::after { transition: none !important; }
|
||||
136
frontend/src/styles/tokens/colors.css
Normal file
136
frontend/src/styles/tokens/colors.css
Normal file
@@ -0,0 +1,136 @@
|
||||
/* ============================================================
|
||||
Corrosion Control — Color System
|
||||
------------------------------------------------------------
|
||||
Three layers:
|
||||
1. Raw neutral ramp (absolute; identical in both themes)
|
||||
2. Semantic surface / text / border tokens (flip per theme)
|
||||
3. Status colors (online/offline/warn/info/...) — game-agnostic
|
||||
Game ACCENT colors live in game-themes.css.
|
||||
|
||||
Theme + game are set together on <html>:
|
||||
<html data-theme="dark" data-game="rust">
|
||||
Dark is primary. Light is full-parity.
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* ---- Raw neutral ramp (warm-cool slate, absolute) ---- */
|
||||
--n-0: #ffffff;
|
||||
--n-25: #fafbfc;
|
||||
--n-50: #f3f5f7;
|
||||
--n-100: #e8ebef;
|
||||
--n-150: #dce0e6;
|
||||
--n-200: #ccd2da;
|
||||
--n-300: #aeb6c1;
|
||||
--n-400: #8a929e;
|
||||
--n-500: #6b7280;
|
||||
--n-600: #515862;
|
||||
--n-650: #444a53;
|
||||
--n-700: #363b43;
|
||||
--n-750: #2b2f36;
|
||||
--n-800: #1f2329;
|
||||
--n-850: #181b20;
|
||||
--n-900: #121419;
|
||||
--n-925: #0e0f13;
|
||||
--n-950: #0a0b0e;
|
||||
--n-975: #060709;
|
||||
|
||||
/* ---- Semantic: DARK (default) ---- */
|
||||
--surface-canvas: var(--n-950); /* app background */
|
||||
--surface-sunken: var(--n-975); /* wells, deep insets */
|
||||
--surface-base: var(--n-925); /* primary panels */
|
||||
--surface-raised: var(--n-850); /* cards, rows */
|
||||
--surface-raised-2:var(--n-800); /* nested cards, hover cards */
|
||||
--surface-overlay: var(--n-800); /* menus, popovers, dialogs */
|
||||
--surface-inset: #07080a; /* inputs, console, code */
|
||||
--surface-hover: rgba(255, 255, 255, 0.045);
|
||||
--surface-active: rgba(255, 255, 255, 0.075);
|
||||
--surface-selected:rgba(255, 255, 255, 0.06);
|
||||
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--border-default: rgba(255, 255, 255, 0.10);
|
||||
--border-strong: rgba(255, 255, 255, 0.16);
|
||||
|
||||
--text-primary: #f2f4f7;
|
||||
--text-secondary: #aeb4bf;
|
||||
--text-tertiary: #767d89;
|
||||
--text-muted: #565d68;
|
||||
--text-inverse: #0a0b0e;
|
||||
--text-on-accent: var(--accent-contrast);
|
||||
|
||||
/* Scrim for modals / image overlays */
|
||||
--scrim: rgba(4, 5, 7, 0.66);
|
||||
|
||||
/* ---- Status / semantic (consistent across themes) ---- */
|
||||
--status-online: #36c780;
|
||||
--status-online-soft: rgba(54, 199, 128, 0.14);
|
||||
--status-online-border: rgba(54, 199, 128, 0.38);
|
||||
|
||||
--status-offline: #e5484d;
|
||||
--status-offline-soft: rgba(229, 72, 77, 0.14);
|
||||
--status-offline-border: rgba(229, 72, 77, 0.40);
|
||||
|
||||
--status-warn: #e8a33c;
|
||||
--status-warn-soft: rgba(232, 163, 60, 0.15);
|
||||
--status-warn-border: rgba(232, 163, 60, 0.40);
|
||||
|
||||
--status-info: #4c8df0;
|
||||
--status-info-soft: rgba(76, 141, 240, 0.15);
|
||||
--status-info-border: rgba(76, 141, 240, 0.40);
|
||||
|
||||
--status-starting: #2bc2d4; /* booting / updating / restarting */
|
||||
--status-starting-soft: rgba(43, 194, 212, 0.15);
|
||||
--status-starting-border: rgba(43, 194, 212, 0.40);
|
||||
|
||||
--status-wiping: #9b7bf0; /* Rust map wipe / maintenance */
|
||||
--status-wiping-soft: rgba(155, 123, 240, 0.16);
|
||||
--status-wiping-border: rgba(155, 123, 240, 0.40);
|
||||
|
||||
/* Aliases used by components */
|
||||
--success: var(--status-online);
|
||||
--danger: var(--status-offline);
|
||||
--warning: var(--status-warn);
|
||||
--info: var(--status-info);
|
||||
|
||||
/* Data-viz categorical (ECharts-friendly) */
|
||||
--viz-1: var(--accent);
|
||||
--viz-2: #4c8df0;
|
||||
--viz-3: #36c780;
|
||||
--viz-4: #9b7bf0;
|
||||
--viz-5: #2bc2d4;
|
||||
--viz-6: #e8a33c;
|
||||
--viz-grid: var(--border-subtle);
|
||||
}
|
||||
|
||||
/* ---- Semantic: LIGHT (full parity) ---- */
|
||||
[data-theme="light"] {
|
||||
--surface-canvas: #f5f6f8;
|
||||
--surface-sunken: #eceef1;
|
||||
--surface-base: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-raised-2:#fbfcfd;
|
||||
--surface-overlay: #ffffff;
|
||||
--surface-inset: #f1f3f5;
|
||||
--surface-hover: rgba(12, 16, 22, 0.04);
|
||||
--surface-active: rgba(12, 16, 22, 0.07);
|
||||
--surface-selected:rgba(12, 16, 22, 0.05);
|
||||
|
||||
--border-subtle: rgba(12, 16, 22, 0.07);
|
||||
--border-default: rgba(12, 16, 22, 0.12);
|
||||
--border-strong: rgba(12, 16, 22, 0.20);
|
||||
|
||||
--text-primary: #14171c;
|
||||
--text-secondary: #474e58;
|
||||
--text-tertiary: #6b727e;
|
||||
--text-muted: #969ca6;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
--scrim: rgba(16, 20, 28, 0.45);
|
||||
|
||||
/* Status soft fills need a touch more alpha on light */
|
||||
--status-online-soft: rgba(33, 160, 98, 0.12);
|
||||
--status-offline-soft: rgba(206, 44, 49, 0.10);
|
||||
--status-warn-soft: rgba(193, 124, 18, 0.13);
|
||||
--status-info-soft: rgba(43, 105, 214, 0.10);
|
||||
--status-starting-soft: rgba(18, 150, 168, 0.12);
|
||||
--status-wiping-soft: rgba(118, 86, 214, 0.12);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user