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>
This commit is contained in:
Vantz Stockwell
2026-02-21 14:48:05 -05:00
parent 834e17e7cf
commit b94717d51b
3 changed files with 313 additions and 17 deletions

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

@@ -14,7 +14,14 @@ import {
Download,
Terminal,
Monitor,
Rocket,
AlertTriangle,
Check,
ClipboardCopy,
ChevronRight,
} from 'lucide-vue-next'
import type { DeploymentConfig, DeploymentStatus } from '@/types'
import { useWebSocket } from '@/composables/useWebSocket'
const server = useServerStore()
const auth = useAuthStore()
@@ -23,6 +30,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' &&
@@ -58,16 +79,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 +192,13 @@ async function serverAction(action: 'start' | 'stop' | 'restart') {
onMounted(async () => {
await server.fetchServer()
loadFormFromConfig()
const ws = useWebSocket()
ws.on('event', (data: any) => {
if (data.event === 'deploy_status') {
server.updateDeploymentStatus(data.data as DeploymentStatus)
}
})
})
</script>
@@ -262,24 +346,47 @@ 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://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64</p>
<p>chmod +x corrosion-companion-linux-amd64</p>
@@ -290,6 +397,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>