7 Commits

Author SHA1 Message Date
Vantz Stockwell
358adde496 feat: Add companion agent one-click deployment + fix frontend TS errors
All checks were successful
Build Companion Agent / build (push) Successful in 30s
Test Asgard Runner / test (push) Successful in 3s
Go deployment orchestrator with platform-specific SteamCMD install,
Rust server download, server.cfg generation, and service registration.
Wire deploy command subscription in daemon, make GameServerPath optional,
add InstallDir config with OS-aware defaults. Fix unused imports and
WebSocket subscribe API in ServerView.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:49:48 -05:00
Vantz Stockwell
b94717d51b feat: Add frontend support for one-click Rust server deployment
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Adds DeploymentConfig/DeploymentStatus types, deployment state management
in the server store, tabbed Linux/Windows quick setup commands, and a
Deploy Rust Server card with progress tracker and configuration form.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:48:05 -05:00
Vantz Stockwell
834e17e7cf feat: Add backend support for one-click Rust server deployment
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Add deploy endpoint, DTO, NATS command publisher, and WebSocket bridge
subscription to support the one-click server deployment feature via the
companion agent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:45:06 -05:00
Vantz Stockwell
ee7fdb897d docs: Add standing orders — auto commit/push, tag companion builds
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:22:46 -05:00
Vantz Stockwell
0fdbad0d07 fix: Resolve TypeScript build errors blocking Docker nginx build
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- DashboardView: Add non-null assertion on upcoming[0] (guarded by length check)
- EarlyAccessView: Add missing `computed` import from Vue

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:21:58 -05:00
Vantz Stockwell
93d536a13e feat: Upload companion agent to SeaweedFS CDN + update download links
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
CI pipeline now uploads binaries to cdn.corrosionmgmt.com:
- /companion/latest/ (always current, overwritten each release)
- /companion/v1.x.x/ (versioned archive)

Frontend download links updated from Gitea releases to CDN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:11:06 -05:00
Vantz Stockwell
ca176c4c9b fix: Remove jq dependency from CI — parse release ID with grep
All checks were successful
Build Companion Agent / build (push) Successful in 18s
Test Asgard Runner / test (push) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:04:32 -05:00
18 changed files with 1063 additions and 41 deletions

View File

@@ -49,12 +49,20 @@ jobs:
REPO="vantzs/corrosion-admin-panel"
API_URL="https://git.corrosionmgmt.com/api/v1"
# Create release
RELEASE_ID=$(curl -s -X POST \
# Create release (parse ID without jq)
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}" \
"${API_URL}/repos/${REPO}/releases" | jq -r '.id')
"${API_URL}/repos/${REPO}/releases")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
if [ -z "$RELEASE_ID" ]; then
echo "ERROR: Failed to create release. Response:"
echo "$RESPONSE"
exit 1
fi
echo "Release created with ID: ${RELEASE_ID}"
# Upload Linux binary
curl -s -X POST \
@@ -77,6 +85,39 @@ jobs:
--data-binary @companion-agent/bin/checksums.txt \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=checksums.txt"
- name: Upload to CDN (latest)
run: |
CDN_URL="https://cdn.corrosionmgmt.com"
# Upload Linux binary to /companion/latest/
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
"${CDN_URL}/companion/latest/corrosion-companion-linux-amd64"
# Upload Windows binary to /companion/latest/
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
"${CDN_URL}/companion/latest/corrosion-companion-windows-amd64.exe"
# Upload checksums
curl -s -X POST \
-F "file=@companion-agent/bin/checksums.txt" \
"${CDN_URL}/companion/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"
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
"${CDN_URL}/companion/${VERSION}/corrosion-companion-windows-amd64.exe"
curl -s -X POST \
-F "file=@companion-agent/bin/checksums.txt" \
"${CDN_URL}/companion/${VERSION}/checksums.txt"
echo "CDN upload complete: ${CDN_URL}/companion/latest/"
- name: Build Summary
run: |
echo "## Companion Agent Build Complete" >> $GITHUB_STEP_SUMMARY

View File

@@ -367,6 +367,8 @@ Default to Sonnet. Escalate to Opus when the problem demands it, not as a comfor
- Present trade-offs as COAs with pros/cons — let operator decide
- Treat every change as production deployment (`corrosionmgmt.com`)
- Document why, not just what, in commits and CHANGELOG
- **Always commit and push when done touching code — never ask, never wait for permission**
- **Tag companion agent builds when Go code in `companion-agent/` is modified** — increment from latest tag (currently v1.0.3), push tag to trigger CI build + CDN upload
## Development Notes

View File

@@ -0,0 +1,41 @@
import { IsString, IsInt, Min, Max, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class DeployServerDto {
@ApiProperty({ example: 'My Rust Server', description: 'Server hostname' })
@IsString()
server_name: string;
@ApiProperty({ example: 100, description: 'Maximum player slots' })
@IsInt()
@Min(1)
@Max(500)
max_players: number;
@ApiProperty({ example: 4000, description: 'World size (1000-8000)' })
@IsInt()
@Min(1000)
@Max(8000)
world_size: number;
@ApiProperty({ example: 12345, description: 'Map seed' })
@IsInt()
seed: number;
@ApiProperty({ example: 28015, description: 'Server game port' })
@IsInt()
@Min(1024)
@Max(65535)
server_port: number;
@ApiProperty({ example: 28016, description: 'RCON port' })
@IsInt()
@Min(1024)
@Max(65535)
rcon_port: number;
@ApiProperty({ example: 'changeme', description: 'RCON password (min 6 chars)' })
@IsString()
@MinLength(6)
rcon_password: string;
}

View File

@@ -3,6 +3,7 @@ import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { ServersService } from './servers.service';
import { UpdateServerConfigDto } from './dto/update-config.dto';
import { SendCommandDto } from './dto/send-command.dto';
import { DeployServerDto } from './dto/deploy-server.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -62,4 +63,14 @@ export class ServersController {
async restartServer(@CurrentTenant() licenseId: string) {
return await this.serversService.restartServer(licenseId);
}
@Post('deploy')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Deploy Rust server via companion agent' })
async deployServer(
@CurrentTenant() licenseId: string,
@Body() dto: DeployServerDto,
) {
return await this.serversService.deployServer(licenseId, dto);
}
}

