9 Commits

Author SHA1 Message Date
Vantz Stockwell
4abf0ab889 ci(host-agent): Rust agent build pipeline on agent-v* tags -> CDN alpha channel
Some checks failed
Build Host Agent (Rust) / build (push) Failing after 3s
Test Asgard Runner / test (push) Successful in 3s
Separate tag namespace from the Go pipeline (v*.*.*) per the
blast-radius doctrine; artifacts publish to /host-agent/alpha/ and a
versioned dir, leaving /host-agent/latest/ on the Go build until
cutover. Linux = static musl, Windows = mingw (msvc/cargo-xwin stays
the local release path). Tag-vs-Cargo.toml version gate included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:09:43 -04:00
Vantz Stockwell
cea3d66cdd feat(host-agent): Rust rewrite Phase 0 — multi-instance foundation, v2 wire protocol, real telemetry
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
New corrosion-host-agent/ crate (Go companion-agent stays as behavior
reference until parity). Wire protocol v2 per COA-B: instance-scoped
subjects corrosion.{license}.{instance}.* + host-level .host.* — spec
in PROTOCOL.md, designed for the license->host->instance fleet model.

- Multi-instance TOML config in the foundation, not retrofitted
- NATS layer on the Vigilance production profile (infinite reconnect,
  capped backoff, 30s ping, 8192-msg offline buffer)
- Heartbeat with real sysinfo telemetry — Go agent shipped hardcoded
  disk/cpu placeholders; this is the panel's first true Resources data
- Connectivity prober (outbound TCP, periodic + on-demand)
- Host cmd channel (ping/probe/sysinfo), going-offline beacon,
  CancellationToken shutdown
- Live-fire verified against production NATS; artifacts: 3.7MB static
  linux-musl, 3.8MB windows .exe (static CRT)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:02:46 -04:00
Vantz Stockwell
1abe57ca40 fix(marketing): strip dead Discord invite from footer; docs: Scout tier -> sonnet[1m]
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
discord.gg/corrosion is an Unknown Invite (verified via Discord API) -
no dead community promises on the marketing site. Re-add when a real
server exists. Discord *webhook* feature copy stays; that's shipped.

AGENTS.md Scout tier haiku -> sonnet[1m] confirmed by Commander:
marginal price difference, 1m context window pays for itself on recon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:28:06 -04:00
Vantz Stockwell
a8722a7a07 fix(audit): kill fake install cmds + dead demo CTA; production fonts; scoped error boundary; admin bootstrap seed
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Full-site fake-data audit findings:
- SetupWizard showed a curl|sh installer (get.corrosionmgmt.com) and a
  'corrosion-agent' binary that don't exist -> real host-agent commands
- 'View live demo' CTA on 5 marketing pages linked to a login, not a
  demo -> honest 'Sign in'
- Google Fonts @import was silently dropped from the production CSS
  bundle (mid-bundle @import) -> <link> tags in index.html; prod was
  shipping system fallback fonts
- App-root ErrorBoundary bricked the entire SPA (incl. marketing) on a
  single failed fetch until manual reload -> resets on route change +
  content-scoped boundary inside DashboardLayout so nav chrome survives
