feat: Add one-click Oxide/uMod installer — backend + frontend
All checks were successful
Build Companion Agent / build (push) Successful in 24s
Test Asgard Runner / test (push) Successful in 3s

POST /servers/install-oxide endpoint, NATS bridge for oxide.status,
server store installOxide method, ServerView Install Oxide card with
progress tracker matching the Deploy card pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-22 01:56:59 -05:00
parent 380ab2700c
commit 6461417b50
6 changed files with 159 additions and 0 deletions

View File

@@ -64,6 +64,15 @@ export const useServerStore = defineStore('server', () => {
}
}
async function installOxide() {
try {
await api.post('/servers/install-oxide')
} catch (e) {
console.error('Failed to start Oxide installation:', e)
throw e
}
}
function updateDeploymentStatus(status: DeploymentStatus) {
deploymentStatus.value = status
if (status.stage === 'online' || status.stage === 'failed') {
@@ -94,6 +103,7 @@ export const useServerStore = defineStore('server', () => {
stopServer,
restartServer,
deployServer,
installOxide,
updateDeploymentStatus,
clearDeploymentStatus,
updateStats,

View File

@@ -18,6 +18,7 @@ import {
Rocket,
AlertTriangle,
Check,
Puzzle,
} from 'lucide-vue-next'
import type { DeploymentConfig, DeploymentStatus } from '@/types'
import { useWebSocket } from '@/composables/useWebSocket'
@@ -34,6 +35,8 @@ const setupTab = ref<'linux' | 'windows'>('linux')
const windowsCopied = ref(false)
const showDeployForm = ref(false)
const deployLoading = ref(false)
const oxideStatus = ref<{ stage: string; progress: number; message: string; error?: string } | null>(null)
const isInstallingOxide = ref(false)
const deployForm = ref<DeploymentConfig>({
server_name: 'My Rust Server',
@@ -141,6 +144,42 @@ function getStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'f
return 'pending'
}
const oxideStages = [
{ key: 'fetching_release', label: 'Check Latest Release' },
{ key: 'downloading', label: 'Download Oxide' },
{ key: 'installing', label: 'Extract Files' },
{ key: 'restarting', label: 'Restart Server' },
{ key: 'complete', label: 'Complete' },
] as const
function getOxideStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'failed' {
if (!oxideStatus.value) return 'pending'
const status = oxideStatus.value
if (status.stage === 'failed') {
const currentStages = oxideStages
const idx = currentStages.findIndex(s => s.key === stageKey)
// Find which stage was active when failure occurred — approximate from message
// For failed state, mark all stages before current as complete
return idx === 0 ? 'failed' : 'pending'
}
const currentIdx = oxideStages.findIndex(s => s.key === status.stage)
const thisIdx = oxideStages.findIndex(s => s.key === stageKey)
if (thisIdx < currentIdx) return 'complete'
if (thisIdx === currentIdx) return status.stage === 'complete' ? 'complete' : 'active'
return 'pending'
}
async function installOxide() {
isInstallingOxide.value = true
oxideStatus.value = null
try {
await server.installOxide()
} catch {
toast.error('Failed to start Oxide installation')
isInstallingOxide.value = false
}
}
const form = ref({
server_name: '',
max_players: 0,
@@ -207,6 +246,12 @@ onMounted(async () => {
if (msg.type === 'event' && msg.event === 'deploy_status') {
server.updateDeploymentStatus(msg.data as DeploymentStatus)
}
if (msg.type === 'event' && msg.event === 'oxide_status') {
oxideStatus.value = msg.data as { stage: string; progress: number; message: string; error?: string }
if (msg.data && (msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed') {
isInstallingOxide.value = false
}
}
})
})
</script>
@@ -544,6 +589,82 @@ onMounted(async () => {
</div>
</div>
<!-- Install Oxide/uMod -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-5">
<Puzzle class="w-4 h-4 text-oxide-400" />
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Install Oxide / uMod</h2>
</div>
<!-- Installation Progress Tracker -->
<div v-if="oxideStatus || isInstallingOxide" class="mb-6">
<div class="space-y-3">
<div
v-for="stage in oxideStages"
: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': getOxideStageState(stage.key) === 'pending',
'bg-amber-500/20 text-amber-400': getOxideStageState(stage.key) === 'active',
'bg-green-500/20 text-green-400': getOxideStageState(stage.key) === 'complete',
'bg-red-500/20 text-red-400': getOxideStageState(stage.key) === 'failed',
}"
>
<Loader2 v-if="getOxideStageState(stage.key) === 'active'" class="w-3.5 h-3.5 animate-spin" />
<Check v-else-if="getOxideStageState(stage.key) === 'complete'" class="w-3.5 h-3.5" />
<AlertTriangle v-else-if="getOxideStageState(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': getOxideStageState(stage.key) === 'pending',
'text-amber-300 font-medium': getOxideStageState(stage.key) === 'active',
'text-green-400': getOxideStageState(stage.key) === 'complete',
'text-red-400': getOxideStageState(stage.key) === 'failed',
}"
>{{ stage.label }}</span>
</div>
</div>
<!-- Status message -->
<div v-if="oxideStatus?.message" class="mt-4 px-3 py-2 bg-neutral-800/50 rounded-lg">
<p class="text-xs text-neutral-400">{{ oxideStatus.message }}</p>
</div>
<!-- Error display -->
<div v-if="oxideStatus?.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">{{ oxideStatus.error }}</p>
</div>
<!-- Retry button on failure -->
<button
v-if="oxideStatus?.stage === 'failed'"
@click="oxideStatus = null; installOxide()"
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 Installation
</button>
</div>
<!-- Install Button (shown when not installing) -->
<div v-else class="text-center py-4">
<p class="text-sm text-neutral-400 mb-4">Install or update Oxide/uMod. Required for all plugins including CorrosionCompanion.</p>
<button
@click="installOxide()"
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"
>
<Puzzle class="w-4 h-4" />
Install Oxide
</button>
</div>
</div>
<!-- Configuration -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center justify-between mb-4">