View File

@@ -5,6 +5,7 @@ import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerConfig } from '../../entities/server-config.entity';
import { NatsService } from '../../services/nats.service';
import { UpdateServerConfigDto } from './dto/update-config.dto';
import { DeployServerDto } from './dto/deploy-server.dto';
@Injectable()
export class ServersService {
@@ -86,4 +87,12 @@ export class ServersService {
await this.natsService.sendServerCommand(licenseId, 'restart');
return { message: 'Restart command sent' };
}
/**
* Deploy Rust server via companion agent
*/
async deployServer(licenseId: string, dto: DeployServerDto) {
await this.natsService.sendDeployCommand(licenseId, { ...dto });
return { message: 'Deployment started' };
}
}

View File

@@ -34,6 +34,11 @@ export class NatsBridgeService implements OnModuleInit {
this.emit(licenseId, 'server_status', data);
});
this.nats.subscribe('corrosion.*.deploy.status', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'deploy_status', data);
});
this.logger.log('NATS bridge subscriptions initialized');
}

View File

@@ -70,4 +70,13 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
timestamp: new Date().toISOString(),
});
}
/** Publish a deploy command to a specific license's companion agent */
async sendDeployCommand(licenseId: string, config: Record<string, unknown>): Promise<void> {
await this.publish(`corrosion.${licenseId}.cmd.deploy`, {
action: 'deploy',
config,
timestamp: new Date().toISOString(),
});
}
}

View File

