feat: Add one-click Oxide/uMod installer — backend + frontend
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:
@@ -73,4 +73,11 @@ export class ServersController {
|
|||||||
) {
|
) {
|
||||||
return await this.serversService.deployServer(licenseId, dto);
|
return await this.serversService.deployServer(licenseId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('install-oxide')
|
||||||
|
@RequirePermission('server.manage')
|
||||||
|
@ApiOperation({ summary: 'Install Oxide/uMod via companion agent' })
|
||||||
|
async installOxide(@CurrentTenant() licenseId: string) {
|
||||||
|
return await this.serversService.installOxide(licenseId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,4 +103,12 @@ export class ServersService {
|
|||||||
await this.natsService.sendDeployCommand(licenseId, { ...dto });
|
await this.natsService.sendDeployCommand(licenseId, { ...dto });
|
||||||
return { message: 'Deployment started' };
|
return { message: 'Deployment started' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Oxide/uMod via companion agent
|
||||||
|
*/
|
||||||
|
async installOxide(licenseId: string) {
|
||||||
|
await this.natsService.sendOxideInstallCommand(licenseId);
|
||||||
|
return { message: 'Oxide installation started' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export class NatsBridgeService implements OnModuleInit {
|
|||||||
this.emit(licenseId, 'deploy_status', data);
|
this.emit(licenseId, 'deploy_status', data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.nats.subscribe('corrosion.*.oxide.status', (data, subject) => {
|
||||||
|
const licenseId = subject.split('.')[1];
|
||||||
|
this.emit(licenseId, 'oxide_status', data);
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log('NATS bridge subscriptions initialized');
|
this.logger.log('NATS bridge subscriptions initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,4 +79,12 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Publish an Oxide install command to a specific license's companion agent */
|
||||||
|
async sendOxideInstallCommand(licenseId: string): Promise<void> {
|
||||||
|
await this.publish(`corrosion.${licenseId}.cmd.oxide`, {
|
||||||
|
action: 'install_oxide',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
function updateDeploymentStatus(status: DeploymentStatus) {
|
||||||
deploymentStatus.value = status
|
deploymentStatus.value = status
|
||||||
if (status.stage === 'online' || status.stage === 'failed') {
|
if (status.stage === 'online' || status.stage === 'failed') {
|
||||||
@@ -94,6 +103,7 @@ export const useServerStore = defineStore('server', () => {
|
|||||||
stopServer,
|
stopServer,
|
||||||
restartServer,
|
restartServer,
|
||||||
deployServer,
|
deployServer,
|
||||||
|
installOxide,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
clearDeploymentStatus,
|
clearDeploymentStatus,
|
||||||
updateStats,
|
updateStats,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Rocket,
|
Rocket,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
|
Puzzle,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
@@ -34,6 +35,8 @@ const setupTab = ref<'linux' | 'windows'>('linux')
|
|||||||
const windowsCopied = ref(false)
|
const windowsCopied = ref(false)
|
||||||
const showDeployForm = ref(false)
|
const showDeployForm = ref(false)
|
||||||
const deployLoading = 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>({
|
const deployForm = ref<DeploymentConfig>({
|
||||||
server_name: 'My Rust Server',
|
server_name: 'My Rust Server',
|
||||||
@@ -141,6 +144,42 @@ function getStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'f
|
|||||||
return 'pending'
|
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({
|
const form = ref({
|
||||||
server_name: '',
|
server_name: '',
|
||||||
max_players: 0,
|
max_players: 0,
|
||||||
@@ -207,6 +246,12 @@ onMounted(async () => {
|
|||||||
if (msg.type === 'event' && msg.event === 'deploy_status') {
|
if (msg.type === 'event' && msg.event === 'deploy_status') {
|
||||||
server.updateDeploymentStatus(msg.data as DeploymentStatus)
|
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>
|
</script>
|
||||||
@@ -544,6 +589,82 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Configuration -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user