- Status page KPIs showed fake zeros while the fetch failed -> em dash
- Login lacked the forgot-password link (flow already existed end-to-end)
- AdminSeedService: fresh DB had schema but no login possible; seeds
  super-admin + license from ADMIN_* env when users table is empty

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:23:44 -04:00
Vantz Stockwell
180631989a fix(panel): real auto-updating version + remove fake agent footer; rename companion -> Corrosion host agent
All checks were successful
Build Host Agent / build (push) Successful in 28s
Test Asgard Runner / test (push) Successful in 3s
Version badge: was hardcoded '1.0.8' — now single-sourced from frontend/package.json (1.0.0) via Vite define __APP_VERSION__, so it auto-updates on release. Sidebar agent footer: removed the FABRICATED 'asgard-01' host name and the fake 'Agent v1.0.8' line — now shows real server.connection data, or an honest 'No host agent connected' empty state when nothing is deployed (the operator's actual state). Renamed 'Companion agent' -> 'Corrosion host agent' across the UI (ServerView/SetupWizard/Dashboard/Plugins), the binary names (corrosion-host-agent-<os>-<arch>) + CDN path (/host-agent/), the Go Makefile build output, and the Gitea CI workflow — frontend download links and CI output now match. Marketing hero mock host names neutralized (asgard-01 -> rust-host/dune-host/conan-host). DB column names (companion_last_seen) left intact. Build green; zero 'asgard'/'1.0.8' remain in frontend/src.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:03:37 -04:00
Vantz Stockwell
23decd9b08 feat(panel): per-game UI adaptation — sidebar, Server view, and dashboard transform by selected game
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Drives the panel off the active game (GameSwitcher selection) + the GameProfile registry, so each game visibly differs (not just accent color). Sidebar nav: Rust = full (uMod plugins + plugin configs); Conan/Soulmask/Dune drop uMod + plugin-configs and relabel reset (Wipe World / World Reset / Deep Desert), Dune relabels Console->Broadcast (no RCON) and is Docker-managed. ServerView: management-model badge + game-appropriate panels (Rust deploy + Oxide; Dune Docker/BattleGroup-Sietches; Conan clans/thralls/avatars/purge; Soulmask main-client cluster) with HONEST EmptyStates where no backend data exists yet. Dashboard: per-game reset terminology + stat labels. No invented routes (all map to existing router entries); no fabricated data. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:37:03 -04:00
Vantz Stockwell
8b84bba165 fix(docker): auto-build schema on a fresh DB via docker-entrypoint-initdb.d
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Root cause of 'data lost on every rebuild': nothing created the Postgres schema. TypeORM is synchronize:false, the API container runs no migration step, and there was no init mount — so a fresh pg_data volume came up with ZERO tables (empty/broken DB; the schema had only ever been loaded manually). Mount backend/migrations/*.sql into /docker-entrypoint-initdb.d so Postgres auto-applies the full schema (001..021, plain SQL) ON FIRST INIT ONLY. Existing volumes are untouched (initdb scripts run only on an empty data dir); a fresh volume now self-heals the schema. NOTE: actual row DATA still persists only while the pg_data named volume persists — 'docker compose down' keeps it across 'build --no-cache'; 'down -v' / volume prune is the only thing that wipes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:34:18 -04:00
Vantz Stockwell
9a5b93dd08 feat(api): early-access signup endpoint (POST /api/early-access)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Real @Public() NestJS endpoint persisting to the existing early_access_signups table (email + server_count), matching the schema exactly (no migration). Duplicate-email safe (pre-check + unique-constraint catch -> friendly success). Wired into app.module. Makes the marketing early-access form functional end-to-end on next API deploy. tsc/nest build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 05:09:34 -04:00
Vantz Stockwell
3545e6f5c8 feat(marketing): pricing, how-it-works, FAQ, roadmap, early-access pages (real content)
Five marketing sub-pages built to match the landing's design language, all real content: Pricing (4 real tiers + Fleet Block + commercial-use definition + feature-comparison table + self-service support model), How it works (one agent -> N game instances, BYOS, no-SSH), FAQ (real support/product/games/billing Q&A reflecting the self-service model), Roadmap (honest Shipped/In-progress/Planned, no fake dates), Early access (real signup form). 3 icons added (circle/send/help-circle). Visually verified via Playwright; 0 console errors. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 05:09:34 -04:00
52 changed files with 5868 additions and 849 deletions

View File

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

View File

@@ -0,0 +1,115 @@
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
- name: Install cross toolchains
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq musl-tools gcc-mingw-w64-x86-64
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

View File

@@ -38,7 +38,7 @@
### **TYPE 1: THE SCOUT (Intelligence)**
- **Model:** haiku
- **Model:** sonnet[1m]
- **Role:** Reconnaissance, Context Mapping, Log Analysis.

View File

@@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### 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:**

View File

@@ -44,6 +44,7 @@ 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';
@@ -123,6 +124,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
BetterChatModule,
TimedExecuteModule,
RaidableBasesModule,
EarlyAccessModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)

View 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}`;
}
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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
View File

@@ -0,0 +1 @@
/target

2100
corrosion-host-agent/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
[package]
name = "corrosion-host-agent"
version = "2.0.0-alpha.1"
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"
# 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

View File

@@ -0,0 +1,143 @@
# Corrosion Wire Protocol v2
Status: **Phase 0 implemented** (host heartbeat, host commands, going-offline
beacon). Per-instance command/status subjects are reserved and specified here
for Phase 1.
## 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).
Phase 0 instance `state` values: `configured` (root path exists),
`missing_root`. Phase 1 adds live process states: `running`, `stopped`,
`crashed`, `starting`, `updating`.
### `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 (Phase 1 — reserved, not yet implemented)
### `corrosion.{license_id}.{instance_id}.cmd` (backend → agent, request-reply)
Lifecycle and control for one game instance. Planned funcs: `start`, `stop`,
`restart`, `status`, `rcon` (process-class games), `steam_update`,
`oxide_install` (rust), plus game-adapter-specific commands (Dune: docker
lifecycle, RabbitMQ bus commands, Coriolis reset).
### `corrosion.{license_id}.{instance_id}.status` (agent → backend, publish)
State-change events (started/stopped/crashed) so the panel does not wait for
the next heartbeat.
### `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.

View File

@@ -0,0 +1,36 @@
# 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)
- [ ] Phase 1: process-class game adapter (spawn/RCON/SteamCMD/files) — Rust, Conan, Soulmask
- [ ] 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
```

View File

@@ -0,0 +1,39 @@
# 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"
# [[instance]]
# id = "soulmask-main"
# game = "soulmask"
# root = "/opt/soulmask/main"
# label = "Cloud Mist Forest (cluster main)"
[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

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

View File

@@ -0,0 +1,16 @@
//! Shared agent handle: every subsystem task holds an `Arc<Agent>`.
use std::time::Instant;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::config::Settings;
use crate::prober::ProbeReport;
pub struct Agent {
pub cfg: Settings,
pub nats: async_nats::Client,
pub started: Instant,
pub last_probe: RwLock<Option<ProbeReport>>,
pub shutdown: CancellationToken,
}

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

View File

@@ -0,0 +1,186 @@
//! 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};
/// 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>,
}
#[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(())
}

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

View File

@@ -0,0 +1,168 @@
//! 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).
mod agent;
mod bus;
mod config;
mod hostcmd;
mod prober;
mod subjects;
mod telemetry;
mod 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 agent = Arc::new(Agent {
cfg: settings,
nats,
started: Instant::now(),
last_probe: RwLock::new(None),
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:#}");
}
}));
}
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;
}
}

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

View File

@@ -0,0 +1,30 @@
//! 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")
}
/// Phase 1: per-instance command channel (start/stop/restart/rcon/...).
#[allow(dead_code)]
pub fn instance_cmd(license: &str, instance: &str) -> String {
format!("corrosion.{license}.{instance}.cmd")
}
/// Phase 1: per-instance state-change events.
#[allow(dead_code)]
pub fn instance_status(license: &str, instance: &str) -> String {
format!("corrosion.{license}.{instance}.status")
}

View File

@@ -0,0 +1,175 @@
//! 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>,
/// Phase 0 states: `configured` (root exists) or `missing_root`.
/// Phase 1 adds live process states (running/stopped/crashed).
pub state: String,
#[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 instances = agent
.cfg
.instances
.iter()
.map(|inst| {
let exists = inst.root.exists();
InstanceInfo {
id: inst.id.clone(),
game: inst.game.clone(),
label: inst.label.clone(),
state: if exists { "configured" } else { "missing_root" }.to_string(),
root_disk_free_mb: disk_free_for_path(&disks, &inst.root),
}
})
.collect();
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)
}

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

View File

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

View File

@@ -9,6 +9,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0a0a" />
<title>Corrosion Management</title>
<!-- 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. */

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",

1
frontend/src/app-version.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare const __APP_VERSION__: string

View File

@@ -1,8 +1,15 @@
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
import { ref, watch, onErrorCaptured } from 'vue'
import { useRoute } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue'
import 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('')
@@ -13,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 = ''
@@ -21,7 +34,7 @@ function retry() {
</script>
<template>
<div v-if="hasError" class="eb-screen">
<div v-if="hasError" class="eb-screen" :class="{ 'eb-screen--content': variant === 'content' }">
<div class="eb-card">
<div class="eb-icon-wrap">
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
@@ -44,6 +57,11 @@ function retry() {
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);

View File

@@ -25,6 +25,7 @@ import {
Pencil, Save, ShoppingBag, Target, User,
// Marketing site additions
Route, Timer, Megaphone, DatabaseBackup, Store, Undo2,
Circle, Send, HelpCircle,
} from 'lucide-vue-next'
const props = withDefaults(
@@ -63,6 +64,7 @@ const registry: Record<string, Component> = {
// 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)

View File

@@ -1,15 +1,20 @@
<script setup lang="ts">
/**
* DashboardLayout — game-aware app shell (Phase C redesign).
* Replaces the old Tailwind-only sidebar with the DS component set.
* Preserves: navSections, permission gating, super-admin section, logout, RouterView.
* Adds: GameSwitcher, Logo, DS NavItem, agent-health footer, topbar w/ search + theme toggle.
* 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 } from 'vue'
import { ref, computed } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useServerStore } from '@/stores/server'
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'
@@ -33,7 +38,7 @@ const sidebarOpen = ref(false)
function closeSidebar() { sidebarOpen.value = false }
// ---- App version ----
const APP_VERSION = '1.0.8'
const APP_VERSION = __APP_VERSION__
// ---- Game switcher ----
const GAME_OPTIONS: GameOption[] = [
@@ -53,61 +58,15 @@ function onActiveGame(val: string) {
setActiveGame(val as ActiveGame)
}
// ---- Navigation ----
type NavItemDef = { name: string; path: string; icon: string; permission: string | null }
type NavSection = { label: string; items: NavItemDef[] }
const navSections: NavSection[] = [
{
label: '',
items: [
{ name: 'Dashboard', path: '/', icon: 'layout-dashboard', permission: null },
],
},
{
label: 'Server',
items: [
{ name: 'Server', path: '/server', icon: 'server', permission: 'server.view' },
{ name: 'Console', path: '/console', icon: 'terminal', permission: 'console.view' },
{ name: 'Players', path: '/players', icon: 'users', permission: 'players.view' },
{ name: 'Plugins', path: '/plugins', icon: 'puzzle', permission: 'plugins.view' },
{ name: 'File manager', path: '/files', icon: 'folder-open', permission: 'files.view' },
],
},
{
label: 'Plugin configs',
items: [
{ name: 'Plugin configs', path: '/plugin-configs', icon: 'puzzle', permission: null },
],
},
{
label: 'Operations',
items: [
{ name: 'Wipe manager', path: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
{ name: 'Maps', path: '/maps', icon: 'map', permission: 'maps.view' },
{ name: 'Schedules', path: '/schedules', icon: 'calendar-clock', permission: 'schedules.view' },
],
},
{
label: 'Monitoring',
items: [
{ name: 'Chat log', path: '/chat', icon: 'message-square', permission: 'chat.view' },
{ name: 'Analytics', path: '/analytics', icon: 'bar-chart-3', permission: 'analytics.view' },
{ name: 'Alerts', path: '/alerts', icon: 'triangle-alert', permission: 'alerts.view' },
{ name: 'Notifications', path: '/notifications', icon: 'bell', permission: 'notifications.view' },
],
},
{
label: 'Management',
items: [
{ name: 'Team', path: '/team', icon: 'users', permission: null },
{ name: 'Store', path: '/store/config', icon: 'shopping-cart', permission: 'store.view' },
{ name: 'Modules', path: '/modules', icon: 'layers', permission: 'modules.view' },
{ name: 'Changelog', path: '/changelog', icon: 'file-text', permission: 'changelog.view' },
{ name: 'Settings', path: '/settings', icon: 'settings', permission: 'settings.view' },
],
},
]
// ---- 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' },
@@ -137,6 +96,8 @@ function hasVisibleItems(section: NavSection): boolean {
}
// ---- Agent health ----
const hasAgent = computed(() => server.connection !== null)
const agentTone = computed(() => {
const cs = server.connection?.connection_status
if (cs === 'connected') return 'online' as const
@@ -149,18 +110,23 @@ const agentLabel = computed(() => {
if (cs === 'degraded') return 'Degraded'
return 'Offline'
})
const agentName = computed(() => {
const ip = server.connection?.server_ip
return ip ?? 'asgard-01'
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')
// ---- Import computed from vue (missed above) ----
import { computed } from 'vue'
</script>
<template>
@@ -197,20 +163,20 @@ import { computed } from 'vue'
/>
</div>
<!-- Navigation -->
<!-- Navigation sections driven by GAME_PROFILES[activeGame].nav -->
<nav class="side__nav">
<template v-for="section in navSections" :key="section.label">
<template v-for="section in activeNavSections" :key="section.label">
<template v-if="hasVisibleItems(section)">
<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"
:key="item.route"
:icon="item.icon"
:label="item.name"
:active="isActive(item.path)"
@click="navigate(item.path)"
:label="item.label"
:active="isActive(item.route)"
@click="navigate(item.route)"
/>
</div>
</template>
@@ -230,18 +196,24 @@ import { computed } from 'vue'
</div>
</nav>
<!-- Agent health footer -->
<!-- Host agent footer -->
<div class="side__foot">
<div class="agent">
<!-- 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">
Agent v{{ APP_VERSION }}
<template v-if="server.stats"> · {{ server.stats.player_count }}/{{ server.stats.max_players }} players</template>
<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">
@@ -313,9 +285,11 @@ import { computed } from 'vue'
</div>
</header>
<!-- Page content -->
<!-- Page content boundary keeps sidebar/topbar alive when a view fails -->
<main class="app__content">
<RouterView />
<ErrorBoundary variant="content">
<RouterView />
</ErrorBoundary>
</main>
</div>
</div>
@@ -419,6 +393,13 @@ body { margin: 0; overflow: hidden; }
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;

View File

@@ -62,7 +62,6 @@ const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
<div class="footer__col">
<h5>Support</h5>
<RouterLink :to="{ name: 'faq' }">FAQ</RouterLink>
<a href="https://discord.gg/corrosion" target="_blank" rel="noopener">Discord</a>
<RouterLink to="/status">Status</RouterLink>
</div>
<div class="footer__col">

View File

@@ -2,9 +2,9 @@
* gameProfiles.ts — Source of truth for per-game UI adaptation.
*
* Every game-specific label, terminology, Steam app ID, management model,
* and stat field list lives here. The dashboard, server cards, wipe manager,
* and any future multi-game surface should key off this registry — never
* hard-code game-specific strings in components.
* 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
@@ -15,6 +15,26 @@
* 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
// ---------------------------------------------------------------------------
@@ -87,12 +107,67 @@ export interface GameProfile {
* 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',
@@ -109,6 +184,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
group: 'Team',
},
statFields: ['Players', 'uMod', 'Wipe'],
nav: RUST_NAV,
},
conan: {
@@ -130,6 +206,30 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
},
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: {
@@ -151,6 +251,29 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
},
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: {
@@ -170,6 +293,34 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
},
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

View File

@@ -4,13 +4,14 @@
JetBrains Mono — console, data, IDs, telemetry
Oxanium — brand wordmark + marketing display (game-ops flavor)
------------------------------------------------------------
NOTE: Loaded from Google Fonts CDN. If you want these self-
hosted (offline), send the woff2 files and these @imports
become @font-face rules.
NOTE: The Google Fonts stylesheet is loaded via <link> tags in
index.html — NOT @import here. A CSS @import that ends up
mid-bundle after concatenation is silently dropped by the
optimizer (fonts never load in production). If you want these
self-hosted (offline), send the woff2 files and they become
@font-face rules here.
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap');
:root {
--font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;

View File

@@ -25,6 +25,7 @@ import { useWipeStore } from '@/stores/wipe'
import { useApi } from '@/composables/useApi'
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
import { useGameProfile } from '@/config/gameProfiles'
import { useThemeGame } from '@/composables/useThemeGame'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import ConsoleLineDS from '@/components/ds/data/ConsoleLine.vue'
@@ -44,10 +45,14 @@ const server = useServerStore()
const wipeStore = useWipeStore()
const router = useRouter()
const api = useApi()
const { activeGame } = useThemeGame()
// Today every license is Rust. When the backend adds a `game` field to the
// license or server_config, pass it here: useGameProfile(server.config?.game ?? 'rust')
const profile = computed(() => useGameProfile('rust'))
// Profile follows the GameSwitcher selection. 'all' falls back to rust (neutral house skin).
// When the backend adds a `game` field on licenses, swap activeGame for server.config?.game.
const profile = computed(() => {
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
return useGameProfile(game)
})
// ---------------------------------------------------------------------------
// Derived server state — all real, no fallbacks to fabricated values
@@ -254,7 +259,7 @@ function navServer() { router.push('/server') }
<EmptyState
icon="server"
title="No server connected"
description="Install the companion agent on your host machine to begin managing your server from Corrosion."
description="Install the Corrosion host agent on your host machine to begin managing your server from Corrosion."
>
<template #action>
<Button icon="server" @click="navServer">Set up server</Button>
@@ -298,7 +303,7 @@ function navServer() { router.push('/server') }
<div class="dash__kpis">
<StatCard
icon="users"
:label="profile.statFields[0] + ' online'"
:label="(profile.statFields[0] ?? 'Players') + ' online'"
:value="soloPlayers !== null ? String(soloPlayers) : '—'"
:unit="soloMaxPlayers !== null ? '/' + soloMaxPlayers : ''"
note="live via agent"
@@ -399,7 +404,7 @@ function navServer() { router.push('/server') }
<div class="dash__col dash__col--side">
<!-- Resources real stats from agent; null = '—' -->
<Panel title="Resources" subtitle="Companion agent telemetry">
<Panel title="Resources" subtitle="Host agent telemetry">
<div class="solo-meters">
<ResourceMeter
label="CPU"
@@ -413,15 +418,15 @@ function navServer() { router.push('/server') }
/>
</div>
<div v-if="soloCpu === null && soloRamMb === null" class="meters-note">
Resource metrics arrive via the companion agent heartbeat.
Resource metrics arrive via the host agent heartbeat.
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
Agent setup
</Button>
</div>
</Panel>
<!-- Next wipe real schedule from wipeStore -->
<Panel title="Next wipe">
<!-- Next wipe/reset title follows game terminology -->
<Panel :title="'Next ' + profile.terminology.reset.toLowerCase()">
<div v-if="nextWipe" class="solo-wipe">
<div>
<div class="solo-wipe__type">{{ nextWipeType }}</div>
@@ -433,8 +438,8 @@ function navServer() { router.push('/server') }
<EmptyState
v-else
icon="calendar"
title="No wipe scheduled"
description="Configure automatic wipes in the wipe manager."
:title="'No ' + profile.terminology.reset.toLowerCase() + ' scheduled'"
:description="'Configure automatic ' + profile.terminology.reset.toLowerCase() + 's in the wipe manager.'"
>
<template #action>
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">

View File

@@ -485,7 +485,7 @@ onMounted(() => {
</Panel>
<Alert tone="info">
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
The plugin will be registered in your plugin list immediately. Your host agent must be connected
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
</Alert>
</div>

View File

@@ -3,6 +3,8 @@ import { ref, computed, onMounted } from 'vue'
import { useServerStore } from '@/stores/server'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import type { DeploymentConfig, DeploymentStatus } from '@/types'
import { useWebSocket } from '@/composables/useWebSocket'
import Panel from '@/components/ds/data/Panel.vue'
@@ -11,6 +13,7 @@ import Badge from '@/components/ds/core/Badge.vue'
import StatusDot from '@/components/ds/core/StatusDot.vue'
import Icon from '@/components/ds/core/Icon.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Input from '@/components/ds/forms/Input.vue'
import Switch from '@/components/ds/forms/Switch.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
@@ -18,6 +21,39 @@ import Tabs from '@/components/ds/navigation/Tabs.vue'
const server = useServerStore()
const auth = useAuthStore()
const toast = useToastStore()
const { activeGame } = useThemeGame()
// Profile follows the GameSwitcher. 'all' defaults to rust (neutral house skin).
const profile = computed(() => {
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
return useGameProfile(game)
})
// Game-specific derived flags
const isRust = computed(() => profile.value.mods === 'umod')
const hasPluginSystem = computed(() => profile.value.mods === 'umod')
const isDockerManaged = computed(() => profile.value.managementModel === 'docker-compose')
// Management model human label for the identity badge
const managementModelLabel = computed(() => {
const m = profile.value.managementModel
const c = profile.value.console
if (m === 'docker-compose') {
return profile.value.clustering === 'battlegroup' ? 'Docker · BattleGroup' : 'Docker · Compose'
}
if (c === 'rcon+ingame') return 'Process · RCON + In-game'
if (c === 'rcon+gm') return 'Process · RCON + GM'
return 'Process · RCON'
})
// Clustering section label per game
const clusterLabel = computed(() => {
const cl = profile.value.clustering
if (cl === 'battlegroup') return 'BattleGroups & Sietches'
if (cl === 'main-client') return 'Cluster'
if (cl === 'character-transfer') return 'Clans & Character Transfer'
return ''
})
const editMode = ref(false)
const saving = ref(false)
@@ -64,22 +100,22 @@ const agentLastSeenLabel = computed(() => {
const licenseKey = computed(() => auth.license?.license_key || 'YOUR-LICENSE-KEY')
const linuxCommands = computed(() => `# Download the agent
curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64
chmod +x corrosion-companion-linux-amd64
curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64
chmod +x corrosion-host-agent-linux-amd64
# Start with your license key
export LICENSE_ID="${licenseKey.value}"
export NATS_URL="nats://nats.corrosionmgmt.com:4222"
./corrosion-companion-linux-amd64`)
./corrosion-host-agent-linux-amd64`)
const windowsCommands = computed(() => `# Requires PowerShell (not Command Prompt)
# Download the agent
Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe" -OutFile "corrosion-companion-windows-amd64.exe"
Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe" -OutFile "corrosion-host-agent-windows-amd64.exe"
# Start with your license key
$env:LICENSE_ID="${licenseKey.value}"
$env:NATS_URL="nats://nats.corrosionmgmt.com:4222"
.\\corrosion-companion-windows-amd64.exe`)
.\\corrosion-host-agent-windows-amd64.exe`)
async function copySetupCommands() {
try {
@@ -278,17 +314,18 @@ onMounted(async () => {
<template>
<div class="sv">
<!-- Page head -->
<!-- Page head game-aware identity -->
<div class="sv__head">
<div class="sv__head-id">
<div class="sv__head-chip">
<Icon name="server" :size="20" :stroke-width="2" />
</div>
<div>
<div class="t-eyebrow">Server management</div>
<div class="t-eyebrow">{{ profile.label }} · Server management</div>
<h1 class="sv__title">Server</h1>
</div>
</div>
<Badge tone="neutral" :mono="true" class="sv__model-badge">{{ managementModelLabel }}</Badge>
</div>
<!-- Connection -->
@@ -350,8 +387,8 @@ onMounted(async () => {
</div>
</Panel>
<!-- Companion agent -->
<Panel title="Companion agent" subtitle="Bare-metal server management binary">
<!-- Host agent -->
<Panel title="Host agent" subtitle="Bare-metal server management binary">
<template #actions>
<Badge :tone="isAgentConnected ? 'online' : 'offline'" :dot="true" :pulse="isAgentConnected">
{{ isAgentConnected ? 'Active' : 'Inactive' }}
@@ -380,20 +417,20 @@ onMounted(async () => {
<!-- Download -->
<div class="sv__section-head">
<Icon name="download" :size="14" />
<span>Download companion agent</span>
<span>Download host agent</span>
</div>
<div class="sv__downloads sv__mb">
<a
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64"
download="corrosion-companion-linux-amd64"
href="https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64"
download="corrosion-host-agent-linux-amd64"
class="sv__dl-link"
>
<Icon name="download" :size="15" />
Linux (amd64)
</a>
<a
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"
download="corrosion-companion-windows-amd64.exe"
href="https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"
download="corrosion-host-agent-windows-amd64.exe"
class="sv__dl-link"
>
<Icon name="download" :size="15" />
@@ -424,28 +461,28 @@ onMounted(async () => {
<!-- Linux commands -->
<div v-if="setupTab === 'linux'" class="sv__codeblock">
<p class="sv__cmt"># Download the agent</p>
<p>curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64</p>
<p>chmod +x corrosion-companion-linux-amd64</p>
<p>curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
<p>chmod +x corrosion-host-agent-linux-amd64</p>
<p class="sv__cmt sv__mt">&#x23; Start with your license key</p>
<p>export LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
<p>export NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
<p>./corrosion-companion-linux-amd64</p>
<p>./corrosion-host-agent-linux-amd64</p>
</div>
<!-- Windows commands -->
<div v-if="setupTab === 'windows'" class="sv__codeblock">
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
<p class="sv__cmt"># Download the agent</p>
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-companion-windows-amd64.exe"</span></p>
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-host-agent-windows-amd64.exe"</span></p>
<p class="sv__cmt sv__mt">&#x23; Start with your license key</p>
<p>$env:LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
<p>$env:NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
<p>.\corrosion-companion-windows-amd64.exe</p>
<p>.\corrosion-host-agent-windows-amd64.exe</p>
</div>
</Panel>
<!-- Deploy Rust Server -->
<Panel title="Deploy Rust server" subtitle="One-click: SteamCMD, download, configure, start">
<!-- Deploy Server Rust only (SteamCMD path). Other games use docker-compose or external tooling. -->
<Panel v-if="isRust" title="Deploy Rust server" subtitle="One-click: SteamCMD, download, configure, start">
<template #title-append>
<Icon name="rocket" :size="15" />
</template>
@@ -560,8 +597,28 @@ onMounted(async () => {
</div>
</Panel>
<!-- Install Oxide / uMod -->
<Panel title="Install Oxide / uMod" subtitle="Required for all plugins including CorrosionCompanion">
<!-- Non-Rust: Docker-managed server note -->
<Panel
v-if="isDockerManaged"
:title="profile.label + ' server deployment'"
subtitle="Managed via Docker Compose"
>
<template #title-append>
<Icon name="box" :size="15" />
</template>
<EmptyState
icon="box"
title="Docker-managed deployment"
:description="profile.label + ' servers are managed via Docker Compose. Connect the host agent on your Docker host to enable lifecycle management.'"
>
<template #action>
<Badge tone="info">Docker · Compose</Badge>
</template>
</EmptyState>
</Panel>
<!-- Install Oxide / uMod — Rust only -->
<Panel v-if="hasPluginSystem" title="Install Oxide / uMod" subtitle="Required for all plugins including CorrosionCompanion">
<template #title-append>
<Icon name="puzzle" :size="15" />
</template>
@@ -611,6 +668,79 @@ onMounted(async () => {
</div>
</Panel>
<!-- Workshop Mods info — Conan / Soulmask (Steam Workshop, no install step needed) -->
<Panel
v-else-if="profile.mods === 'workshop'"
:title="(profile.terminology.mods ?? 'Workshop Mods')"
:subtitle="profile.label + ' uses Steam Workshop — no manual install step required'"
>
<template #title-append>
<Icon name="layers" :size="15" />
</template>
<EmptyState
icon="layers"
:title="profile.label + ' mod management'"
:description="profile.label + ' loads mods directly from Steam Workshop. Manage your mod list in server config — no Corrosion install step needed.'"
/>
</Panel>
<!-- Conan Exiles special concepts (Clans / Thralls / Purge) -->
<Panel
v-if="profile.accent === 'conan'"
title="Conan Exiles concepts"
subtitle="Key admin mechanics for Conan Exiles servers"
>
<div class="sv__concept-grid">
<div class="sv__concept">
<Icon name="users" :size="16" />
<div>
<div class="sv__concept-label">Clans</div>
<div class="sv__concept-desc">Player factions. Clan management via in-game admin panel or RCON.</div>
</div>
</div>
<div class="sv__concept">
<Icon name="zap" :size="16" />
<div>
<div class="sv__concept-label">Thralls &amp; Avatars</div>
<div class="sv__concept-desc">Server-controlled NPCs and deity summons. Purge cycle managed via server settings.</div>
</div>
</div>
<div class="sv__concept">
<Icon name="shield" :size="16" />
<div>
<div class="sv__concept-label">Purge</div>
<div class="sv__concept-desc">NPC raid events targeting player bases. Enable / tune via server config.</div>
</div>
</div>
</div>
</Panel>
<!-- Soulmask clustering section -->
<Panel
v-if="profile.clustering === 'main-client'"
:title="clusterLabel"
subtitle="Main-client cluster topology for Soulmask"
>
<EmptyState
icon="network"
title="Cluster management coming soon"
:description="'Connect a ' + profile.label + ' host to manage the main-client cluster from this panel. Cluster configuration requires the host agent.'"
/>
</Panel>
<!-- Dune BattleGroup / Sietches section -->
<Panel
v-if="profile.clustering === 'battlegroup'"
title="BattleGroups &amp; Sietches"
subtitle="Dune: Awakening server cluster topology"
>
<EmptyState
icon="map"
title="Sietch management requires a connected Dune host"
description="Connect the host agent on your Dune: Awakening Docker host to manage BattleGroups and Sietches from this panel."
/>
</Panel>
<!-- Configuration -->
<Panel title="Configuration">
<template #actions>
@@ -708,8 +838,13 @@ onMounted(async () => {
</div>
<div class="sv__toggle-row">
<div class="sv__toggle-body">
<div class="sv__toggle-label">Auto-update on force wipe</div>
<div class="sv__toggle-sub">Update when Facepunch pushes</div>
<div class="sv__toggle-label">
<!-- Rust: "force wipe" is a Facepunch concept. Others: plain "auto-update" -->
{{ isRust ? 'Auto-update on force wipe' : 'Auto-update on patch' }}
</div>
<div class="sv__toggle-sub">
{{ isRust ? 'Update when Facepunch pushes' : 'Update when the developer pushes a patch' }}
</div>
</div>
<Switch
:model-value="server.config?.auto_update_on_force_wipe ?? false"
@@ -717,7 +852,8 @@ onMounted(async () => {
@update:model-value="toggleAutomation('auto_update_on_force_wipe')"
/>
</div>
<div class="sv__toggle-row">
<!-- Rust-only: force wipe eligibility is a Facepunch concept -->
<div v-if="isRust" class="sv__toggle-row">
<div class="sv__toggle-body">
<div class="sv__toggle-label">Force wipe eligible</div>
<div class="sv__toggle-sub">Server participates in force wipes</div>
@@ -848,4 +984,19 @@ onMounted(async () => {
.sv__toggle-row:first-child { padding-top: 0; }
.sv__toggle-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.sv__toggle-sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
/* Management model badge in page head */
.sv__model-badge { align-self: center; }
/* Game concept cards (Conan Exiles special features) */
.sv__concept-grid { display: flex; flex-direction: column; gap: 14px; }
.sv__concept {
display: flex; align-items: flex-start; gap: 12px;
padding: 12px 14px;
background: var(--surface-raised); border-radius: var(--radius-md);
box-shadow: var(--ring-default);
color: var(--accent);
}
.sv__concept-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); margin-bottom: 2px; }
.sv__concept-desc { font-size: var(--text-xs); color: var(--text-tertiary); line-height: 1.5; }
</style>

View File

@@ -191,6 +191,8 @@ function handleBackToLogin() {
<p v-if="!showTotpInput" class="auth-footer">
No account?
<router-link to="/register" class="auth-footer__link">Create one</router-link>
·
<router-link to="/forgot-password" class="auth-footer__link">Forgot password?</router-link>
</p>
</div>
</div>

View File

@@ -35,7 +35,7 @@ function syncPorts() {
}
const connectionTypes = [
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via Companion Agent' },
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via Corrosion host agent' },
{ value: 'amp', label: 'AMP (CubeCoders)', desc: 'Connect through AMP panel API' },
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
]
@@ -183,7 +183,7 @@ async function completeSetup() {
</form>
</div>
<!-- Step 2: Companion agent install -->
<!-- Step 2: Corrosion host agent install -->
<div v-if="step === 2" class="setup-card">
<div class="setup-card__head setup-card__head--center">
<div class="setup-icon">
@@ -191,19 +191,22 @@ async function completeSetup() {
<path d="M5 12.55a11 11 0 0 1 14.08 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01" />
</svg>
</div>
<h1 class="setup-card__title">Install the Companion Agent</h1>
<h1 class="setup-card__title">Install the Corrosion host agent</h1>
<p class="setup-card__sub">The agent runs on your server and connects to Corrosion — no inbound ports required.</p>
</div>
<div class="setup-code">
<p class="setup-code__comment"># Download and install the Companion Agent</p>
<p class="setup-code__cmd">curl -sSL https://get.corrosionmgmt.com | sh</p>
<p class="setup-code__comment setup-code__comment--mt"># Start the agent with your license key</p>
<p class="setup-code__cmd">corrosion-agent start --key {{ auth.license?.license_key ?? 'YOUR-LICENSE-KEY' }}</p>
<p class="setup-code__comment"># Download the Corrosion host agent (Linux)</p>
<p class="setup-code__cmd">curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
<p class="setup-code__cmd">chmod +x corrosion-host-agent-linux-amd64</p>
<p class="setup-code__comment setup-code__comment--mt"># Start with your license key</p>
<p class="setup-code__cmd">export LICENSE_ID="{{ auth.license?.license_key ?? 'YOUR-LICENSE-KEY' }}"</p>
<p class="setup-code__cmd">export NATS_URL="nats://nats.corrosionmgmt.com:4222"</p>
<p class="setup-code__cmd">./corrosion-host-agent-linux-amd64</p>
</div>
<p class="setup-hint">
The agent auto-registers with your panel. You can also use the uMod plugin for lightweight integration.
On Windows, download the agent from the Server page after setup. The agent connects outbound and auto-registers with your panel.
</p>
<div class="setup-actions">
@@ -235,7 +238,7 @@ async function completeSetup() {
</svg>
</div>
<h1 class="setup-card__title">You're all set</h1>
<p class="setup-card__sub">Your server is configured. Head to the dashboard to start managing your Rust server.</p>
<p class="setup-card__sub">Your server is configured. Head to the dashboard to start managing your game server.</p>
<Button
type="button"
:loading="isLoading"

View File

@@ -1,16 +1,39 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Shield, Users, Star, MessageCircle, Clock, ChevronRight, Check, Zap, Terminal, RefreshCw, LayoutDashboard } from 'lucide-vue-next'
/**
* EarlyAccess signup page.
*
* Backend endpoint: POST /api/early-access
* The early_access_signups entity exists but no NestJS controller/module exposes it yet.
* TODO: Create backend-nest/src/modules/early-access/ with a @Public() POST /early-access
* controller that accepts { email, name?, game_interest? } and writes to early_access_signups.
* The server_count column on the entity is varchar(10) — map game_interest to it or add a
* migration adding a game_interest column.
*/
import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
// ---------- Email capture ----------
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
// Form state
const email = ref('')
const serverCount = ref('')
const name = ref('')
const gameInterest = ref('')
const submitting = ref(false)
const submitted = ref(false)
const errorMsg = ref('')
async function handleSubmit() {
if (!email.value || !serverCount.value) return
const GAME_OPTIONS = [
{ value: 'rust', label: 'Rust' },
{ value: 'dune', label: 'Dune: Awakening' },
{ value: 'conan', label: 'Conan Exiles' },
{ value: 'soulmask', label: 'Soulmask' },
{ value: 'multiple', label: 'Multiple games' },
]
async function handleSubmit(): Promise<void> {
if (!email.value) return
errorMsg.value = ''
submitting.value = true
try {
@@ -19,12 +42,13 @@ async function handleSubmit() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email.value,
server_count: serverCount.value,
// server_count column stores game interest (varchar 10) — no dedicated name column in DB
server_count: gameInterest.value || 'not specified',
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({ message: 'Something went wrong' }))
throw new Error(data.message || `HTTP ${res.status}`)
throw new Error((data as { message?: string }).message ?? `HTTP ${res.status}`)
}
submitted.value = true
} catch (err: unknown) {
@@ -34,291 +58,393 @@ async function handleSubmit() {
}
}
// ---------- Demo panels ----------
const panels = [
{ label: 'Dashboard', icon: LayoutDashboard, desc: 'Server overview, player count, uptime, and alerts at a glance.' },
{ label: 'Wipe Scheduler', icon: RefreshCw, desc: 'Visual wipe timeline with pre-wipe backup, map rotation, and health verification.' },
{ label: 'Plugin Config', icon: Zap, desc: 'Edit plugin settings from your browser. No JSON. No SFTP.' },
{ label: 'Player Management', icon: Users, desc: 'Online players, session tracking, kick/ban controls, and playtime history.' },
{ label: 'Console', icon: Terminal, desc: 'Real-time RCON console with timestamped, color-coded output.' },
]
// Scroll-reveal
let io: IntersectionObserver | null = null
// ---------- Roadmap voting ----------
interface VoteItem {
id: string
label: string
votes: number
voted: boolean
function initReveal(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in')
io?.unobserve(e.target)
}
})
},
{ threshold: 0.1 },
)
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
const voteItems = ref<VoteItem[]>([
{ id: 'analytics', label: 'Analytics & Retention Insights', votes: 47, voted: false },
{ id: 'webstore', label: 'Integrated Webstore', votes: 38, voted: false },
{ id: 'modules', label: 'Module Marketplace', votes: 31, voted: false },
{ id: 'discord', label: 'Discord Bot Integration', votes: 28, voted: false },
{ id: 'hosting', label: 'Hosting Provider API', votes: 19, voted: false },
])
function vote(item: VoteItem) {
if (item.voted) return
item.votes++
item.voted = true
}
const totalVotes = computed(() => voteItems.value.reduce((sum, i) => sum + i.votes, 0))
onMounted(() => { initReveal() })
onUnmounted(() => { io?.disconnect() })
</script>
<template>
<div>
<!-- Hero -->
<section class="relative overflow-hidden">
<div class="max-w-4xl mx-auto px-6 pt-20 pb-16 text-center">
<span class="inline-block px-4 py-1.5 bg-green-500/10 border border-green-500/20 rounded-full text-green-400 text-sm font-medium mb-6">
Early Access Is Now Open
</span>
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4 tracking-tight">
Wipe Night Just Got<br />
<span class="text-oxide-500">A Lot Easier.</span>
</h1>
<p class="text-lg text-neutral-400 max-w-xl mx-auto mb-10">
Corrosion is live in limited early access. Install once. Automate everything. Never SSH again.
</p>
<div class="flex items-center justify-center gap-4">
<a href="#join" class="px-8 py-3.5 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors">
Claim Your Spot
</a>
<a href="#demo" class="px-8 py-3.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-semibold rounded-lg border border-neutral-700 transition-colors">
View Demo Architecture
</a>
</div>
<!-- PAGE HEADER -->
<section class="hero" style="padding-bottom:0; border-bottom:none;">
<div class="hero__atmo" />
<div class="hero__grid" />
<div class="hero__grain" />
<div class="wrap hero__in" style="padding-bottom:52px;">
<div class="hero__mark">
<CorrosionMark :size="56" />
</div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-oxide-500/8 rounded-full blur-3xl pointer-events-none" />
</section>
<span class="eyebrow">Early access</span>
<h1 style="font-size:var(--text-5xl)">
Take control of your servers.
<span class="accent">Starting now.</span>
</h1>
<p class="hero__sub">
Corrosion is in early access. Join the list to be notified when your access opens.
No spam. No fabricated scarcity.
</p>
</div>
</section>
<!-- Early Access Live Banner -->
<section class="py-12 border-t border-neutral-800">
<div class="max-w-3xl mx-auto px-6 text-center">
<div class="inline-flex items-center gap-3 px-6 py-4 bg-green-500/10 border border-green-500/20 rounded-2xl">
<div class="w-2.5 h-2.5 bg-green-400 rounded-full animate-pulse shrink-0" />
<p class="text-green-300 font-semibold text-lg">Early Access is now live founding admin spots are limited.</p>
</div>
<p class="text-neutral-500 text-sm mt-4">
Sign up below to lock in founding pricing before spots run out.
<!-- WHAT YOU GET -->
<section class="sec" id="access">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">What early access means</span>
<h2 class="title">Real access to a real platform.</h2>
<p class="lead">
Early access is not a waitlist gimmick. It is how we manage onboarding while the
platform stabilizes. You get the full Corrosion control plane one tier at a time
as capacity opens.
</p>
</div>
</section>
<!-- What Early Access Means -->
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-4xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-8 text-center">What Early Access Means</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<Shield class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Limited Founding Licenses</p>
<p class="text-xs text-neutral-500 mt-1">2550 spots</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<MessageCircle class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Direct Founder Discord</p>
<p class="text-xs text-neutral-500 mt-1">Private channel access</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<Star class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Influence the Roadmap</p>
<p class="text-xs text-neutral-500 mt-1">Vote on features</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<Clock class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Lifetime Pricing Lock</p>
<p class="text-xs text-neutral-500 mt-1">Never pay more</p>
</div>
<div class="infra reveal">
<div class="icard">
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
<b>Full control plane</b>
<p>Agent, panel, wipes, console, plugins, schedules all of it. Not a trimmed preview.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
<b>Pricing you can lock in</b>
<p>Early access pricing is the live pricing. No bait-and-switch after launch.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="message-square" :size="16" /></div>
<b>Direct feedback channel</b>
<p>Early access operators have a direct line for platform bug reports and feature input.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="box" :size="16" /></div>
<b>Rust-first</b>
<p>Rust support is complete. Dune, Conan, and Soulmask are in active development.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="users" :size="16" /></div>
<b>RBAC team access</b>
<p>Add your admin team from day one. Fine-grained permission roles are built in.</p>
</div>
</div>
</section>
</div>
</section>
<!-- Email Capture -->
<section id="join" class="py-16 border-t border-neutral-800">
<div class="max-w-md mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">Claim Your Founding Spot</h2>
<p class="text-neutral-400 text-center mb-8">Early access is open now. Spots are limited lock in founding pricing today.</p>
<div v-if="submitted" class="bg-green-500/10 border border-green-500/20 rounded-xl p-8 text-center">
<Check class="w-10 h-10 text-green-400 mx-auto mb-3" />
<h3 class="text-lg font-semibold text-neutral-100 mb-1">You're in.</h3>
<p class="text-sm text-neutral-400">We'll be in touch shortly with your access details.</p>
<!-- SIGNUP FORM -->
<section class="sec" id="join">
<div class="wrap">
<div class="ea-form-wrap reveal">
<!-- Success state -->
<div v-if="submitted" class="ea-success">
<div class="ea-success__ic">
<Icon name="check" :size="28" />
</div>
<h2 class="ea-success__title">You are on the list.</h2>
<p class="ea-success__body">
We will reach out when your access slot opens. In the meantime, read the
<RouterLink :to="{ name: 'how-it-works' }" class="ea-link">how it works</RouterLink>
guide or review the
<RouterLink :to="{ name: 'faq' }" class="ea-link">FAQ</RouterLink>.
</p>
<RouterLink class="btn btn--ghost" :to="{ name: 'landing' }">
Back to home
</RouterLink>
</div>
<form v-else @submit.prevent="handleSubmit" class="space-y-4">
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
<!-- Form state -->
<form v-else @submit.prevent="handleSubmit" class="ea-form">
<div class="ea-form__head">
<h2>Join the early access list</h2>
<p>Required: email address. Everything else is optional but helps us prioritise.</p>
</div>
<!-- Error banner -->
<div v-if="errorMsg" class="ea-error">
<Icon name="triangle-alert" :size="15" />
{{ errorMsg }}
</div>
<div>
<label for="ea-email" class="block text-sm font-medium text-neutral-400 mb-1.5">Email</label>
<!-- Email (required) -->
<div class="ea-field">
<label class="ea-field__label" for="ea-email">
Email address <span class="ea-field__req">*</span>
</label>
<input
id="ea-email"
v-model="email"
type="email"
required
autocomplete="email"
placeholder="admin@example.com"
class="w-full px-3 py-2.5 bg-neutral-900 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
class="ea-input"
/>
</div>
<div>
<label for="ea-servers" class="block text-sm font-medium text-neutral-400 mb-1.5">How many servers do you run?</label>
<div class="grid grid-cols-3 gap-3">
<!-- Name (optional) -->
<div class="ea-field">
<label class="ea-field__label" for="ea-name">
Your name <span class="ea-field__optional">(optional)</span>
</label>
<input
id="ea-name"
v-model="name"
type="text"
autocomplete="name"
placeholder="Server admin name or handle"
class="ea-input"
/>
</div>
<!-- Game interest (optional) -->
<div class="ea-field">
<label class="ea-field__label">
Primary game interest <span class="ea-field__optional">(optional)</span>
</label>
<div class="ea-pills">
<button
v-for="option in ['1', '2-3', '4+']"
:key="option"
v-for="opt in GAME_OPTIONS"
:key="opt.value"
type="button"
@click="serverCount = option"
class="py-2.5 text-sm font-medium rounded-lg border transition-colors"
:class="serverCount === option
? 'bg-oxide-500/15 border-oxide-500/40 text-oxide-400'
: 'bg-neutral-900 border-neutral-700 text-neutral-400 hover:border-neutral-600'"
class="ea-pill"
:class="{ 'ea-pill--on': gameInterest === opt.value }"
@click="gameInterest = gameInterest === opt.value ? '' : opt.value"
>
{{ option }}
{{ opt.label }}
</button>
</div>
</div>
<button
type="submit"
:disabled="submitting || !email || !serverCount"
class="w-full py-3 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
class="btn btn--primary btn--lg ea-submit"
:disabled="submitting || !email"
>
{{ submitting ? 'Submitting...' : 'Join Early Access' }}
<Icon v-if="submitting" name="loader" :size="16" />
<Icon v-else name="send" :size="16" />
{{ submitting ? 'Submitting…' : 'Join early access' }}
</button>
<p class="ea-privacy">
We store your email to contact you when access opens. We do not sell or share it.
No newsletters unless you opt in separately.
</p>
</form>
</div>
</section>
</div>
</section>
<!-- Founding Admin Program -->
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-3xl mx-auto px-6 text-center">
<span class="inline-block px-3 py-1 bg-oxide-500/10 border border-oxide-500/20 rounded-full text-oxide-400 text-xs font-semibold uppercase tracking-wider mb-4">
Limited to 25 Servers
</span>
<h2 class="text-3xl font-bold text-neutral-100 mb-4">Founding Admin Program</h2>
<p class="text-neutral-400 mb-8">
The first 25 servers to run Corrosion receive:
</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<Star class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Founding Admin Role</p>
<p class="text-xs text-neutral-500 mt-1">Discord badge</p>
</div>
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<Clock class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Permanent Early Pricing</p>
<p class="text-xs text-neutral-500 mt-1">Locked forever</p>
</div>
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<Users class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Public Recognition</p>
<p class="text-xs text-neutral-500 mt-1">Featured server</p>
</div>
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<MessageCircle class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Direct Feature Input</p>
<p class="text-xs text-neutral-500 mt-1">Private channel</p>
</div>
<!-- HOW IT WORKS TEASER -->
<section class="sec" id="teaser">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">How it works</span>
<h2 class="title">Install the agent. Never SSH again.</h2>
</div>
<div class="steps reveal">
<div class="step">
<div class="step__n">1</div>
<b>Install the host agent</b>
<p>Download the Go binary from your dashboard. Run it on Windows or Linux. One agent per machine.</p>
</div>
<div class="step">
<div class="step__n">2</div>
<b>Agent connects to Corrosion</b>
<p>Single outbound NATS connection. No inbound ports. No exposed management panel on your machine.</p>
</div>
<div class="step">
<div class="step__n">3</div>
<b>Manage from the browser</b>
<p>Console, wipes, plugins, schedules, file manager, player management all at panel.corrosionmgmt.com.</p>
</div>
</div>
</section>
<!-- Demo Dashboard Preview -->
<section id="demo" class="py-16 border-t border-neutral-800">
<div class="max-w-5xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">Preview of Corrosion 1.0 Dashboard</h2>
<p class="text-neutral-500 text-center mb-10">Screenshots will replace these frames at launch.</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="panel in panels"
:key="panel.label"
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden hover:border-neutral-700 transition-colors"
>
<div class="h-36 bg-neutral-800/50 flex items-center justify-center border-b border-neutral-800">
<component :is="panel.icon" class="w-10 h-10 text-neutral-700" />
</div>
<div class="p-4">
<h3 class="text-sm font-semibold text-neutral-200 mb-1">{{ panel.label }}</h3>
<p class="text-xs text-neutral-500 leading-relaxed">{{ panel.desc }}</p>
</div>
</div>
</div>
<div class="closing reveal">
<RouterLink :to="{ name: 'how-it-works' }" class="btn btn--ghost btn--lg">
<Icon name="chevron-right" :size="17" />Read the full walkthrough
</RouterLink>
</div>
</section>
</div>
</section>
<!-- Roadmap Voting -->
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-2xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">What Should We Build Next?</h2>
<p class="text-neutral-500 text-center mb-8">Vote on the features that matter most to you.</p>
<div class="space-y-3">
<button
v-for="item in voteItems"
:key="item.id"
@click="vote(item)"
class="w-full flex items-center gap-4 p-4 bg-neutral-900 border rounded-xl transition-colors text-left"
:class="item.voted ? 'border-oxide-500/30' : 'border-neutral-800 hover:border-neutral-700'"
>
<div class="flex-1">
<p class="text-sm font-medium" :class="item.voted ? 'text-oxide-400' : 'text-neutral-200'">{{ item.label }}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<div class="w-24 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="item.voted ? 'bg-oxide-500' : 'bg-neutral-600'"
:style="{ width: `${totalVotes ? (item.votes / totalVotes) * 100 : 0}%` }"
/>
</div>
<span class="text-xs font-medium tabular-nums w-8 text-right" :class="item.voted ? 'text-oxide-400' : 'text-neutral-500'">
{{ item.votes }}
</span>
</div>
</button>
</div>
<!-- FINAL CTA -->
<section class="finalcta">
<div class="finalcta__atmo" />
<div class="wrap finalcta__in reveal">
<h2>Ready to stop babysitting<br>your servers?</h2>
<div class="cta-row">
<a href="#join" class="btn btn--primary btn--lg">
Sign up above
</a>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="key" :size="17" />Sign in
</a>
</div>
</section>
<!-- Timeline -->
<section class="py-16 border-t border-neutral-800">
<div class="max-w-2xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-8 text-center">Launch Timeline</h2>
<div class="space-y-4">
<div class="flex items-start gap-4">
<div class="w-8 h-8 bg-green-500/10 border border-green-500/20 rounded-full flex items-center justify-center shrink-0 mt-0.5">
<Check class="w-4 h-4 text-green-400" />
</div>
<div>
<p class="text-sm font-medium text-neutral-200">Week 1 Closed Beta Stabilization</p>
<p class="text-xs text-neutral-500 mt-0.5">Core platform hardening and testing.</p>
</div>
</div>
<div class="ml-4 w-px h-4 bg-neutral-800" />
<div class="flex items-start gap-4">
<div class="w-8 h-8 bg-green-500/10 border border-green-500/20 rounded-full flex items-center justify-center shrink-0 mt-0.5">
<Check class="w-4 h-4 text-green-400" />
</div>
<div>
<p class="text-sm font-medium text-neutral-200">Week 2 Early Access Open</p>
<p class="text-xs text-neutral-500 mt-0.5">Founding Admin licenses are live claim yours now.</p>
</div>
</div>
<div class="ml-4 w-px h-4 bg-neutral-800" />
<div class="flex items-start gap-4">
<div class="w-8 h-8 bg-neutral-800 border border-neutral-700 rounded-full flex items-center justify-center shrink-0 mt-0.5">
<ChevronRight class="w-4 h-4 text-neutral-500" />
</div>
<div>
<p class="text-sm font-medium text-neutral-400">Public Release</p>
<p class="text-xs text-neutral-500 mt-0.5">Shortly after early access stabilization.</p>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</section>
</template>
<style scoped>
/* Form wrapper */
.ea-form-wrap {
max-width: 520px;
margin: 0 auto;
}
/* Success state */
.ea-success {
text-align: center;
padding: 48px 32px;
background: var(--surface-base);
border-radius: var(--radius-xl);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.ea-success__ic {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-soft);
color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
margin: 0 auto 18px;
}
.ea-success__title {
font-family: var(--font-brand);
font-weight: 700;
font-size: var(--text-2xl);
margin: 0 0 10px;
}
.ea-success__body {
color: var(--text-tertiary);
font-size: var(--text-sm);
line-height: 1.6;
margin: 0 0 24px;
}
.ea-link {
color: var(--accent-text);
text-decoration: none;
font-weight: 600;
}
.ea-link:hover { text-decoration: underline; }
/* Form */
.ea-form {
background: var(--surface-base);
border-radius: var(--radius-xl);
box-shadow: var(--ring-default);
padding: 36px 32px;
display: flex;
flex-direction: column;
gap: 22px;
}
.ea-form__head h2 {
font-family: var(--font-brand);
font-weight: 700;
font-size: var(--text-2xl);
margin: 0 0 6px;
}
.ea-form__head p {
color: var(--text-tertiary);
font-size: var(--text-sm);
margin: 0;
}
/* Error banner */
.ea-error {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
background: color-mix(in srgb, var(--status-offline) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--status-offline) 25%, transparent);
border-radius: var(--radius-md);
color: var(--status-offline);
font-size: var(--text-sm);
}
/* Fields */
.ea-field { display: flex; flex-direction: column; gap: 7px; }
.ea-field__label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-secondary);
}
.ea-field__req { color: var(--status-offline); }
.ea-field__optional { color: var(--text-muted); font-weight: 400; }
.ea-input {
height: 42px;
padding: 0 13px;
background: var(--surface-inset);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: var(--text-sm);
font-family: var(--font-sans);
transition: border-color var(--dur-fast), box-shadow var(--dur-fast);
outline: none;
}
.ea-input::placeholder { color: var(--text-muted); }
.ea-input:focus {
border-color: var(--accent-border);
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Game pills */
.ea-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ea-pill {
height: 34px;
padding: 0 14px;
border-radius: var(--radius-pill);
background: var(--surface-raised);
box-shadow: var(--ring-default);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 600;
cursor: pointer;
border: none;
transition: var(--transition-colors);
}
.ea-pill--on {
background: var(--accent-soft);
color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
/* Submit */
.ea-submit {
width: 100%;
justify-content: center;
}
.ea-submit:disabled { opacity: 0.55; cursor: not-allowed; }
/* Privacy note */
.ea-privacy {
font-size: var(--text-xs);
color: var(--text-muted);
text-align: center;
line-height: 1.5;
margin: 0;
}
</style>

View File

@@ -1,101 +1,353 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
interface FaqItem {
question: string
answer: string
}
const faqs: FaqItem[] = [
interface FaqGroup {
label: string
icon: string
items: FaqItem[]
}
const groups: FaqGroup[] = [
{
question: 'Do I need to open any firewall ports?',
answer: 'No. All connections are outbound from your server to Corrosion\'s cloud. No inbound ports required.',
label: 'Support',
icon: 'help-circle',
items: [
{
question: 'Do you provide direct support?',
answer:
'Corrosion is a self-service tool. Every plan includes documentation, community forum access, diagnostics, and structured platform bug reports. We do not provide 1:1 setup assistance, Discord DMs, video calls, server administration, hosting-provider troubleshooting, firewall configuration, mod installation, or emergency wipe-day support.',
},
{
question: 'What if Corrosion itself is broken?',
answer:
'Platform bugs and agent issues go through structured bug reports in the panel. Operator and Network customers receive priority bug triage for platform-level issues. Direct server administration is not included in any support tier.',
},
{
question: 'Do you manage my server for me?',
answer:
'No. Corrosion provides the panel, the agent, automation workflows, diagnostics, and the control plane. You remain responsible for your host machine, operating system, network, firewall, game files, mods, and community rules.',
},
{
question: 'Is hands-on help available?',
answer:
'Yes — separately. Direct 1:1 support is available at $125/hour, prepaid in 1-hour blocks. This is billed time with a human, not a support tier. It is available to any customer who needs it.',
},
{
question: 'What does community support include?',
answer:
'Documentation (setup guides, architecture reference, troubleshooting walkthroughs), a community forum for operator-to-operator knowledge sharing, in-panel diagnostics (agent health, log access), and a structured bug report system for platform issues.',
},
],
},
{
question: 'Does Corrosion replace my hosting panel (AMP / Pterodactyl)?',
answer: 'No. Corrosion integrates with them via API or companion agent. Your existing panel remains intact.',
label: 'Product',
icon: 'server',
items: [
{
question: 'Do I need my own server?',
answer:
'Yes. Corrosion is bring-your-own-server. You supply the host machine — a VPS, dedicated server, or bare metal box running Windows or Linux. Corrosion provides the control plane, the agent, and the panel.',
},
{
question: 'Does Corrosion host my game server for me?',
answer:
'No. Corrosion is not a hosting provider. It is a management layer that runs on top of a server you already own or rent. If you need hosting, you need a separate hosting provider.',
},
{
question: 'Do I need to open inbound firewall ports for Corrosion?',
answer:
'No. The host agent establishes a single outbound NATS connection to Corrosion\'s cloud. No inbound management ports are required. Your game server\'s player ports (RCON, game ports) remain as they have always been.',
},
{
question: 'Does Corrosion replace AMP or Pterodactyl?',
answer:
'It can, depending on your use case. Corrosion is an independent control plane — it does not require an existing game panel. If you are currently using AMP or Pterodactyl, migration tooling is available in the panel.',
},
{
question: 'What happens if Corrosion goes offline?',
answer:
'Your game servers continue running normally. Corrosion does not proxy gameplay traffic — it only handles management operations. If the panel or cloud is unreachable, your players are unaffected.',
},
{
question: 'Can multiple admins manage the same server?',
answer:
'Yes. Role-based access control (RBAC) is built in. You can grant team members specific permissions — from full admin access down to read-only viewer — without sharing credentials.',
},
{
question: 'What OS does the agent run on?',
answer:
'Both Windows and Linux are supported for the host agent. The agent binary is downloaded directly from your dashboard — there is no manual build or dependency setup required.',
},
{
question: 'Is my data isolated from other customers?',
answer:
'Yes. All data is scoped by license ID at the database level. No server, config, or player data is shared across tenant boundaries.',
},
],
},
{
question: 'What happens if Corrosion goes offline?',
answer: 'Your Rust server continues running normally. Corrosion does not proxy gameplay traffic.',
label: 'Games',
icon: 'box',
items: [
{
question: 'Which games are supported?',
answer:
'Currently in active development or shipped: Rust, Dune: Awakening, Conan Exiles, and Soulmask. Each game has a purpose-built blueprint — not a generic template — that models its specific operational reality (wipe types, mod systems, cluster configurations). More games are planned.',
},
{
question: 'Does Corrosion support Rust plugin management?',
answer:
'Yes. Corrosion integrates with uMod (Oxide) for Rust. You can browse the plugin registry, install plugins, manage configuration profiles, and push config changes to the server — all from the browser.',
},
{
question: 'Can I run multiple game types on the same host machine?',
answer:
'Yes. A single host agent can supervise multiple game server processes — across different games — on the same machine. Each game instance has its own lifecycle, configuration, and wipe schedule.',
},
{
question: 'Does Corrosion handle Rust wipes?',
answer:
'Yes. Rust wipes are a first-class feature: map wipes, blueprint wipes, and full wipes. Wipes run as verified, logged sequences — pre-warning, backup, stop, update, map rotation, restart, health check, announce. Rollback is available when supported.',
},
],
},
{
question: 'Is my data shared with other servers?',
answer: 'No. All data is isolated by license ID. Multi-tenant database queries are scoped per license.',
},
{
question: 'What if a wipe fails?',
answer: 'Corrosion can automatically retry and optionally roll back using the pre-wipe backup.',
},
{
question: 'Does this work on bare metal?',
answer: 'Yes. Use the Companion Agent — no SSH required after initial setup.',
},
{
question: 'Can I manage multiple admins?',
answer: 'Yes. Multi-Admin Role-Based Access Control is built in. Grant granular permissions per team member.',
},
{
question: 'Is this beginner friendly?',
answer: 'Yes. If you can install a uMod plugin, you can use Corrosion.',
},
{
question: 'Does this replace Tebex?',
answer: 'Corrosion includes an optional integrated store (Phase 5 roadmap), but does not require Tebex.',
},
{
question: 'How is licensing handled?',
answer: 'One license per server. License validation occurs on plugin startup and periodically.',
label: 'Billing',
icon: 'credit-card',
items: [
{
question: 'What counts as commercial use?',
answer:
'Commercial use includes monetized communities, paid access, VIP slots, donation-funded servers, sponsorship-supported servers, hosting providers, or managing servers for others. Hobby and Community plans are non-commercial only. Operator and Network plans permit commercial use.',
},
{
question: 'What is the Fleet Block on the Network plan?',
answer:
'The Network plan base includes 50 server instances at $99.99/mo. Each additional Fleet Block adds 50 more server slots at $49.99/mo. Stack as many Fleet Blocks as your operation requires.',
},
{
question: 'Can I upgrade my plan?',
answer:
'Yes. You can upgrade at any time. Pricing is prorated from the upgrade date.',
},
{
question: 'Is there a free trial?',
answer:
'Corrosion is currently in early access. Join the early access list to be notified when access opens.',
},
{
question: 'Are there annual billing discounts?',
answer:
'Not at this time. All plans are billed monthly.',
},
],
},
]
const openIndex = ref<number | null>(null)
const openKey = ref<string | null>(null)
function toggle(index: number) {
openIndex.value = openIndex.value === index ? null : index
function toggle(key: string): void {
openKey.value = openKey.value === key ? null : key
}
function itemKey(groupLabel: string, idx: number): string {
return `${groupLabel}-${idx}`
}
// Scroll-reveal
let io: IntersectionObserver | null = null
function initReveal(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in')
io?.unobserve(e.target)
}
})
},
{ threshold: 0.1 },
)
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
onMounted(() => { initReveal() })
onUnmounted(() => { io?.disconnect() })
</script>
<template>
<div>
<!-- Header -->
<section class="pt-20 pb-12">
<div class="max-w-4xl mx-auto px-6 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">Frequently Asked Questions</h1>
<p class="text-lg text-neutral-400">Everything you need to know about Corrosion.</p>
<!-- PAGE HEADER -->
<section class="hero" style="padding-bottom:0; border-bottom:none;">
<div class="hero__atmo" />
<div class="hero__grid" />
<div class="hero__grain" />
<div class="wrap hero__in" style="padding-bottom:52px;">
<div class="hero__mark">
<CorrosionMark :size="56" />
</div>
</section>
<span class="eyebrow">FAQ</span>
<h1 style="font-size:var(--text-5xl)">
Honest answers.
<span class="accent">No marketing fluff.</span>
</h1>
<p class="hero__sub">
Common questions about support, the product, supported games, and billing answered
plainly.
</p>
</div>
</section>
<!-- FAQ Accordion -->
<section class="pb-20">
<div class="max-w-3xl mx-auto px-6">
<div class="space-y-3">
<!-- FAQ GROUPS -->
<section class="sec" id="faq">
<div class="wrap">
<div
v-for="group in groups"
:key="group.label"
class="faq-group reveal"
>
<div class="faq-group__head">
<span class="faq-group__ic">
<Icon :name="group.icon" :size="16" />
</span>
<span class="eyebrow">{{ group.label }}</span>
</div>
<div class="faq-list">
<div
v-for="(faq, index) in faqs"
:key="index"
class="bg-neutral-900 border rounded-xl overflow-hidden transition-colors"
:class="openIndex === index ? 'border-oxide-500/30' : 'border-neutral-800'"
v-for="(item, idx) in group.items"
:key="idx"
class="faq-item"
:class="{ 'faq-item--open': openKey === itemKey(group.label, idx) }"
>
<button
@click="toggle(index)"
class="w-full flex items-center justify-between p-6 text-left"
class="faq-item__q"
@click="toggle(itemKey(group.label, idx))"
>
<span class="text-neutral-100 font-medium pr-4">{{ faq.question }}</span>
<ChevronDown
class="w-5 h-5 text-neutral-500 shrink-0 transition-transform duration-200"
:class="{ 'rotate-180': openIndex === index }"
/>
<span>{{ item.question }}</span>
<span class="faq-item__chevron">
<Icon
name="chevron-down"
:size="16"
:class="{ 'faq-item__chevron--open': openKey === itemKey(group.label, idx) }"
/>
</span>
</button>
<div
v-if="openIndex === index"
class="px-6 pb-6 -mt-2"
v-if="openKey === itemKey(group.label, idx)"
class="faq-item__a"
>
<p class="text-neutral-400 leading-relaxed">{{ faq.answer }}</p>
{{ item.answer }}
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</section>
<!-- SUPPORT CTA -->
<section class="sec" id="support-cta" style="border-bottom:none">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Still have questions?</span>
<h2 class="title">Check the docs or join the community</h2>
<p class="lead">
The documentation covers setup, architecture, troubleshooting, and every supported
game. The community forum is where operators share configs, ask questions, and help
each other.
</p>
</div>
<div class="hero__cta reveal">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
Join early access
</RouterLink>
<RouterLink class="btn btn--ghost btn--lg" :to="{ name: 'pricing' }">
<Icon name="credit-card" :size="17" />View pricing
</RouterLink>
</div>
</div>
</section>
</template>
<style scoped>
.faq-group {
margin-bottom: 48px;
}
.faq-group__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.faq-group__ic {
width: 28px;
height: 28px;
flex: none;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-text);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.faq-list {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 860px;
margin: 0 auto;
}
.faq-item {
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--ring-default);
overflow: hidden;
transition: box-shadow var(--dur-fast);
}
.faq-item--open {
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.faq-item__q {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
transition: color var(--dur-fast);
}
.faq-item__q:hover { color: var(--accent-text); }
.faq-item__chevron {
flex: none;
color: var(--text-muted);
transition: color var(--dur-fast);
}
.faq-item__chevron--open {
transform: rotate(180deg);
color: var(--accent-text);
}
.faq-item__a {
padding: 0 20px 18px;
font-size: var(--text-sm);
color: var(--text-tertiary);
line-height: 1.65;
}
</style>

View File

@@ -1,150 +1,358 @@
<script setup lang="ts">
import { Download, Globe, Wifi, LayoutDashboard, ArrowDown } from 'lucide-vue-next'
import { onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
// Scroll-reveal
let io: IntersectionObserver | null = null
function initReveal(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in')
io?.unobserve(e.target)
}
})
},
{ threshold: 0.1 },
)
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
onMounted(() => { initReveal() })
onUnmounted(() => { io?.disconnect() })
</script>
<template>
<div>
<!-- Header -->
<section class="pt-20 pb-12">
<div class="max-w-4xl mx-auto px-6 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">How Corrosion Works</h1>
<p class="text-lg text-neutral-400">
Corrosion connects your Rust server to a hosted control plane securely, outbound-only.
<!-- PAGE HEADER -->
<section class="hero" style="padding-bottom:0; border-bottom:none;">
<div class="hero__atmo" />
<div class="hero__grid" />
<div class="hero__grain" />
<div class="wrap hero__in" style="padding-bottom:52px;">
<div class="hero__mark">
<CorrosionMark :size="56" />
</div>
<span class="eyebrow">How it works</span>
<h1 style="font-size:var(--text-5xl)">
One agent.
<span class="accent">Every game. No SSH.</span>
</h1>
<p class="hero__sub">
Install the host agent once on your Windows or Linux machine. Corrosion connects
securely, outbound-only. You manage every game instance from the browser.
</p>
</div>
</section>
<!-- THE MODEL: 3-STEP OVERVIEW -->
<section class="sec" id="model">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">The agent model</span>
<h2 class="title">Bring your own server.<br>We provide the control plane.</h2>
<p class="lead">
Corrosion is not a hosting provider. You supply the hardware or the VPS. The host
agent runs on that machine and bridges your game instances to Corrosion's control
plane — securely, without opening inbound firewall ports.
</p>
</div>
</section>
<div class="steps reveal">
<div class="step">
<div class="step__n">1</div>
<b>Install the host agent</b>
<p>
Download the Corrosion agent binary from your dashboard. Run it on any Windows
or Linux host. One agent per machine — it manages every game instance you assign
to it.
</p>
</div>
<div class="step">
<div class="step__n">2</div>
<b>It connects to Corrosion</b>
<p>
The agent makes a single outbound NATS connection to Corrosion's cloud. No
inbound ports. No open panels. No SSH required after initial setup.
</p>
</div>
<div class="step">
<div class="step__n">3</div>
<b>Deploy and manage from the browser</b>
<p>
Create game instances, run wipes, manage plugins, schedule maintenance, and
monitor players all from the Corrosion panel at panel.corrosionmgmt.com.
</p>
</div>
</div>
<div class="nots reveal">
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No open inbound ports</span>
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No permanent SSH sessions</span>
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No config file spelunking</span>
<span><Icon name="check" :size="16" style="color:var(--status-online)" />Windows and Linux supported</span>
</div>
</div>
</section>
<!-- Steps -->
<section class="pb-20">
<div class="max-w-3xl mx-auto px-6">
<div class="space-y-2">
<!-- Step 1 -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
<div class="flex items-start gap-6">
<div class="w-12 h-12 bg-oxide-500/10 border border-oxide-500/20 rounded-xl flex items-center justify-center shrink-0">
<Download class="w-6 h-6 text-oxide-500" />
</div>
<div>
<div class="flex items-center gap-3 mb-2">
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 1</span>
</div>
<h3 class="text-xl font-bold text-neutral-100 mb-2">Install the Plugin</h3>
<p class="text-neutral-400">
Drop the Corrosion plugin into <code class="px-2 py-0.5 bg-neutral-800 rounded text-oxide-300 text-sm">oxide/plugins</code>.
That's it. No dependencies, no config files to create.
</p>
</div>
<!-- MULTI-GAME RUNTIME -->
<section class="sec" id="multi-game">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Multi-game host runtime</span>
<h2 class="title">One agent. Multiple game worlds<br>on the same machine.</h2>
<p class="lead">
The host agent is not a per-game process. It is a general-purpose ops runtime. One
agent on a single machine can supervise multiple game server processes across
different games each with its own configuration, lifecycle, and wipe schedule.
</p>
</div>
<div class="blueprints reveal">
<div class="bp" data-game="rust">
<div class="bp__head">
<span class="bp__ic"><Icon name="box" :size="21" /></span>
<div>
<div class="bp__name">Rust</div>
<div class="bp__accent">Oxide Orange</div>
</div>
</div>
<div class="flex justify-center py-1">
<ArrowDown class="w-5 h-5 text-neutral-700" />
<div class="bp__role">uMod / Oxide plugin ecosystem</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Map / BP / full wipe scheduling</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Plugin config profiles from the browser</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Wipe-day backup before every change</div>
</div>
<!-- Step 2 -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
<div class="flex items-start gap-6">
<div class="w-12 h-12 bg-oxide-500/10 border border-oxide-500/20 rounded-xl flex items-center justify-center shrink-0">
<Globe class="w-6 h-6 text-oxide-500" />
</div>
<div>
<div class="flex items-center gap-3 mb-2">
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 2</span>
</div>
<h3 class="text-xl font-bold text-neutral-100 mb-2">Register Online</h3>
<p class="text-neutral-400">
Activate your license and configure your server. Set your hostname, game port, and admin preferences.
</p>
</div>
</div>
<div class="bp" data-game="dune">
<div class="bp__head">
<span class="bp__ic"><Icon name="sun" :size="21" /></span>
<div>
<div class="bp__name">Dune: Awakening</div>
<div class="bp__accent">Spice Amber</div>
</div>
</div>
<div class="flex justify-center py-1">
<ArrowDown class="w-5 h-5 text-neutral-700" />
<div class="bp__role">Battlegroup orchestration</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Deep Desert wipe scheduling</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Procedural map regeneration</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Battlegroup lifecycle controls</div>
</div>
<!-- Step 3 -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
<div class="flex items-start gap-6">
<div class="w-12 h-12 bg-oxide-500/10 border border-oxide-500/20 rounded-xl flex items-center justify-center shrink-0">
<Wifi class="w-6 h-6 text-oxide-500" />
</div>
<div>
<div class="flex items-center gap-3 mb-2">
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 3</span>
</div>
<h3 class="text-xl font-bold text-neutral-100 mb-2">Secure Outbound Connection</h3>
<p class="text-neutral-400">
Your server connects securely to Corrosion's NATS cluster. No inbound firewall rules required. Your server initiates all connections.
</p>
</div>
</div>
<div class="bp" data-game="soulmask">
<div class="bp__head">
<span class="bp__ic"><Icon name="drama" :size="21" /></span>
<div>
<div class="bp__name">Soulmask</div>
<div class="bp__accent">Ritual Jade</div>
</div>
</div>
<div class="flex justify-center py-1">
<ArrowDown class="w-5 h-5 text-neutral-700" />
<div class="bp__role">Linked-world cluster deployment</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />One-click linked-server clusters</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Port and config automation</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Cluster health monitoring</div>
</div>
</div>
<div class="bp" data-game="conan">
<div class="bp__head">
<span class="bp__ic"><Icon name="swords" :size="21" /></span>
<div>
<div class="bp__name">Conan Exiles</div>
<div class="bp__accent">Hyborian Bronze</div>
</div>
</div>
<div class="bp__role">Persistent world management</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Mod management + world backups</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Purge, decay, and event tracking</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Scheduled maintenance workflows</div>
</div>
</div>
</div>
</div>
</section>
<!-- Step 4 -->
<div class="bg-neutral-900 border border-oxide-500/30 rounded-xl p-8">
<div class="flex items-start gap-6">
<div class="w-12 h-12 bg-oxide-500/15 border border-oxide-500/30 rounded-xl flex items-center justify-center shrink-0">
<LayoutDashboard class="w-6 h-6 text-oxide-400" />
</div>
<div>
<div class="flex items-center gap-3 mb-2">
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 4</span>
</div>
<h3 class="text-xl font-bold text-neutral-100 mb-2">Full Orchestration</h3>
<p class="text-neutral-400 mb-4">From the dashboard, you can:</p>
<ul class="space-y-2">
<li class="text-neutral-300 text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
Execute console commands
</li>
<li class="text-neutral-300 text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
Configure plugins
</li>
<li class="text-neutral-300 text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
Schedule wipes
</li>
<li class="text-neutral-300 text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
Monitor performance
</li>
<li class="text-neutral-300 text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
Automate Steam updates
</li>
</ul>
</div>
<!-- WIPE AND MAINTENANCE AUTOMATION -->
<section class="sec" id="automation">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Wipe and maintenance automation</span>
<h2 class="title">Self-service workflows,<br>not manual processes</h2>
<p class="lead">
Every wipe, update, and maintenance window runs as a verified, logged sequence.
Pre-warning announcements, pre-wipe backups, health checks after restart, and
rollback capability when things go wrong.
</p>
</div>
<div class="pipe reveal">
<span class="pchip">Pre-warning</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Backup</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Stop services</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Update / rotate</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Restart</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Health check</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip pchip--last">Announce complete</span>
</div>
<div class="stack-lines reveal">
<span>Every operation is logged. Every step is verified.</span>
<span class="hi">Rollback is one click away when supported.</span>
</div>
</div>
</section>
<!-- ARCHITECTURE: HOW CONNECTIVITY WORKS -->
<section class="sec" id="connectivity">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Connectivity model</span>
<h2 class="title">Outbound-only. No exposed panel.</h2>
<p class="lead">
The host agent establishes one secure NATS connection to Corrosion's cloud. All
commands flow through that channel. Your machine never needs to accept inbound
connections from the internet.
</p>
</div>
<div class="infra reveal">
<div class="icard">
<div class="icard__ic"><Icon name="server" :size="16" /></div>
<b>Your host machine</b>
<p>Windows or Linux. Bare metal, VPS, or dedicated. You own it and run it.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
<b>Corrosion agent</b>
<p>A single Go binary. Runs as a service. Manages game processes, files, and updates.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
<b>Outbound NATS channel</b>
<p>One secure, outbound-only connection. No open ports. No SSH tunnels.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
<b>Corrosion cloud</b>
<p>Hosted control plane. Multi-tenant isolation. Every command is license-scoped.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="layout-dashboard" :size="16" /></div>
<b>Your browser</b>
<p>The panel at panel.corrosionmgmt.com. Console, wipes, plugins, schedules — all here.</p>
</div>
</div>
<div class="techrow reveal">
<span>Go host agent</span>
<span>NATS JetStream</span>
<span>NestJS API</span>
<span>PostgreSQL</span>
<span>Outbound-only connectivity</span>
</div>
</div>
</section>
<!-- HOST REQUIREMENTS -->
<section class="sec" id="requirements">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Host requirements</span>
<h2 class="title">What you need to get started</h2>
</div>
<div class="caps reveal" style="max-width:760px">
<div class="caps__col">
<span class="eyebrow">Your machine</span>
<div class="feat">
<span class="feat__ic"><Icon name="server" :size="16" /></span>
<div>
<b>Windows or Linux host</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
VPS, dedicated server, or bare metal. You supply it; Corrosion manages it.
</p>
</div>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="hard-drive" :size="16" /></span>
<div>
<b>Enough CPU and RAM for your game</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Corrosion's agent is lightweight. Your game server determines the actual
hardware requirement.
</p>
</div>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="wifi" :size="16" /></span>
<div>
<b>Outbound internet access</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
The agent connects out; your game server's player ports stay open as they
always have been.
</p>
</div>
</div>
</div>
<div class="caps__col">
<span class="eyebrow">From Corrosion</span>
<div class="feat">
<span class="feat__ic"><Icon name="download" :size="16" /></span>
<div>
<b>Agent binary (Windows or Linux)</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Downloaded from your dashboard. No manual build. No dependency management.
</p>
</div>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="key" :size="16" /></span>
<div>
<b>Your license key</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Issued when you register. The agent uses it to authenticate to the cloud.
</p>
</div>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="globe" :size="16" /></span>
<div>
<b>The panel</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Everything else — console, wipes, schedules, players — lives at
panel.corrosionmgmt.com.
</p>
</div>
</div>
</div>
</div>
</section>
</div>
</section>
<!-- Architecture Diagram -->
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-3xl mx-auto px-6 text-center">
<h2 class="text-2xl font-bold text-neutral-100 mb-10">Architecture Overview</h2>
<div class="space-y-3">
<div class="inline-block px-6 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-medium">Rust Server</div>
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
<div class="inline-block px-6 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-medium">Corrosion Plugin</div>
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
<div class="inline-block px-6 py-3 bg-oxide-500/10 border border-oxide-500/30 rounded-lg text-oxide-400 font-medium">Secure NATS Messaging</div>
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
<div class="inline-block px-6 py-3 bg-oxide-500/10 border border-oxide-500/30 rounded-lg text-oxide-400 font-medium">Corrosion Cloud</div>
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
<div class="inline-block px-6 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-medium">Dashboard + Store + Analytics</div>
</div>
<p class="text-neutral-500 mt-10">
Corrosion does not proxy gameplay traffic. It orchestrates operations.
</p>
<!-- FINAL CTA -->
<section class="finalcta">
<div class="finalcta__atmo" />
<div class="wrap finalcta__in reveal">
<h2>Install the agent.<br>Never SSH again.</h2>
<div class="cta-row">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
Join early access
</RouterLink>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="key" :size="17" />Sign in
</a>
</div>
</section>
</div>
</div>
</section>
</template>