@@ -5,6 +5,7 @@ import (
"log"
"os"
"os/signal"
"runtime"
"syscall"
"time"
@@ -24,9 +25,12 @@ type Config struct {
// Game server configuration
SteamCMDPath string `envconfig:"STEAMCMD_PATH" default:"/usr/games/steamcmd"`
GameServerPath string `envconfig:"GAME_SERVER_PATH" required:"true"`
GameServerPath string `envconfig:"GAME_SERVER_PATH" default:""`
GameServerArgs string `envconfig:"GAME_SERVER_ARGS" default:"-batchmode"`
// Install directory for deployment
InstallDir string `envconfig:"INSTALL_DIR" default:""`
// Optional settings
HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"`
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
@@ -44,11 +48,21 @@ func main() {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set default InstallDir based on OS if not configured
if cfg.InstallDir == "" {
if runtime.GOOS == "windows" {
cfg.InstallDir = `C:\RustServer`
} else {
cfg.InstallDir = "/opt/rustserver"
}
}
log.Printf("Configuration loaded:")
log.Printf(" NATS URL: %s", cfg.NATSUrl)
log.Printf(" License ID: %s", cfg.LicenseID)
log.Printf(" Game Server Path: %s", cfg.GameServerPath)
log.Printf(" SteamCMD Path: %s", cfg.SteamCMDPath)
log.Printf(" Install Dir: %s", cfg.InstallDir)
log.Printf(" Heartbeat Interval: %ds", cfg.HeartbeatInterval)
// Create context with signal handling for graceful shutdown
@@ -73,6 +87,7 @@ func main() {
GameServerPath: cfg.GameServerPath,
GameServerArgs: cfg.GameServerArgs,
Version: version,
InstallDir: cfg.InstallDir,
}
// Start daemon

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/nats-io/nats.go"
"github.com/vigilcyber/corrosion-companion/internal/deploy"
"github.com/vigilcyber/corrosion-companion/internal/files"
"github.com/vigilcyber/corrosion-companion/internal/process"
"github.com/vigilcyber/corrosion-companion/internal/update"
@@ -22,6 +23,7 @@ type DaemonConfig struct {
GameServerPath string
GameServerArgs string
Version string
InstallDir string
}
// Daemon manages the companion agent's main operations
@@ -31,6 +33,7 @@ type Daemon struct {
gameServer *process.GameServer
fileOps *files.Operations
updater *update.Updater
deployer *deploy.Deployer
subscriptions []*nats.Subscription
}
@@ -44,9 +47,26 @@ type HeartbeatPayload struct {
CPUPercent float64 `json:"cpu_percent"`
LastUpdate string `json:"last_update"`
PlayerCount int `json:"player_count"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
ServerInstalled bool `json:"server_installed"`
}
// gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter
type gameServerAdapter struct {
gs *process.GameServer
cfg *DaemonConfig
}
func (a *gameServerAdapter) Start() error {
return a.gs.Start()
}
func (a *gameServerAdapter) UpdatePath(path string) {
a.cfg.GameServerPath = path
// Recreate game server with new path
*a.gs = *process.NewGameServer(path, a.cfg.GameServerArgs)
}
// NewDaemon creates a new daemon instance
@@ -54,6 +74,8 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
fileOps := files.NewOperations()
updater := update.NewUpdater(cfg.Version)
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
d := &Daemon{
nc: nc,
@@ -61,6 +83,7 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
gameServer: gameServer,
fileOps: fileOps,
updater: updater,
deployer: deployer,
}
return d, nil
@@ -90,6 +113,11 @@ func (d *Daemon) Run(ctx context.Context) error {
return fmt.Errorf("failed to subscribe to self-update: %w", err)
}
// Subscribe to deploy commands
if err := d.subscribeDeployCommand(); err != nil {
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
}
log.Println("All subscriptions active")
// Start heartbeat ticker
@@ -267,6 +295,49 @@ func (d *Daemon) subscribeSelfUpdate() error {
return nil
}
// subscribeDeployCommand subscribes to server deployment commands
func (d *Daemon) subscribeDeployCommand() error {
subject := fmt.Sprintf("corrosion.%s.cmd.deploy", d.cfg.LicenseID)
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
var cmd struct {
Action string `json:"action"`
Config deploy.DeployConfig `json:"config"`
}
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
log.Printf("Failed to parse deploy command: %v", err)
d.respondError(msg, "invalid_command", err.Error())
return
}
log.Printf("Received deploy command: %s", cmd.Action)
// Run deployment in goroutine (it's long-running)
go func() {
if err := d.deployer.Deploy(cmd.Config); err != nil {
log.Printf("Deployment failed: %v", err)
} else {
log.Println("Deployment completed successfully")
}
}()
// Immediately acknowledge the command
d.respondSuccess(msg, map[string]interface{}{
"status": "accepted",
"message": "Deployment started, progress will be published to deploy.status",
})
})
if err != nil {
return err
}
d.subscriptions = append(d.subscriptions, sub)
log.Printf("Subscribed to: %s", subject)
return nil
}
// handleFileOperation processes file operation requests
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
// Parse common fields
@@ -325,17 +396,18 @@ func (d *Daemon) publishHeartbeat() {
diskFree := getDiskFreeSpace(d.cfg.GameServerPath)
payload := HeartbeatPayload{
Timestamp: time.Now().UTC().Format(time.RFC3339),
Status: "running",
ServerStatus: status,
UptimeSeconds: int64(uptime.Seconds()),
DiskFreeMB: diskFree,
CPUPercent: 0.0, // TODO: Implement CPU monitoring
LastUpdate: "", // TODO: Track last SteamCMD update
PlayerCount: 0, // Populated by plugin, not companion
Version: d.cfg.Version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Status: "running",
ServerStatus: status,
UptimeSeconds: int64(uptime.Seconds()),
DiskFreeMB: diskFree,
CPUPercent: 0.0, // TODO: Implement CPU monitoring
LastUpdate: "", // TODO: Track last SteamCMD update
PlayerCount: 0, // Populated by plugin, not companion
Version: d.cfg.Version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
}
data, err := json.Marshal(payload)

View File

@@ -0,0 +1,71 @@
package deploy
import (
"fmt"
"os"
"path/filepath"
)
// DeployConfig holds the configuration received from a NATS cmd.deploy command.
// These fields map directly to the Rust game server settings needed for initial deployment.
type DeployConfig struct {
ServerName string `json:"server_name"`
MaxPlayers int `json:"max_players"`
WorldSize int `json:"world_size"`
Seed int `json:"seed"`
ServerPort int `json:"server_port"`
RconPort int `json:"rcon_port"`
RconPassword string `json:"rcon_password"`
}
// DeployStatus represents a progress update published to NATS during deployment.
// The frontend listens on corrosion.{license_id}.deploy.status for these messages
// to display real-time deployment progress to the user.
type DeployStatus struct {
Stage string `json:"stage"`
Progress int `json:"progress"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
Timestamp string `json:"timestamp"`
}
// Valid deployment stages:
// downloading_steamcmd - Downloading and extracting SteamCMD
// installing_steamcmd - Running SteamCMD initial setup
// downloading_rust - Downloading Rust Dedicated Server via SteamCMD
// configuring - Generating server.cfg and identity directories
// starting - Launching the Rust server process
// online - Server is running and accepting connections
// failed - Deployment failed at some stage
// GenerateServerCfg creates the server.cfg file for a Rust Dedicated Server.
// It writes to {installDir}/server/server/corrosion/cfg/server.cfg, creating
// the full directory tree if it does not already exist.
func GenerateServerCfg(installDir string, cfg DeployConfig) error {
cfgDir := filepath.Join(installDir, "server", "server", "corrosion", "cfg")
if err := os.MkdirAll(cfgDir, 0755); err != nil {
return fmt.Errorf("failed to create cfg directory %s: %w", cfgDir, err)
}
content := fmt.Sprintf(`server.hostname "%s"
server.maxplayers %d
server.worldsize %d
server.seed %d
server.port %d
rcon.port %d
rcon.password "%s"
rcon.web 1
server.identity "corrosion"
server.saveinterval 300
`, cfg.ServerName, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed,
cfg.ServerPort, cfg.RconPort, cfg.RconPassword)
cfgPath := filepath.Join(cfgDir, "server.cfg")
if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write server.cfg to %s: %w", cfgPath, err)
}
return nil
}

View File

@@ -0,0 +1,180 @@
package deploy
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/nats-io/nats.go"
)
// GameServerStarter abstracts the game server process manager so the deployer
// can set the executable path and start the server without depending on the
// concrete process.GameServer type. The existing GameServer will implement
// UpdatePath in a separate task.
type GameServerStarter interface {
Start() error
UpdatePath(path string)
}
// Deployer orchestrates one-click Rust server deployment. It downloads SteamCMD,
// installs the Rust Dedicated Server, generates server.cfg, and starts the server
// process — publishing progress updates to NATS at each stage so the frontend can
// display real-time deployment status.
type Deployer struct {
nc *nats.Conn
licenseID string
installDir string
gameServer GameServerStarter
}
// NewDeployer creates a new Deployer instance.
func NewDeployer(nc *nats.Conn, licenseID, installDir string, gs GameServerStarter) *Deployer {
return &Deployer{
nc: nc,
licenseID: licenseID,
installDir: installDir,
gameServer: gs,
}
}
// Deploy executes the full deployment pipeline: SteamCMD install, Rust server
// download, config generation, and server startup. If any stage fails, a "failed"
// status is published and the error is returned. Progress updates are published
// to NATS at each stage transition.
func (d *Deployer) Deploy(cfg DeployConfig) error {
// Stage 1: SteamCMD
log.Printf("Deploy: starting SteamCMD installation for license %s", d.licenseID)
d.publishStatus("downloading_steamcmd", 0, "Checking for existing SteamCMD installation...")
steamcmdPath, err := InstallSteamCMD(d.installDir)
if err != nil {
d.publishStatus("failed", 0, "SteamCMD installation failed", err.Error())
return fmt.Errorf("steamcmd install failed: %w", err)
}
log.Printf("Deploy: SteamCMD ready at %s", steamcmdPath)
d.publishStatus("downloading_steamcmd", 100, "SteamCMD ready")
// Stage 2: Download Rust Dedicated Server
log.Printf("Deploy: downloading Rust Dedicated Server via SteamCMD")
d.publishStatus("downloading_rust", 0, "Downloading Rust Dedicated Server via SteamCMD...")
if err := DownloadRustServer(steamcmdPath, d.installDir); err != nil {
d.publishStatus("failed", 0, "Rust server download failed", err.Error())
return fmt.Errorf("rust server download failed: %w", err)
}
log.Printf("Deploy: Rust Dedicated Server installed")
d.publishStatus("downloading_rust", 100, "Rust Dedicated Server installed")
// Stage 3: Generate server.cfg
log.Printf("Deploy: generating server.cfg")
d.publishStatus("configuring", 0, "Generating server.cfg...")
if err := GenerateServerCfg(d.installDir, cfg); err != nil {
d.publishStatus("failed", 0, "Server configuration failed", err.Error())
return fmt.Errorf("config generation failed: %w", err)
}
log.Printf("Deploy: server.cfg written")
d.publishStatus("configuring", 100, "Server configured")
// Stage 4: Start the server
log.Printf("Deploy: starting Rust server")
d.publishStatus("starting", 0, "Starting Rust server...")
var exePath string
switch runtime.GOOS {
case "windows":
exePath = filepath.Join(d.installDir, "server", "RustDedicated.exe")
default:
exePath = filepath.Join(d.installDir, "server", "RustDedicated")
}
d.gameServer.UpdatePath(exePath)
if err := d.gameServer.Start(); err != nil {
d.publishStatus("failed", 0, "Server failed to start", err.Error())
return fmt.Errorf("server start failed: %w", err)
}
log.Printf("Deploy: Rust server is now running")
d.publishStatus("online", 100, "Rust server is now running")
return nil
}
// DownloadRustServer runs SteamCMD to download/update the Rust Dedicated Server
// (App ID 258550) into {installDir}/server. This function is platform-agnostic —
// it simply executes the steamcmd binary which was installed by the platform-specific
// InstallSteamCMD function.
func DownloadRustServer(steamcmdPath, installDir string) error {
serverDir := filepath.Join(installDir, "server")
log.Printf("Downloading Rust Dedicated Server to %s", serverDir)
cmd := exec.Command(steamcmdPath,
"+login", "anonymous",
"+force_install_dir", serverDir,
"+app_update", "258550", "validate",
"+quit",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("steamcmd app_update 258550 failed: %w", err)
}
return nil
}
// CheckServerInstalled returns true if the Rust Dedicated Server executable
// exists at the expected path within the install directory.
func CheckServerInstalled(installDir string) bool {
var exePath string
switch runtime.GOOS {
case "windows":
exePath = filepath.Join(installDir, "server", "RustDedicated.exe")
default:
exePath = filepath.Join(installDir, "server", "RustDedicated")
}
_, err := os.Stat(exePath)
return err == nil
}
// publishStatus publishes a DeployStatus message to the NATS subject
// corrosion.{licenseID}.deploy.status. Publish errors are logged but do not
// fail the deployment — losing a progress update is not fatal.
func (d *Deployer) publishStatus(stage string, progress int, message string, errDetail ...string) {
subject := fmt.Sprintf("corrosion.%s.deploy.status", d.licenseID)
status := DeployStatus{
Stage: stage,
Progress: progress,
Message: message,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if len(errDetail) > 0 && errDetail[0] != "" {
status.Error = errDetail[0]
}
data, err := json.Marshal(status)
if err != nil {
log.Printf("Failed to marshal deploy status: %v", err)
return
}
if err := d.nc.Publish(subject, data); err != nil {
log.Printf("Failed to publish deploy status to %s: %v", subject, err)
}
}

View File

@@ -0,0 +1,127 @@
//go:build linux
package deploy
import (
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
)
// InstallSteamCMD downloads and installs SteamCMD for Linux into the given
// install directory. If SteamCMD is already present it returns the existing
// path without re-downloading. The returned string is the absolute path to
// the steamcmd.sh executable.
func InstallSteamCMD(installDir string) (string, error) {
steamcmdDir := filepath.Join(installDir, "steamcmd")
steamcmdPath := filepath.Join(steamcmdDir, "steamcmd.sh")
// Already installed — nothing to do.
if _, err := os.Stat(steamcmdPath); err == nil {
log.Printf("SteamCMD already installed at %s", steamcmdPath)
return steamcmdPath, nil
}
if err := os.MkdirAll(steamcmdDir, 0755); err != nil {
return "", fmt.Errorf("failed to create steamcmd directory %s: %w", steamcmdDir, err)
}
// Download the Linux tarball.
tarball := filepath.Join(steamcmdDir, "steamcmd_linux.tar.gz")
if err := downloadFile("https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz", tarball); err != nil {
return "", fmt.Errorf("failed to download steamcmd: %w", err)
}
// Extract with tar.
cmd := exec.Command("tar", "-xzf", "steamcmd_linux.tar.gz")
cmd.Dir = steamcmdDir
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("failed to extract steamcmd: %w — output: %s", err, string(out))
}
// Ensure the script is executable.
if err := os.Chmod(steamcmdPath, 0755); err != nil {
return "", fmt.Errorf("failed to chmod steamcmd.sh: %w", err)
}
// Verify the installation by running +quit (triggers first-time setup).
verify := exec.Command(steamcmdPath, "+quit")
verify.Dir = steamcmdDir
if out, err := verify.CombinedOutput(); err != nil {
return "", fmt.Errorf("steamcmd verification failed: %w — output: %s", err, string(out))
}
log.Printf("SteamCMD installed successfully at %s", steamcmdPath)
return steamcmdPath, nil
}
// RegisterService creates a systemd unit file for the Rust Dedicated Server
// and enables it. If the caller does not have root access, the unit file is
// written into installDir as a fallback so the user can install it manually.
func RegisterService(installDir string, cfg DeployConfig) error {
serverPath := filepath.Join(installDir, "server", "RustDedicated")
unit := fmt.Sprintf(`[Unit]
Description=Rust Dedicated Server (Corrosion Managed)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=%s/server
ExecStart=%s -batchmode +server.hostname "%s" +server.port %d +rcon.port %d +rcon.password "%s" +rcon.web 1 +server.identity "corrosion" +server.maxplayers %d +server.worldsize %d +server.seed %d
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
`, installDir, serverPath, cfg.ServerName, cfg.ServerPort, cfg.RconPort,
cfg.RconPassword, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed)
systemdPath := "/etc/systemd/system/rustserver.service"
if err := os.WriteFile(systemdPath, []byte(unit), 0644); err != nil {
// Fallback — write into installDir so the user can place it manually.
fallback := filepath.Join(installDir, "rustserver.service")
log.Printf("WARNING: cannot write to %s (%v), falling back to %s", systemdPath, err, fallback)
if writeErr := os.WriteFile(fallback, []byte(unit), 0644); writeErr != nil {
return fmt.Errorf("failed to write service file to fallback %s: %w", fallback, writeErr)
}
}
// Best-effort daemon-reload and enable — ignore errors (systemctl may not
// exist or the user may lack privileges).
_ = exec.Command("systemctl", "daemon-reload").Run()
_ = exec.Command("systemctl", "enable", "rustserver").Run()
log.Println("Systemd service registered for rustserver")
return nil
}
// downloadFile fetches url and writes the response body to dest on disk.
func downloadFile(url, dest string) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("GET %s failed: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s returned status %d", url, resp.StatusCode)
}
out, err := os.Create(dest)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", dest, err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("failed to write to %s: %w", dest, err)
}
return nil
}

