feat: Add frontend support for one-click Rust server deployment
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
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:
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">"<your-nats-token>"</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user