View File

@@ -113,7 +113,7 @@ const mockActiveGame = activeGame
Join early access
</RouterLink>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="play" :size="17" />View live demo
<Icon name="key" :size="17" />Sign in
</a>
</div>
<!-- Game pills -->
@@ -185,7 +185,7 @@ const mockActiveGame = activeGame
<span class="g"><Icon name="box" :size="13" /></span>
<span class="nm">
Main · 2x Vanilla
<small>asgard-01 · rust</small>
<small>rust-host · rust</small>
</span>
<span class="st"><b />online</span>
</div>
@@ -193,7 +193,7 @@ const mockActiveGame = activeGame
<span class="g"><Icon name="sun" :size="13" /></span>
<span class="nm">
Arrakis · Hardcore
<small>asgard-01 · dune</small>
<small>dune-host · dune</small>
</span>
<span class="st"><b />online</span>
</div>
@@ -201,7 +201,7 @@ const mockActiveGame = activeGame
<span class="g"><Icon name="swords" :size="13" /></span>
<span class="nm">
Exiled Lands · PvP-C
<small>asgard-02 · conan</small>
<small>conan-host · conan</small>
</span>
<span class="st"><b />online</span>
</div>
@@ -672,7 +672,7 @@ const mockActiveGame = activeGame
Join early access
</RouterLink>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="play" :size="17" />View live demo
<Icon name="key" :size="17" />Sign in
</a>
</div>
</div>