View File

@@ -0,0 +1,145 @@
//go:build windows
package deploy
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
)
// InstallSteamCMD downloads and installs SteamCMD for Windows into the given
// install directory. If SteamCMD is already present it returns the existing
// path without re-downloading. The returned string is the absolute path to
// steamcmd.exe.
func InstallSteamCMD(installDir string) (string, error) {
steamcmdDir := filepath.Join(installDir, "steamcmd")
steamcmdPath := filepath.Join(steamcmdDir, "steamcmd.exe")
// Already installed — nothing to do.
if _, err := os.Stat(steamcmdPath); err == nil {
log.Printf("SteamCMD already installed at %s", steamcmdPath)
return steamcmdPath, nil
}
if err := os.MkdirAll(steamcmdDir, 0755); err != nil {
return "", fmt.Errorf("failed to create steamcmd directory %s: %w", steamcmdDir, err)
}
// Download the Windows zip.
zipPath := filepath.Join(steamcmdDir, "steamcmd.zip")
if err := downloadFile("https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip", zipPath); err != nil {
return "", fmt.Errorf("failed to download steamcmd: %w", err)
}
// Extract the zip into steamcmdDir.
if err := extractZip(zipPath, steamcmdDir); err != nil {
return "", fmt.Errorf("failed to extract steamcmd.zip: %w", err)
}
// Verify the exe landed where expected.
if _, err := os.Stat(steamcmdPath); err != nil {
return "", fmt.Errorf("steamcmd.exe not found after extraction: %w", err)
}
log.Printf("SteamCMD installed successfully at %s", steamcmdPath)
return steamcmdPath, nil
}
// RegisterService creates a Windows service for the Rust Dedicated Server
// using sc.exe. If the caller does not have administrator privileges the
// command will fail silently with a warning log.
func RegisterService(installDir string, cfg DeployConfig) error {
serverPath := filepath.Join(installDir, "server", "RustDedicated.exe")
binPath := fmt.Sprintf(`"%s" -batchmode +server.hostname "%s" +server.port %d +rcon.port %d +rcon.password "%s" +rcon.web 1 +server.identity "corrosion" +server.maxplayers %d +server.worldsize %d +server.seed %d`,
serverPath, cfg.ServerName, cfg.ServerPort, cfg.RconPort,
cfg.RconPassword, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed)
cmd := exec.Command("sc.exe", "create", "RustServer", "binPath=", binPath, "start=", "auto")
if out, err := cmd.CombinedOutput(); err != nil {
log.Printf("WARNING: sc.exe create failed (may require admin): %v — output: %s", err, string(out))
} else {
log.Println("Windows service RustServer registered successfully")
}
return nil
}
// downloadFile fetches url and writes the response body to dest on disk.
func downloadFile(url, dest string) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("GET %s failed: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s returned status %d", url, resp.StatusCode)
}
out, err := os.Create(dest)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", dest, err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("failed to write to %s: %w", dest, err)
}
return nil
}
// extractZip extracts all files from a zip archive into destDir, preserving
// the directory structure from the archive.
func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("failed to open zip %s: %w", zipPath, err)
}
defer r.Close()
for _, f := range r.File {
target := filepath.Join(destDir, f.Name)
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", target, err)
}
continue
}
// Ensure the parent directory exists.
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create parent dir for %s: %w", target, err)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open zip entry %s: %w", f.Name, err)
}
outFile, err := os.Create(target)
if err != nil {
rc.Close()
return fmt.Errorf("failed to create file %s: %w", target, err)
}
if _, err := io.Copy(outFile, rc); err != nil {
outFile.Close()
rc.Close()
return fmt.Errorf("failed to extract %s: %w", f.Name, err)
}
outFile.Close()
rc.Close()
}
return nil
}

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ServerConnection, ServerConfig, ServerStats } from '@/types'
import type { ServerConnection, ServerConfig, ServerStats, DeploymentConfig, DeploymentStatus } from '@/types'
import { useApi } from '@/composables/useApi'
export const useServerStore = defineStore('server', () => {
@@ -8,6 +8,8 @@ export const useServerStore = defineStore('server', () => {
const config = ref<ServerConfig | null>(null)
const stats = ref<ServerStats | null>(null)
const isLoading = ref(false)
const deploymentStatus = ref<DeploymentStatus | null>(null)
const isDeploying = ref(false)
const api = useApi()
@@ -50,6 +52,30 @@ export const useServerStore = defineStore('server', () => {
return api.post('/servers/restart')
}
async function deployServer(config: DeploymentConfig) {
isDeploying.value = true
deploymentStatus.value = null
try {
await api.post('/servers/deploy', config)
} catch (e) {
console.error('Failed to start deployment:', e)
isDeploying.value = false
throw e
}
}
function updateDeploymentStatus(status: DeploymentStatus) {
deploymentStatus.value = status
if (status.stage === 'online' || status.stage === 'failed') {
isDeploying.value = false
}
}
function clearDeploymentStatus() {
deploymentStatus.value = null
isDeploying.value = false
}
function updateStats(newStats: ServerStats) {
stats.value = newStats
}
@@ -59,12 +85,17 @@ export const useServerStore = defineStore('server', () => {
config,
stats,
isLoading,
deploymentStatus,
isDeploying,
fetchServer,
updateConfig,
sendCommand,
startServer,
stopServer,
restartServer,
deployServer,
updateDeploymentStatus,
clearDeploymentStatus,
updateStats,
}
})

View File

@@ -423,3 +423,21 @@ export interface StoreTransaction {
payer_email: string | null
created_at: string
}
// Deployment types
export interface DeploymentConfig {
server_name: string
max_players: number
world_size: number
seed: number
server_port: number
rcon_port: number
rcon_password: string
}
export interface DeploymentStatus {
stage: 'downloading_steamcmd' | 'installing_steamcmd' | 'downloading_rust' | 'configuring' | 'starting' | 'online' | 'failed'
progress: number
message: string
error?: string
}

View File

@@ -27,7 +27,7 @@ const nextWipeDate = computed<string>(() => {
if (upcoming.length === 0) return 'Not Scheduled'
return upcoming[0].toLocaleDateString('en-US', {
return upcoming[0]!.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',

View File

@@ -14,7 +14,12 @@ import {
Download,
Terminal,
Monitor,
Rocket,
AlertTriangle,
Check,
} from 'lucide-vue-next'
import type { DeploymentConfig, DeploymentStatus } from '@/types'
import { useWebSocket } from '@/composables/useWebSocket'
const server = useServerStore()
const auth = useAuthStore()
@@ -23,6 +28,20 @@ const editMode = ref(false)
const saving = ref(false)
const actionLoading = ref<string | null>(null)
const copied = ref(false)
const setupTab = ref<'linux' | 'windows'>('linux')
const windowsCopied = ref(false)
const showDeployForm = ref(false)
const deployLoading = ref(false)
const deployForm = ref<DeploymentConfig>({
server_name: 'My Rust Server',
max_players: 100,
world_size: 4000,
seed: Math.floor(Math.random() * 2147483647),
server_port: 28015,
rcon_port: 28016,
rcon_password: '',
})
const isAgentConnected = computed(() =>
server.connection?.connection_type === 'bare_metal' &&
@@ -48,7 +67,7 @@ const agentLastSeenLabel = computed(() => {
const licenseKey = computed(() => auth.license?.license_key || 'YOUR-LICENSE-KEY')
const linuxCommands = computed(() => `# Download the agent
curl -LO https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-linux-amd64
curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64
chmod +x corrosion-companion-linux-amd64
# Start with your license key
@@ -58,16 +77,72 @@ export NATS_TOKEN="<your-nats-token>"
export GAME_SERVER_PATH="/path/to/RustDedicated"
./corrosion-companion-linux-amd64`)
async function copyCommands() {
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"
# Start with your license key
$env:LICENSE_ID="${licenseKey.value}"
$env:NATS_URL="nats://nats.corrosionmgmt.com:4222"
$env:NATS_TOKEN="<your-nats-token>"
$env:GAME_SERVER_PATH="C:\\RustServer\\server\\RustDedicated.exe"
.\\corrosion-companion-windows-amd64.exe`)
async function copySetupCommands() {
try {
await navigator.clipboard.writeText(linuxCommands.value)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
const text = setupTab.value === 'linux' ? linuxCommands.value : windowsCommands.value
await navigator.clipboard.writeText(text)
if (setupTab.value === 'linux') {
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} else {
windowsCopied.value = true
setTimeout(() => { windowsCopied.value = false }, 2000)
}
} catch {
// Clipboard API unavailable
}
}
async function startDeploy() {
if (!deployForm.value.rcon_password || deployForm.value.rcon_password.length < 6) return
deployLoading.value = true
try {
await server.deployServer(deployForm.value)
showDeployForm.value = false
} catch {
// Error handled in store
} finally {
deployLoading.value = false
}
}
const deployStages = [
{ key: 'downloading_steamcmd', label: 'Download SteamCMD' },
{ key: 'installing_steamcmd', label: 'Install SteamCMD' },
{ key: 'downloading_rust', label: 'Download Rust Server' },
{ key: 'configuring', label: 'Configure' },
{ key: 'starting', label: 'Start Server' },
{ key: 'online', label: 'Online' },
] as const
function getStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'failed' {
const status = server.deploymentStatus
if (!status) return 'pending'
if (status.stage === 'failed') {
const idx = deployStages.findIndex(s => s.key === stageKey)
const failIdx = deployStages.findIndex(s => s.key === status.stage)
if (idx < failIdx) return 'complete'
if (idx === failIdx) return 'failed'
return 'pending'
}
const currentIdx = deployStages.findIndex(s => s.key === status.stage)
const thisIdx = deployStages.findIndex(s => s.key === stageKey)
if (thisIdx < currentIdx) return 'complete'
if (thisIdx === currentIdx) return status.stage === 'online' ? 'complete' : 'active'
return 'pending'
}
const form = ref({
server_name: '',
max_players: 0,
@@ -115,6 +190,13 @@ async function serverAction(action: 'start' | 'stop' | 'restart') {
onMounted(async () => {
await server.fetchServer()
loadFormFromConfig()
const ws = useWebSocket()
ws.subscribe((msg) => {
if (msg.type === 'event' && msg.event === 'deploy_status') {
server.updateDeploymentStatus(msg.data as DeploymentStatus)
}
})
})
</script>
@@ -244,7 +326,7 @@ onMounted(async () => {
</div>
<div class="flex flex-wrap gap-3">
<a
href="https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-linux-amd64"
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64"
download="corrosion-companion-linux-amd64"
class="flex items-center gap-2 px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700 hover:border-neutral-600 rounded-lg text-sm font-medium transition-colors"
>
@@ -252,7 +334,7 @@ onMounted(async () => {
Linux (amd64)
</a>
<a
href="https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-windows-amd64.exe"
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"
download="corrosion-companion-windows-amd64.exe"
class="flex items-center gap-2 px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700 hover:border-neutral-600 rounded-lg text-sm font-medium transition-colors"
>
@@ -262,26 +344,49 @@ onMounted(async () => {
</div>
</div>
<!-- Quick Setup Section -->
<!-- Quick Setup Section Tabbed -->
<div>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<Terminal class="w-3.5 h-3.5 text-neutral-500" />
<p class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Quick Setup (Linux)</p>
<p class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Quick Setup</p>
</div>
<div class="flex items-center gap-2">
<!-- OS Tabs -->
<div class="flex bg-neutral-800 rounded-md p-0.5">
<button
@click="setupTab = 'linux'"
class="px-3 py-1 text-xs font-medium rounded transition-colors"
:class="setupTab === 'linux' ? 'bg-neutral-700 text-neutral-100' : 'text-neutral-500 hover:text-neutral-300'"
>Linux</button>
<button
@click="setupTab = 'windows'"
class="px-3 py-1 text-xs font-medium rounded transition-colors"
:class="setupTab === 'windows' ? 'bg-neutral-700 text-neutral-100' : 'text-neutral-500 hover:text-neutral-300'"
>Windows</button>
</div>
<button
@click="copySetupCommands"
class="flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-md transition-colors"
:class="(setupTab === 'linux' ? copied : windowsCopied)
? 'bg-green-600/20 text-green-400 border border-green-600/30'
: 'bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-neutral-200 border border-neutral-700'"
>
{{ (setupTab === 'linux' ? copied : windowsCopied) ? 'Copied!' : 'Copy' }}
</button>
</div>
<button
@click="copyCommands"
class="flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-md transition-colors"
:class="copied
? 'bg-green-600/20 text-green-400 border border-green-600/30'
: 'bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-neutral-200 border border-neutral-700'"
>
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
<div class="bg-black/50 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
<!-- Windows Warning Badge -->
<div v-if="setupTab === 'windows'" class="flex items-center gap-2 mb-3 px-3 py-2 bg-amber-500/10 border border-amber-500/20 rounded-lg">
<AlertTriangle class="w-4 h-4 text-amber-400 shrink-0" />
<p class="text-xs text-amber-300">PowerShell Required Command Prompt is not supported</p>
</div>
<!-- Linux Commands -->
<div v-if="setupTab === 'linux'" class="bg-black/50 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
<p class="text-neutral-500"># Download the agent</p>
<p>curl -LO https://git.corrosionmgmt.com/vantzs/corrosion-admin-panel/releases/latest/download/corrosion-companion-linux-amd64</p>
<p>curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64</p>
<p>chmod +x corrosion-companion-linux-amd64</p>
<p class="mt-3 text-neutral-500"># Start with your license key</p>
<p>export LICENSE_ID=<span class="text-oxide-400">"{{ licenseKey }}"</span></p>
@@ -290,6 +395,146 @@ onMounted(async () => {
<p>export GAME_SERVER_PATH=<span class="text-neutral-500">"/path/to/RustDedicated"</span></p>
<p>./corrosion-companion-linux-amd64</p>
</div>
<!-- Windows Commands -->
<div v-if="setupTab === 'windows'" class="bg-black/50 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
<p class="text-neutral-500"># Requires PowerShell (not Command Prompt)</p>
<p class="text-neutral-500"># Download the agent</p>
<p>Invoke-WebRequest -Uri <span class="text-oxide-400">"https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"</span> -OutFile <span class="text-oxide-400">"corrosion-companion-windows-amd64.exe"</span></p>
<p class="mt-3 text-neutral-500"># Start with your license key</p>
<p>$env:LICENSE_ID=<span class="text-oxide-400">"{{ licenseKey }}"</span></p>
<p>$env:NATS_URL=<span class="text-oxide-400">"nats://nats.corrosionmgmt.com:4222"</span></p>
<p>$env:NATS_TOKEN=<span class="text-neutral-500">"&lt;your-nats-token&gt;"</span></p>
<p>$env:GAME_SERVER_PATH=<span class="text-neutral-500">"C:\RustServer\server\RustDedicated.exe"</span></p>
<p>.\corrosion-companion-windows-amd64.exe</p>
</div>
</div>
</div>
<!-- Deploy Rust Server -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-5">
<Rocket class="w-4 h-4 text-oxide-400" />
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Deploy Rust Server</h2>
</div>
<!-- Deployment Progress Tracker -->
<div v-if="server.deploymentStatus || server.isDeploying" class="mb-6">
<div class="space-y-3">
<div
v-for="stage in deployStages"
:key="stage.key"
class="flex items-center gap-3"
>
<!-- Stage indicator -->
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
:class="{
'bg-neutral-800 text-neutral-600': getStageState(stage.key) === 'pending',
'bg-amber-500/20 text-amber-400': getStageState(stage.key) === 'active',
'bg-green-500/20 text-green-400': getStageState(stage.key) === 'complete',
'bg-red-500/20 text-red-400': getStageState(stage.key) === 'failed',
}"
>
<Loader2 v-if="getStageState(stage.key) === 'active'" class="w-3.5 h-3.5 animate-spin" />
<Check v-else-if="getStageState(stage.key) === 'complete'" class="w-3.5 h-3.5" />
<AlertTriangle v-else-if="getStageState(stage.key) === 'failed'" class="w-3.5 h-3.5" />
<span v-else class="w-1.5 h-1.5 rounded-full bg-neutral-600" />
</div>
<!-- Stage label -->
<span
class="text-sm"
:class="{
'text-neutral-600': getStageState(stage.key) === 'pending',
'text-amber-300 font-medium': getStageState(stage.key) === 'active',
'text-green-400': getStageState(stage.key) === 'complete',
'text-red-400': getStageState(stage.key) === 'failed',
}"
>{{ stage.label }}</span>
</div>
</div>
<!-- Status message -->
<div v-if="server.deploymentStatus?.message" class="mt-4 px-3 py-2 bg-neutral-800/50 rounded-lg">
<p class="text-xs text-neutral-400">{{ server.deploymentStatus.message }}</p>
</div>
<!-- Error display -->
<div v-if="server.deploymentStatus?.error" class="mt-3 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg">
<p class="text-xs text-red-400">{{ server.deploymentStatus.error }}</p>
</div>
<!-- Retry button on failure -->
<button
v-if="server.deploymentStatus?.stage === 'failed'"
@click="server.clearDeploymentStatus(); showDeployForm = true"
class="mt-3 flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<RotateCcw class="w-4 h-4" />
Retry Deployment
</button>
</div>
<!-- Deploy Form (shown when not deploying) -->
<div v-else>
<div v-if="!showDeployForm" class="text-center py-4">
<p class="text-sm text-neutral-400 mb-4">Automatically install SteamCMD, download Rust Dedicated Server, configure, and start all with one click.</p>
<button
@click="showDeployForm = true"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<Rocket class="w-4 h-4" />
Deploy Server
</button>
</div>
<form v-else @submit.prevent="startDeploy" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-xs text-neutral-500 mb-1">Server Name</label>
<input v-model="deployForm.server_name" type="text" required class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Max Players</label>
<input v-model.number="deployForm.max_players" type="number" min="1" max="500" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">World Size</label>
<input v-model.number="deployForm.world_size" type="number" min="1000" max="8000" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Map Seed</label>
<input v-model.number="deployForm.seed" type="number" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Server Port</label>
<input v-model.number="deployForm.server_port" type="number" min="1024" max="65535" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">RCON Port</label>
<input v-model.number="deployForm.rcon_port" type="number" min="1024" max="65535" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors" />
</div>
<div class="col-span-2">
<label class="block text-xs text-neutral-500 mb-1">RCON Password <span class="text-red-400">*</span></label>
<input v-model="deployForm.rcon_password" type="password" required minlength="6" placeholder="Minimum 6 characters" class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors placeholder:text-neutral-600" />
</div>
</div>
<div class="flex items-center gap-3 pt-2">
<button
type="submit"
:disabled="deployLoading || !deployForm.rcon_password || deployForm.rcon_password.length < 6"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
<Loader2 v-if="deployLoading" class="w-4 h-4 animate-spin" />
<Rocket v-else class="w-4 h-4" />
{{ deployLoading ? 'Deploying...' : 'Deploy Server' }}
</button>
<button
type="button"
@click="showDeployForm = false"
class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>Cancel</button>
</div>
</form>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { Shield, Users, Star, MessageCircle, Clock, ChevronRight, Check, Zap, Terminal, RefreshCw, LayoutDashboard } from 'lucide-vue-next'
// ---------- Email capture ----------