View File

@@ -1,129 +1,429 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { Check } from 'lucide-vue-next'
import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
// Scroll-reveal
let io: IntersectionObserver | null = null
function initReveal(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in')
io?.unobserve(e.target)
}
})
},
{ threshold: 0.1 },
)
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
onMounted(() => { initReveal() })
onUnmounted(() => { io?.disconnect() })
interface PlanFeature {
text: string
}
interface Plan {
name: string
price: string
period: string
scope: string
tag: string
featured: boolean
cta: string
ctaVariant: 'primary' | 'ghost'
features: PlanFeature[]
}
const plans: Plan[] = [
{
name: 'Hobby',
price: '$9.99',
period: '/mo',
scope: '15 servers · non-commercial use',
tag: '',
featured: false,
cta: 'Join early access',
ctaVariant: 'ghost',
features: [
{ text: 'Up to 5 game server instances' },
{ text: 'Non-commercial servers only' },
{ text: 'Auto-wiper with rollback' },
{ text: 'Plugin management (Rust)' },
{ text: 'File manager + real-time console' },
{ text: 'Scheduled tasks' },
{ text: 'Public server page' },
{ text: 'Community support' },
],
},
{
name: 'Community',
price: '$19.99',
period: '/mo',
scope: '610 servers · non-commercial use',
tag: '',
featured: false,
cta: 'Join early access',
ctaVariant: 'ghost',
features: [
{ text: 'Up to 10 game server instances' },
{ text: 'Non-commercial servers only' },
{ text: 'Auto-wiper with rollback' },
{ text: 'Plugin management (Rust)' },
{ text: 'File manager + real-time console' },
{ text: 'Scheduled tasks' },
{ text: 'Public server page' },
{ text: 'Community support' },
],
},
{
name: 'Operator',
price: '$99.99',
period: '/mo',
scope: 'Commercial use · or 1150 servers',
tag: 'Most popular',
featured: true,
cta: 'Get Operator',
ctaVariant: 'primary',
features: [
{ text: 'Up to 50 game server instances' },
{ text: 'Commercial use permitted' },
{ text: 'All games: Rust, Dune, Soulmask, Conan' },
{ text: 'Auto-wiper with rollback' },
{ text: 'Plugin + mod management' },
{ text: 'File manager + real-time console' },
{ text: 'Scheduled tasks + maintenance windows' },
{ text: 'Player management + RBAC team access' },
{ text: 'Public server page + storefront' },
{ text: 'Community support + priority bug triage' },
],
},
{
name: 'Network',
price: '$99.99',
period: '/mo',
scope: '50+ servers · hosting partners + fleets',
tag: '',
featured: false,
cta: 'Join early access',
ctaVariant: 'ghost',
features: [
{ text: '50 servers base included' },
{ text: 'Fleet Blocks: +$49.99/mo per 50 servers' },
{ text: 'Commercial use permitted' },
{ text: 'All games + multi-game hosts' },
{ text: 'Full Operator feature set' },
{ text: 'Fleet-level management' },
{ text: 'Priority bug triage for platform issues' },
{ text: 'Community support' },
],
},
]
</script>
<template>
<div>
<!-- Header -->
<section class="pt-20 pb-12">
<div class="max-w-4xl mx-auto px-6 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">Pricing</h1>
<p class="text-lg text-neutral-400">Simple. Transparent. No hidden tiers.</p>
<!-- PAGE HEADER -->
<section class="hero" style="padding-bottom:0; border-bottom:none;">
<div class="hero__atmo" />
<div class="hero__grid" />
<div class="hero__grain" />
<div class="wrap hero__in" style="padding-bottom: 52px;">
<div class="hero__mark">
<CorrosionMark :size="56" />
</div>
</section>
<span class="eyebrow">Pricing</span>
<h1 style="font-size:var(--text-5xl)">
Scale from one server
<span class="accent">to a fleet.</span>
</h1>
<p class="hero__sub">
Simple tiers based on how many servers you run and whether you operate commercially.
No per-seat charges. No surprises.
</p>
</div>
</section>
<!-- Pricing Cards -->
<section class="pb-20">
<div class="max-w-5xl mx-auto px-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Base License -->
<div class="bg-neutral-900 border-2 border-oxide-500/40 rounded-xl p-8 relative">
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
<span class="px-3 py-1 bg-oxide-600 text-white text-xs font-semibold rounded-full uppercase tracking-wider">Most Popular</span>
</div>
<div class="text-center mb-6">
<h3 class="text-xl font-bold text-neutral-100 mb-2">Base License</h3>
<div class="flex items-baseline justify-center gap-1">
<span class="text-4xl font-bold text-oxide-400">$50</span>
</div>
<p class="text-sm text-neutral-500 mt-1">One server. Lifetime access.</p>
<p class="text-xs text-oxide-400/70 mt-1">Launch Price</p>
</div>
<ul class="space-y-3 mb-8">
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Full control plane
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Auto-Wiper with rollback
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Plugin management
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Public server site
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Multi-admin RBAC
</li>
</ul>
<RouterLink to="/register" class="block w-full py-3 bg-oxide-600 hover:bg-oxide-700 text-white text-center font-semibold rounded-lg transition-colors">
Get Started
</RouterLink>
<!-- PRICING CARDS -->
<section class="sec" id="plans">
<div class="wrap">
<div class="pricing reveal">
<div
v-for="plan in plans"
:key="plan.name"
class="plan"
:class="plan.featured ? 'plan--feature' : ''"
>
<div class="plan__tag">{{ plan.tag }}</div>
<div class="plan__name">{{ plan.name }}</div>
<div class="plan__price">
{{ plan.price }}<small>{{ plan.period }}</small>
</div>
<div class="plan__scope">{{ plan.scope }}</div>
<!-- Webstore Add-On -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
<div class="text-center mb-6">
<h3 class="text-xl font-bold text-neutral-100 mb-2">Webstore Add-On</h3>
<div class="flex items-baseline justify-center gap-1">
<span class="text-4xl font-bold text-neutral-200">$10</span>
<span class="text-neutral-500">/mo</span>
</div>
<p class="text-sm text-neutral-500 mt-1">Integrated monetization platform.</p>
</div>
<ul class="space-y-3 mb-8">
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Item catalog
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Stripe + PayPal
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Auto-delivery via RCON
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Transaction history
</li>
</ul>
<button class="block w-full py-3 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 text-center font-semibold rounded-lg border border-neutral-700 transition-colors">
Coming Soon
</button>
</div>
<ul class="plan__feats">
<li v-for="feat in plan.features" :key="feat.text">
<Icon name="check" :size="13" style="color:var(--accent-text);flex:none" />
{{ feat.text }}
</li>
</ul>
<!-- Modules -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
<div class="text-center mb-6">
<h3 class="text-xl font-bold text-neutral-100 mb-2">Modules</h3>
<div class="flex items-baseline justify-center gap-1">
<span class="text-4xl font-bold text-neutral-200">$9.99</span>
<span class="text-neutral-500">+</span>
</div>
<p class="text-sm text-neutral-500 mt-1">Optional feature expansions.</p>
</div>
<ul class="space-y-3 mb-8">
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Analytics & insights
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Discord bot integration
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Cloud backups
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
More on the roadmap
</li>
</ul>
<RouterLink to="/site/roadmap" class="block w-full py-3 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 text-center font-semibold rounded-lg border border-neutral-700 transition-colors">
View Roadmap
</RouterLink>
</div>
<RouterLink
class="btn"
:class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'"
:to="{ name: 'early-access' }"
>
{{ plan.cta }}
</RouterLink>
</div>
</div>
</section>
</div>
<!-- Fleet Block -->
<div class="fleetblock reveal">
<b>Fleet Block</b>
<span class="p">+$49.99/mo</span>
<span>each additional 50 servers stack as many as your network needs.</span>
</div>
<!-- Commercial use definition -->
<p class="commercial reveal">
<b>Commercial use</b> includes monetized communities, paid access, VIP slots, donations,
sponsorship-supported servers, hosting providers, or managing servers for others.
</p>
<!-- Support model -->
<p class="support-note reveal">
Community support is included with every plan documentation, community forum,
diagnostics, and structured bug reports.
<b>Direct 1:1 support</b> is available separately at $125/hour in prepaid 1-hour blocks.
Corrosion is a tool, not a managed service.
</p>
</div>
</section>
<!-- COMPARISON TABLE -->
<section class="sec" id="compare">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Feature breakdown</span>
<h2 class="title">What is included in each tier</h2>
</div>
<div class="compare-table reveal">
<div class="compare-table__head">
<div class="compare-table__label">Feature</div>
<div>Hobby</div>
<div>Community</div>
<div class="compare-table__featured">Operator</div>
<div>Network</div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Server instances</div>
<div>15</div>
<div>610</div>
<div class="compare-table__featured">Up to 50</div>
<div>50 + Fleet Blocks</div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Commercial use</div>
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Rust (Oxide/uMod)</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">All supported games</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Auto-wiper + rollback</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Real-time console</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">RBAC team access</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Public server page</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Priority bug triage</div>
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
</div>
</div>
</section>
<!-- SUPPORT MODEL -->
<section class="sec" id="support">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Support model</span>
<h2 class="title">Corrosion is a tool,<br>not a managed service</h2>
<p class="lead">
Every plan includes self-service support. Hands-on time is available separately at
an honest rate, when you actually need it.
</p>
</div>
<div class="infra reveal">
<div class="icard">
<div class="icard__ic"><Icon name="file-text" :size="16" /></div>
<b>Documentation</b>
<p>Setup guides, architecture reference, troubleshooting walkthroughs. Included on every plan.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="message-square" :size="16" /></div>
<b>Community forum</b>
<p>Operator-to-operator knowledge base. Questions, configs, and war stories. All plans.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
<b>Diagnostics</b>
<p>Built-in agent health checks, log access, and structured bug reports. All plans.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
<b>Priority bug triage</b>
<p>Platform bugs for Operator and Network customers go to the front of the queue.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="clock" :size="16" /></div>
<b>Direct 1:1 support</b>
<p>$125/hour, prepaid in 1-hour blocks. Available to any customer who needs it.</p>
</div>
</div>
<p class="closing reveal" style="font-size:var(--text-md);color:var(--text-tertiary);font-weight:500">
Direct server administration, firewall configuration, mod installation, and wipe-day
hand-holding are not included in any plan. Corrosion gives you the panel and the tools.
You run the operation.
</p>
</div>
</section>
<!-- FINAL CTA -->
<section class="finalcta">
<div class="finalcta__atmo" />
<div class="wrap finalcta__in reveal">
<h2>Ready to stop babysitting<br>your servers?</h2>
<div class="cta-row">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
Join early access
</RouterLink>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="key" :size="17" />Sign in
</a>
</div>
</div>
</section>
</template>
<style scoped>
.plan__feats {
list-style: none;
padding: 0;
margin: 16px 0 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 9px;
}
.plan__feats li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.4;
}
.plan__feats li svg { margin-top: 1px; }
/* Comparison table */
.compare-table {
max-width: 1040px;
margin: 0 auto;
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: var(--ring-default);
}
.compare-table__head,
.compare-table__row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.2fr 1fr;
align-items: center;
}
.compare-table__head {
background: var(--surface-raised-2);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-caps);
color: var(--text-muted);
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.compare-table__head > div,
.compare-table__row > div {
padding: 2px 8px;
text-align: center;
}
.compare-table__head > div:first-child,
.compare-table__row > div:first-child {
text-align: left;
padding-left: 0;
}
.compare-table__row {
background: var(--surface-base);
font-size: var(--text-sm);
color: var(--text-tertiary);
padding: 11px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.compare-table__row:last-child { border-bottom: none; }
.compare-table__label { color: var(--text-secondary); font-weight: 500; }
.compare-table__featured {
background: var(--accent-soft);
color: var(--accent-text) !important;
font-weight: 600;
}
</style>

View File

@@ -1,146 +1,353 @@
<script setup lang="ts">
import { Check, Circle } from 'lucide-vue-next'
import { onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
interface Phase {
name: string
label: string
status: 'complete' | 'current' | 'upcoming'
items: { text: string; done: boolean }[]
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
type Status = 'shipped' | 'in-progress' | 'planned'
interface RoadmapItem {
text: string
note?: string
}
const phases: Phase[] = [
interface RoadmapGroup {
status: Status
label: string
description: string
items: RoadmapItem[]
}
const groups: RoadmapGroup[] = [
{
name: 'Phase 1',
label: 'Foundation',
status: 'complete',
status: 'shipped',
label: 'Phase 1 — Foundation',
description:
'The core control plane is live. Rust server operators can install the agent, connect their server, and manage it entirely from the panel.',
items: [
{ text: 'Core control plane', done: true },
{ text: 'Auto-Wiper with rollback', done: true },
{ text: 'Plugin management', done: true },
{ text: 'Public server site', done: true },
{ text: 'Host agent (Windows + Linux)', note: 'Go binary, outbound NATS, zero inbound ports' },
{ text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' },
{ text: 'Auto-wiper with rollback', note: 'Map, BP, and full wipes as verified sequences' },
{ text: 'Plugin management', note: 'Rust uMod/Oxide — browse, install, configure from the browser' },
{ text: 'Real-time console', note: 'NATS-bridged live output' },
{ text: 'File manager', note: 'Browser-based file access via the agent' },
{ text: 'Scheduled tasks and maintenance windows' },
{ text: 'Player management and RBAC team access' },
{ text: 'Public server page', note: 'Live status, wipe countdown, player count' },
{ text: 'SteamCMD game update automation' },
{ text: 'Discord and notification webhooks' },
],
},
{
name: 'Phase 2',
label: 'Analytics',
status: 'current',
status: 'in-progress',
label: 'Multi-game expansion',
description:
'The agent and control plane are being extended with per-game blueprints. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same agent model with game-specific operational logic.',
items: [
{ text: 'Player retention tracking', done: false },
{ text: 'Wipe performance insights', done: false },
{ text: 'Population heatmaps', done: false },
{ text: 'Dune: Awakening blueprint', note: 'Deep Desert wipe scheduling, battlegroup lifecycle' },
{ text: 'Conan Exiles blueprint', note: 'Persistent world management, mod support, purge tracking' },
{ text: 'Soulmask blueprint', note: 'Linked-world cluster deployment, port automation' },
{ text: 'Multi-instance host runtime', note: 'One agent managing N game processes on the same machine' },
{ text: 'Per-game wipe and event scheduling' },
],
},
{
name: 'Phase 3',
label: 'Status Platform',
status: 'upcoming',
status: 'planned',
label: 'API access and integrations',
description:
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane.',
items: [
{ text: 'Public uptime tracking', done: false },
{ text: 'Server health dashboard', done: false },
{ text: 'Public REST API for server management' },
{ text: 'Webhook events (wipe completed, server down, player banned)' },
{ text: 'API key management per license' },
],
},
{
name: 'Phase 4',
label: 'Module Marketplace',
status: 'upcoming',
status: 'planned',
label: 'Integrated storefront',
description:
'A native store layer for server communities — item catalog, VIP packages, and automated in-game delivery. No Tebex dependency required.',
items: [
{ text: 'Loot manager', done: false },
{ text: 'Event systems', done: false },
{ text: 'Advanced gameplay modules', done: false },
{ text: 'Item catalog and categories' },
{ text: 'PayPal and Stripe payment processing' },
{ text: 'Automated in-game delivery via RCON/agent' },
{ text: 'Transaction history and revenue dashboard' },
],
},
{
name: 'Phase 5',
label: 'Integrated Webstore',
status: 'upcoming',
status: 'planned',
label: 'Fleet management for hosting partners',
description:
'Tools for hosting providers and multi-server operations running 50+ instances across multiple physical hosts.',
items: [
{ text: 'Native item store', done: false },
{ text: 'Automated delivery', done: false },
{ text: 'Revenue dashboard', done: false },
{ text: 'Fleet-level dashboards and health monitoring' },
{ text: 'Multi-host agent orchestration' },
{ text: 'Bulk wipe and update scheduling across a fleet' },
{ text: 'Fleet Block capacity management' },
],
},
{
name: 'Phase 6',
label: 'B2B Hosting Integration',
status: 'upcoming',
status: 'planned',
label: 'More games',
description:
'Corrosion\'s agent model is not Rust-specific. The platform is being designed to support any game that can be managed via process control, file operations, and RCON-style commands.',
items: [
{ text: 'White-label panel', done: false },
{ text: 'Bulk license provisioning', done: false },
{ text: 'SSO integration', done: false },
{ text: 'Additional survival and sandbox games' },
{ text: 'Community-requested game blueprints' },
],
},
]
function phaseStatusClass(status: string): string {
switch (status) {
case 'complete': return 'bg-green-500/10 text-green-400 border-green-500/20'
case 'current': return 'bg-oxide-500/10 text-oxide-400 border-oxide-500/20'
default: return 'bg-neutral-800 text-neutral-500 border-neutral-700'
}
function statusLabel(s: Status): string {
if (s === 'shipped') return 'Shipped'
if (s === 'in-progress') return 'In progress'
return 'Planned'
}
function phaseStatusLabel(status: string): string {
switch (status) {
case 'complete': return 'Shipped'
case 'current': return 'In Progress'
default: return 'Planned'
}
function statusIcon(s: Status): string {
if (s === 'shipped') return 'check'
if (s === 'in-progress') return 'refresh-cw'
return 'circle'
}
// Scroll-reveal
let io: IntersectionObserver | null = null
function initReveal(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in')
io?.unobserve(e.target)
}
})
},
{ threshold: 0.1 },
)
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
onMounted(() => { initReveal() })
onUnmounted(() => { io?.disconnect() })
</script>
<template>
<div>
<!-- Header -->
<section class="pt-20 pb-12">
<div class="max-w-4xl mx-auto px-6 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">Roadmap</h1>
<p class="text-lg text-neutral-400">
Corrosion isn't a single plugin release. It's infrastructure for the Rust ecosystem.
</p>
<!-- PAGE HEADER -->
<section class="hero" style="padding-bottom:0; border-bottom:none;">
<div class="hero__atmo" />
<div class="hero__grid" />
<div class="hero__grain" />
<div class="wrap hero__in" style="padding-bottom:52px;">
<div class="hero__mark">
<CorrosionMark :size="56" />
</div>
</section>
<span class="eyebrow">Roadmap</span>
<h1 style="font-size:var(--text-5xl)">
Where Corrosion
<span class="accent">is going.</span>
</h1>
<p class="hero__sub">
No specific dates. No fabricated percentages. Status labels only: Shipped, In progress,
Planned. This roadmap reflects what is actually being built.
</p>
</div>
</section>
<!-- Timeline -->
<section class="pb-20">
<div class="max-w-3xl mx-auto px-6">
<div class="space-y-6">
<div
v-for="phase in phases"
:key="phase.name"
class="bg-neutral-900 border rounded-xl p-8 transition-colors"
:class="phase.status === 'current' ? 'border-oxide-500/30' : 'border-neutral-800'"
>
<div class="flex items-center justify-between mb-5">
<div>
<span class="text-xs font-bold text-neutral-500 uppercase tracking-wider">{{ phase.name }}</span>
<h3 class="text-xl font-bold text-neutral-100">{{ phase.label }}</h3>
</div>
<span
class="text-xs font-semibold px-3 py-1 rounded-full border"
:class="phaseStatusClass(phase.status)"
>
{{ phaseStatusLabel(phase.status) }}
</span>
</div>
<ul class="space-y-3">
<li
v-for="item in phase.items"
:key="item.text"
class="flex items-center gap-3"
>
<Check v-if="item.done" class="w-4 h-4 text-green-400 shrink-0" />
<Circle v-else class="w-4 h-4 text-neutral-600 shrink-0" />
<span
class="text-sm"
:class="item.done ? 'text-neutral-300' : 'text-neutral-500'"
>
{{ item.text }}
</span>
</li>
</ul>
</div>
<!-- STATUS LEGEND -->
<section class="sec" style="padding:32px 0; border-bottom: 1px solid var(--border-subtle);">
<div class="wrap">
<div class="rm-legend reveal">
<div class="rm-badge rm-badge--shipped">
<Icon name="check" :size="13" />Shipped
</div>
<div class="rm-badge rm-badge--progress">
<Icon name="refresh-cw" :size="13" />In progress
</div>
<div class="rm-badge rm-badge--planned">
<Icon name="circle" :size="13" />Planned
</div>
</div>
</section>
</div>
</div>
</section>
<!-- ROADMAP GROUPS -->
<section class="sec" id="roadmap">
<div class="wrap">
<div
v-for="group in groups"
:key="group.label"
class="rm-group reveal"
:data-status="group.status"
>
<div class="rm-group__head">
<div
class="rm-group__badge"
:class="`rm-badge--${group.status}`"
>
<Icon :name="statusIcon(group.status)" :size="13" />
{{ statusLabel(group.status) }}
</div>
<h3 class="rm-group__title">{{ group.label }}</h3>
</div>
<p class="rm-group__desc">{{ group.description }}</p>
<ul class="rm-group__list">
<li
v-for="item in group.items"
:key="item.text"
class="rm-item"
>
<span class="rm-item__dot" :class="`rm-item__dot--${group.status}`" />
<span>
{{ item.text }}
<span v-if="item.note" class="rm-item__note"> {{ item.note }}</span>
</span>
</li>
</ul>
</div>
</div>
</section>
<!-- HONEST NOTE -->
<section class="sec" style="padding:40px 0; border-bottom:none;">
<div class="wrap">
<div class="closing reveal">
This roadmap reflects real development priorities, not marketing promises.
Timelines are not published because they depend on real-world testing and operator
feedback. <span class="accent">Join early access to influence what gets built next.</span>
</div>
<div class="hero__cta reveal" style="margin-top:28px">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
Join early access
</RouterLink>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="key" :size="17" />Sign in
</a>
</div>
</div>
</section>
</template>
<style scoped>
/* Legend */
.rm-legend {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
/* Badges */
.rm-badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 10px;
border-radius: var(--radius-pill);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
}
.rm-badge--shipped {
background: color-mix(in srgb, var(--status-online) 14%, transparent);
color: var(--status-online);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--status-online) 28%, transparent);
}
.rm-badge--in-progress, .rm-badge--progress {
background: var(--accent-soft);
color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.rm-badge--planned {
background: var(--surface-raised-2);
color: var(--text-muted);
box-shadow: var(--ring-default);
}
/* Groups */
.rm-group {
max-width: 860px;
margin: 0 auto 40px;
padding: 28px 28px 24px;
background: var(--surface-base);
border-radius: var(--radius-xl);
box-shadow: var(--ring-default);
}
.rm-group[data-status="in-progress"] {
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.rm-group[data-status="shipped"] {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--status-online) 22%, transparent);
}
.rm-group__head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.rm-group__badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 10px;
border-radius: var(--radius-pill);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
flex: none;
}
.rm-group__title {
font-family: var(--font-brand);
font-weight: 700;
font-size: var(--text-xl);
margin: 0;
}
.rm-group__desc {
font-size: var(--text-sm);
color: var(--text-tertiary);
line-height: 1.6;
margin: 0 0 18px;
max-width: 680px;
}
.rm-group__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.rm-item {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.rm-item__dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
margin-top: 5px;
}
.rm-item__dot--shipped { background: var(--status-online); }
.rm-item__dot--in-progress { background: var(--accent); }
.rm-item__dot--planned {
background: transparent;
box-shadow: inset 0 0 0 1.5px var(--text-muted);
}
.rm-item__note {
color: var(--text-muted);
font-size: var(--text-xs);
}
</style>

View File

@@ -41,12 +41,8 @@ interface StatusResponse {
const api = useApi()
const servers = ref<ServerStatus[]>([])
const platformHealth = ref<PlatformHealth>({
total_servers: 0,
online_servers: 0,
total_players: 0,
uptime_percent: 0,
})
// null until the first successful fetch — KPIs render '—', never fake zeros
const platformHealth = ref<PlatformHealth | null>(null)
const searchQuery = ref('')
const loading = ref(true)
@@ -148,10 +144,10 @@ onUnmounted(() => {
<!-- Platform KPIs -->
<div v-if="!loading" class="sp-kpis">
<StatCard icon="server" label="Total servers" :value="String(platformHealth.total_servers)" />
<StatCard icon="activity" label="Online now" :value="String(platformHealth.online_servers)" />
<StatCard icon="users" label="Total players" :value="String(platformHealth.total_players)" />
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth.uptime_percent, 1)" unit="%" />
<StatCard icon="server" label="Total servers" :value="platformHealth ? String(platformHealth.total_servers) : '—'" />
<StatCard icon="activity" label="Online now" :value="platformHealth ? String(platformHealth.online_servers) : '—'" />
<StatCard icon="users" label="Total players" :value="platformHealth ? String(platformHealth.total_players) : '—'" />
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth?.uptime_percent ?? null, 1, '—')" unit="%" />
</div>
<!-- Body -->

View File

@@ -2,12 +2,18 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
import { readFileSync } from 'node:fs'
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),