All checks were successful
Test Asgard Runner / test (push) Successful in 3s
18 admin views re-skinned onto design-system components + tokens: Server/Players/Plugins/ChatLog, Wipes/WipeProfiles/Maps/Schedules/Alerts, StoreConfig/StoreItems/ModuleStore, Analytics/WipeAnalytics/MapAnalytics/PlayerRetention/StoreRevenue. ECharts now read var(--accent) (token-driven, follows game skin). 14 icons added to the registry. All logic/store/router/handlers/API calls preserved; presentation-only re-skin. Build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
852 lines
31 KiB
Vue
852 lines
31 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useServerStore } from '@/stores/server'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useToastStore } from '@/stores/toast'
|
|
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
|
import { useWebSocket } from '@/composables/useWebSocket'
|
|
import Panel from '@/components/ds/data/Panel.vue'
|
|
import Button from '@/components/ds/core/Button.vue'
|
|
import Badge from '@/components/ds/core/Badge.vue'
|
|
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
|
import Icon from '@/components/ds/core/Icon.vue'
|
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
|
import Input from '@/components/ds/forms/Input.vue'
|
|
import Switch from '@/components/ds/forms/Switch.vue'
|
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
|
|
|
const server = useServerStore()
|
|
const auth = useAuthStore()
|
|
const toast = useToastStore()
|
|
|
|
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 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',
|
|
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' &&
|
|
server.connection?.connection_status === 'connected'
|
|
)
|
|
|
|
const agentLastSeen = computed(() => {
|
|
const ts = server.connection?.companion_last_seen
|
|
if (!ts) return null
|
|
return new Date(ts)
|
|
})
|
|
|
|
const agentLastSeenLabel = computed(() => {
|
|
const d = agentLastSeen.value
|
|
if (!d) return 'Never'
|
|
const diff = Math.floor((Date.now() - d.getTime()) / 1000)
|
|
if (diff < 60) return `${diff}s ago`
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
return d.toLocaleDateString()
|
|
})
|
|
|
|
const licenseKey = computed(() => auth.license?.license_key || 'YOUR-LICENSE-KEY')
|
|
|
|
const linuxCommands = computed(() => `# Download the agent
|
|
curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64
|
|
chmod +x corrosion-companion-linux-amd64
|
|
|
|
# Start with your license key
|
|
export LICENSE_ID="${licenseKey.value}"
|
|
export NATS_URL="nats://nats.corrosionmgmt.com:4222"
|
|
./corrosion-companion-linux-amd64`)
|
|
|
|
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"
|
|
.\\corrosion-companion-windows-amd64.exe`)
|
|
|
|
async function copySetupCommands() {
|
|
try {
|
|
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 {
|
|
toast.error('Failed to start deployment')
|
|
} 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 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)
|
|
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,
|
|
world_size: 0,
|
|
current_seed: 0,
|
|
})
|
|
|
|
function loadFormFromConfig() {
|
|
if (server.config) {
|
|
form.value = {
|
|
server_name: server.config.server_name || '',
|
|
max_players: server.config.max_players ?? 100,
|
|
world_size: server.config.world_size ?? 4000,
|
|
current_seed: server.config.current_seed ?? 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
async function saveConfig() {
|
|
saving.value = true
|
|
try {
|
|
await server.updateConfig(form.value)
|
|
editMode.value = false
|
|
toast.success('Server configuration saved')
|
|
} catch {
|
|
toast.error('Failed to save server configuration')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function serverAction(action: 'start' | 'stop' | 'restart') {
|
|
actionLoading.value = action
|
|
try {
|
|
if (action === 'start') await server.startServer()
|
|
else if (action === 'stop') await server.stopServer()
|
|
else await server.restartServer()
|
|
await server.fetchServer()
|
|
toast.success(`Server ${action} command sent`)
|
|
} catch {
|
|
toast.error(`Failed to ${action} server`)
|
|
} finally {
|
|
actionLoading.value = null
|
|
}
|
|
}
|
|
|
|
async function toggleAutomation(field: 'crash_recovery_enabled' | 'auto_update_on_force_wipe' | 'force_wipe_eligible') {
|
|
if (!server.config) return
|
|
const newValue = !server.config[field]
|
|
try {
|
|
await server.updateConfig({ [field]: newValue })
|
|
toast.success('Automation setting saved')
|
|
} catch {
|
|
toast.error('Failed to save automation setting')
|
|
}
|
|
}
|
|
|
|
// Deploy form string bindings (DS Input uses string v-model)
|
|
const deployServerName = computed({
|
|
get: () => deployForm.value.server_name,
|
|
set: (v: string) => { deployForm.value.server_name = v },
|
|
})
|
|
const deployRconPassword = computed({
|
|
get: () => deployForm.value.rcon_password,
|
|
set: (v: string) => { deployForm.value.rcon_password = v },
|
|
})
|
|
|
|
// Config form string bindings
|
|
const formServerName = computed({
|
|
get: () => form.value.server_name,
|
|
set: (v: string) => { form.value.server_name = v },
|
|
})
|
|
|
|
// Setup tab items
|
|
const setupTabItems = [
|
|
{ value: 'linux', label: 'Linux' },
|
|
{ value: 'windows', label: 'Windows' },
|
|
]
|
|
|
|
// Connection status derived values
|
|
const connStatusTone = computed<'online' | 'offline' | 'warn'>(() => {
|
|
const cs = server.connection?.connection_status
|
|
if (cs === 'connected') return 'online'
|
|
if (cs === 'degraded') return 'warn'
|
|
return 'offline'
|
|
})
|
|
|
|
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)
|
|
}
|
|
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>
|
|
|
|
<template>
|
|
<div class="sv">
|
|
<!-- Page head -->
|
|
<div class="sv__head">
|
|
<div class="sv__head-id">
|
|
<div class="sv__head-chip">
|
|
<Icon name="server" :size="20" :stroke-width="2" />
|
|
</div>
|
|
<div>
|
|
<div class="t-eyebrow">Server management</div>
|
|
<h1 class="sv__title">Server</h1>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Connection -->
|
|
<Panel title="Connection">
|
|
<template #actions>
|
|
<Badge :tone="connStatusTone" :dot="true" :pulse="connStatusTone === 'online'">
|
|
{{ server.connection?.connection_status ?? 'Unknown' }}
|
|
</Badge>
|
|
</template>
|
|
<div class="sv__grid4">
|
|
<div class="sv__field">
|
|
<div class="sv__field-label">Status</div>
|
|
<div class="sv__field-val sv__field-val--inline">
|
|
<StatusDot :tone="connStatusTone" :pulse="connStatusTone === 'online'" />
|
|
<span>{{ server.connection?.connection_status ?? 'Unknown' }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="sv__field">
|
|
<div class="sv__field-label">Connection type</div>
|
|
<div class="sv__field-val sv__field-val--mono">{{ server.connection?.connection_type ?? '—' }}</div>
|
|
</div>
|
|
<div class="sv__field">
|
|
<div class="sv__field-label">Server IP</div>
|
|
<div class="sv__field-val sv__field-val--mono">
|
|
{{ server.connection?.server_ip ?? '—' }}{{ server.connection?.server_port ? `:${server.connection.server_port}` : '' }}
|
|
</div>
|
|
</div>
|
|
<div class="sv__field">
|
|
<div class="sv__field-label">Game port</div>
|
|
<div class="sv__field-val sv__field-val--mono">{{ server.connection?.game_port ?? '—' }}</div>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
<!-- Controls -->
|
|
<Panel title="Controls">
|
|
<div class="sv__controls">
|
|
<Button
|
|
variant="outline"
|
|
icon="play"
|
|
:loading="actionLoading === 'start'"
|
|
:disabled="server.connection?.connection_status === 'connected' || actionLoading !== null"
|
|
@click="serverAction('start')"
|
|
>Start server</Button>
|
|
<Button
|
|
variant="danger-soft"
|
|
icon="power"
|
|
:loading="actionLoading === 'stop'"
|
|
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null"
|
|
@click="serverAction('stop')"
|
|
>Stop server</Button>
|
|
<Button
|
|
variant="secondary"
|
|
icon="refresh-cw"
|
|
:loading="actionLoading === 'restart'"
|
|
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null"
|
|
@click="serverAction('restart')"
|
|
>Restart server</Button>
|
|
</div>
|
|
</Panel>
|
|
|
|
<!-- Companion agent -->
|
|
<Panel title="Companion agent" subtitle="Bare-metal server management binary">
|
|
<template #actions>
|
|
<Badge :tone="isAgentConnected ? 'online' : 'offline'" :dot="true" :pulse="isAgentConnected">
|
|
{{ isAgentConnected ? 'Active' : 'Inactive' }}
|
|
</Badge>
|
|
</template>
|
|
|
|
<!-- Agent status row -->
|
|
<div class="sv__grid3 sv__mb">
|
|
<div class="sv__field">
|
|
<div class="sv__field-label">Agent status</div>
|
|
<div class="sv__field-val sv__field-val--inline">
|
|
<StatusDot :tone="isAgentConnected ? 'online' : 'offline'" :pulse="isAgentConnected" />
|
|
<span>{{ isAgentConnected ? 'Active' : 'Inactive' }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="sv__field">
|
|
<div class="sv__field-label">Connection type</div>
|
|
<div class="sv__field-val sv__field-val--mono">{{ server.connection?.connection_type ?? '—' }}</div>
|
|
</div>
|
|
<div class="sv__field">
|
|
<div class="sv__field-label">Last heartbeat</div>
|
|
<div class="sv__field-val">{{ agentLastSeenLabel }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Download -->
|
|
<div class="sv__section-head">
|
|
<Icon name="download" :size="14" />
|
|
<span>Download companion agent</span>
|
|
</div>
|
|
<div class="sv__downloads sv__mb">
|
|
<a
|
|
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64"
|
|
download="corrosion-companion-linux-amd64"
|
|
class="sv__dl-link"
|
|
>
|
|
<Icon name="download" :size="15" />
|
|
Linux (amd64)
|
|
</a>
|
|
<a
|
|
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"
|
|
download="corrosion-companion-windows-amd64.exe"
|
|
class="sv__dl-link"
|
|
>
|
|
<Icon name="download" :size="15" />
|
|
Windows (amd64)
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Quick setup -->
|
|
<div class="sv__setup-head">
|
|
<div class="sv__section-head">
|
|
<Icon name="terminal" :size="14" />
|
|
<span>Quick setup</span>
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
:icon="(setupTab === 'linux' ? copied : windowsCopied) ? 'check' : 'copy'"
|
|
@click="copySetupCommands"
|
|
>{{ (setupTab === 'linux' ? copied : windowsCopied) ? 'Copied' : 'Copy' }}</Button>
|
|
</div>
|
|
|
|
<Tabs v-model="setupTab" :items="setupTabItems" class="sv__mb-sm" />
|
|
|
|
<Alert v-if="setupTab === 'windows'" tone="warn" class="sv__mb-sm">
|
|
PowerShell required — Command Prompt is not supported.
|
|
</Alert>
|
|
|
|
<!-- Linux commands -->
|
|
<div v-if="setupTab === 'linux'" class="sv__codeblock">
|
|
<p class="sv__cmt"># 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>
|
|
<p class="sv__cmt sv__mt"># Start with your license key</p>
|
|
<p>export LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
|
|
<p>export NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
|
<p>./corrosion-companion-linux-amd64</p>
|
|
</div>
|
|
|
|
<!-- Windows commands -->
|
|
<div v-if="setupTab === 'windows'" class="sv__codeblock">
|
|
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
|
|
<p class="sv__cmt"># Download the agent</p>
|
|
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-companion-windows-amd64.exe"</span></p>
|
|
<p class="sv__cmt sv__mt"># Start with your license key</p>
|
|
<p>$env:LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
|
|
<p>$env:NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
|
<p>.\corrosion-companion-windows-amd64.exe</p>
|
|
</div>
|
|
</Panel>
|
|
|
|
<!-- Deploy Rust Server -->
|
|
<Panel title="Deploy Rust server" subtitle="One-click: SteamCMD, download, configure, start">
|
|
<template #title-append>
|
|
<Icon name="rocket" :size="15" />
|
|
</template>
|
|
|
|
<!-- Deployment progress -->
|
|
<div v-if="server.deploymentStatus || server.isDeploying" class="sv__stages">
|
|
<div
|
|
v-for="stage in deployStages"
|
|
:key="stage.key"
|
|
class="sv__stage"
|
|
>
|
|
<div
|
|
class="sv__stage-dot"
|
|
:data-state="getStageState(stage.key)"
|
|
>
|
|
<Icon v-if="getStageState(stage.key) === 'active'" name="loader" :size="13" class="sv__spin" />
|
|
<Icon v-else-if="getStageState(stage.key) === 'complete'" name="check" :size="13" />
|
|
<Icon v-else-if="getStageState(stage.key) === 'failed'" name="triangle-alert" :size="13" />
|
|
<span v-else class="sv__stage-nub" />
|
|
</div>
|
|
<span
|
|
class="sv__stage-label"
|
|
:data-state="getStageState(stage.key)"
|
|
>{{ stage.label }}</span>
|
|
</div>
|
|
|
|
<Alert v-if="server.deploymentStatus?.message" tone="neutral" class="sv__mt">
|
|
{{ server.deploymentStatus.message }}
|
|
</Alert>
|
|
<Alert v-if="server.deploymentStatus?.error" tone="danger" class="sv__mt">
|
|
{{ server.deploymentStatus.error }}
|
|
</Alert>
|
|
|
|
<div v-if="server.deploymentStatus?.stage === 'failed'" class="sv__mt">
|
|
<Button
|
|
variant="secondary"
|
|
icon="refresh-cw"
|
|
@click="server.clearDeploymentStatus(); showDeployForm = true"
|
|
>Retry deployment</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Deploy form / prompt -->
|
|
<div v-else>
|
|
<div v-if="!showDeployForm" class="sv__deploy-prompt">
|
|
<p class="sv__deploy-desc">Automatically install SteamCMD, download Rust Dedicated Server, configure, and start — all with one click.</p>
|
|
<Button icon="rocket" @click="showDeployForm = true">Deploy server</Button>
|
|
</div>
|
|
|
|
<form v-else @submit.prevent="startDeploy" class="sv__form">
|
|
<div class="sv__form-grid">
|
|
<Input
|
|
v-model="deployServerName"
|
|
label="Server name"
|
|
:required="true"
|
|
class="sv__col-span2"
|
|
/>
|
|
<Input
|
|
:model-value="String(deployForm.max_players)"
|
|
@update:model-value="v => { deployForm.max_players = Number(v) }"
|
|
label="Max players"
|
|
type="number"
|
|
:mono="true"
|
|
/>
|
|
<Input
|
|
:model-value="String(deployForm.world_size)"
|
|
@update:model-value="v => { deployForm.world_size = Number(v) }"
|
|
label="World size"
|
|
type="number"
|
|
:mono="true"
|
|
/>
|
|
<Input
|
|
:model-value="String(deployForm.seed)"
|
|
@update:model-value="v => { deployForm.seed = Number(v) }"
|
|
label="Map seed"
|
|
type="number"
|
|
:mono="true"
|
|
/>
|
|
<Input
|
|
:model-value="String(deployForm.server_port)"
|
|
@update:model-value="v => { deployForm.server_port = Number(v) }"
|
|
label="Server port"
|
|
type="number"
|
|
:mono="true"
|
|
/>
|
|
<Input
|
|
:model-value="String(deployForm.rcon_port)"
|
|
@update:model-value="v => { deployForm.rcon_port = Number(v) }"
|
|
label="RCON port"
|
|
type="number"
|
|
:mono="true"
|
|
/>
|
|
<Input
|
|
v-model="deployRconPassword"
|
|
label="RCON password"
|
|
type="password"
|
|
placeholder="Minimum 6 characters"
|
|
:required="true"
|
|
class="sv__col-span2"
|
|
/>
|
|
</div>
|
|
<div class="sv__form-actions">
|
|
<Button
|
|
type="submit"
|
|
icon="rocket"
|
|
:loading="deployLoading"
|
|
:disabled="deployLoading || !deployForm.rcon_password || deployForm.rcon_password.length < 6"
|
|
>{{ deployLoading ? 'Deploying…' : 'Deploy server' }}</Button>
|
|
<Button variant="ghost" type="button" @click="showDeployForm = false">Cancel</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</Panel>
|
|
|
|
<!-- Install Oxide / uMod -->
|
|
<Panel title="Install Oxide / uMod" subtitle="Required for all plugins including CorrosionCompanion">
|
|
<template #title-append>
|
|
<Icon name="puzzle" :size="15" />
|
|
</template>
|
|
|
|
<!-- Installation progress -->
|
|
<div v-if="oxideStatus || isInstallingOxide" class="sv__stages">
|
|
<div
|
|
v-for="stage in oxideStages"
|
|
:key="stage.key"
|
|
class="sv__stage"
|
|
>
|
|
<div
|
|
class="sv__stage-dot"
|
|
:data-state="getOxideStageState(stage.key)"
|
|
>
|
|
<Icon v-if="getOxideStageState(stage.key) === 'active'" name="loader" :size="13" class="sv__spin" />
|
|
<Icon v-else-if="getOxideStageState(stage.key) === 'complete'" name="check" :size="13" />
|
|
<Icon v-else-if="getOxideStageState(stage.key) === 'failed'" name="triangle-alert" :size="13" />
|
|
<span v-else class="sv__stage-nub" />
|
|
</div>
|
|
<span
|
|
class="sv__stage-label"
|
|
:data-state="getOxideStageState(stage.key)"
|
|
>{{ stage.label }}</span>
|
|
</div>
|
|
|
|
<Alert v-if="oxideStatus?.message" tone="neutral" class="sv__mt">
|
|
{{ oxideStatus.message }}
|
|
</Alert>
|
|
<Alert v-if="oxideStatus?.error" tone="danger" class="sv__mt">
|
|
{{ oxideStatus.error }}
|
|
</Alert>
|
|
|
|
<div v-if="oxideStatus?.stage === 'failed'" class="sv__mt">
|
|
<Button
|
|
variant="secondary"
|
|
icon="refresh-cw"
|
|
@click="oxideStatus = null; installOxide()"
|
|
>Retry installation</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Install prompt -->
|
|
<div v-else class="sv__deploy-prompt">
|
|
<p class="sv__deploy-desc">Install or update Oxide / uMod. Required for all plugins including CorrosionCompanion.</p>
|
|
<Button icon="puzzle" @click="installOxide()">Install Oxide</Button>
|
|
</div>
|
|
</Panel>
|
|
|
|
<!-- Configuration -->
|
|
<Panel title="Configuration">
|
|
<template #actions>
|
|
<Button
|
|
v-if="!editMode"
|
|
variant="ghost"
|
|
size="sm"
|
|
icon="settings"
|
|
@click="editMode = true; loadFormFromConfig()"
|
|
>Edit</Button>
|
|
<template v-else>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
type="button"
|
|
@click="editMode = false"
|
|
>Cancel</Button>
|
|
<Button
|
|
size="sm"
|
|
icon="check"
|
|
:loading="saving"
|
|
:disabled="saving"
|
|
@click="saveConfig"
|
|
>Save changes</Button>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- Read mode -->
|
|
<div v-if="!editMode" class="sv__grid4">
|
|
<div class="sv__field">
|
|
<div class="sv__field-label">Server name</div>
|
|
<div class="sv__field-val">{{ server.config?.server_name ?? 'Not configured' }}</div>
|
|
</div>
|
|
<div class="sv__field">
|
|
<div class="sv__field-label">Max players</div>
|
|
<div class="sv__field-val sv__field-val--mono">{{ server.config?.max_players ?? '—' }}</div>
|
|
</div>
|
|
<div class="sv__field">
|
|
<div class="sv__field-label">World size</div>
|
|
<div class="sv__field-val sv__field-val--mono">{{ server.config?.world_size ?? '—' }}</div>
|
|
</div>
|
|
<div class="sv__field">
|
|
<div class="sv__field-label">Current seed</div>
|
|
<div class="sv__field-val sv__field-val--mono">{{ server.config?.current_seed ?? '—' }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit mode -->
|
|
<form v-else @submit.prevent="saveConfig" class="sv__form">
|
|
<div class="sv__form-grid">
|
|
<Input
|
|
v-model="formServerName"
|
|
label="Server name"
|
|
class="sv__col-span2"
|
|
/>
|
|
<Input
|
|
:model-value="String(form.max_players)"
|
|
@update:model-value="v => { form.max_players = Number(v) }"
|
|
label="Max players"
|
|
type="number"
|
|
:mono="true"
|
|
/>
|
|
<Input
|
|
:model-value="String(form.world_size)"
|
|
@update:model-value="v => { form.world_size = Number(v) }"
|
|
label="World size"
|
|
type="number"
|
|
:mono="true"
|
|
/>
|
|
<Input
|
|
:model-value="String(form.current_seed)"
|
|
@update:model-value="v => { form.current_seed = Number(v) }"
|
|
label="Current seed"
|
|
type="number"
|
|
:mono="true"
|
|
class="sv__col-span2"
|
|
/>
|
|
</div>
|
|
</form>
|
|
</Panel>
|
|
|
|
<!-- Automation -->
|
|
<Panel title="Automation">
|
|
<div class="sv__toggles">
|
|
<div class="sv__toggle-row">
|
|
<div class="sv__toggle-body">
|
|
<div class="sv__toggle-label">Auto-restart</div>
|
|
<div class="sv__toggle-sub">Restart on crash detection</div>
|
|
</div>
|
|
<Switch
|
|
:model-value="server.config?.crash_recovery_enabled ?? false"
|
|
:disabled="!server.config"
|
|
@update:model-value="toggleAutomation('crash_recovery_enabled')"
|
|
/>
|
|
</div>
|
|
<div class="sv__toggle-row">
|
|
<div class="sv__toggle-body">
|
|
<div class="sv__toggle-label">Auto-update on force wipe</div>
|
|
<div class="sv__toggle-sub">Update when Facepunch pushes</div>
|
|
</div>
|
|
<Switch
|
|
:model-value="server.config?.auto_update_on_force_wipe ?? false"
|
|
:disabled="!server.config"
|
|
@update:model-value="toggleAutomation('auto_update_on_force_wipe')"
|
|
/>
|
|
</div>
|
|
<div class="sv__toggle-row">
|
|
<div class="sv__toggle-body">
|
|
<div class="sv__toggle-label">Force wipe eligible</div>
|
|
<div class="sv__toggle-sub">Server participates in force wipes</div>
|
|
</div>
|
|
<Switch
|
|
:model-value="server.config?.force_wipe_eligible ?? false"
|
|
:disabled="!server.config"
|
|
@update:model-value="toggleAutomation('force_wipe_eligible')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.sv { max-width: 900px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
|
|
|
/* Page head */
|
|
.sv__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
|
.sv__head-id { display: flex; align-items: center; gap: 12px; }
|
|
.sv__head-chip {
|
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--accent); background: var(--accent-soft);
|
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
|
}
|
|
.sv__title {
|
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
|
color: var(--text-primary); margin-top: 3px;
|
|
}
|
|
|
|
/* Layout grids */
|
|
.sv__grid4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
|
|
.sv__grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
|
@media (max-width: 768px) {
|
|
.sv__grid4 { grid-template-columns: repeat(2, 1fr); }
|
|
.sv__grid3 { grid-template-columns: repeat(2, 1fr); }
|
|
}
|
|
|
|
/* Field */
|
|
.sv__field-label { font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: 4px; }
|
|
.sv__field-val { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
|
.sv__field-val--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
|
.sv__field-val--inline { display: flex; align-items: center; gap: 7px; }
|
|
|
|
/* Spacing helpers */
|
|
.sv__mb { margin-bottom: 20px; }
|
|
.sv__mb-sm { margin-bottom: 12px; }
|
|
.sv__mt { margin-top: 12px; }
|
|
|
|
/* Controls */
|
|
.sv__controls { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
|
|
/* Section head (label inside panel body) */
|
|
.sv__section-head {
|
|
display: flex; align-items: center; gap: 7px;
|
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
/* Download links */
|
|
.sv__downloads { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
.sv__dl-link {
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
height: var(--control-h-md); padding: 0 14px;
|
|
background: var(--surface-raised-2); border-radius: var(--radius-md);
|
|
box-shadow: var(--ring-default);
|
|
font-size: var(--text-sm); font-weight: 600; color: var(--text-primary);
|
|
text-decoration: none; transition: var(--transition-colors);
|
|
}
|
|
.sv__dl-link:hover { background: var(--surface-active); }
|
|
|
|
/* Setup head */
|
|
.sv__setup-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
|
|
|
/* Code block */
|
|
.sv__codeblock {
|
|
background: var(--surface-inset); border-radius: var(--radius-md);
|
|
box-shadow: var(--ring-default);
|
|
padding: 14px 16px; font-family: var(--font-mono); font-size: var(--text-xs);
|
|
color: var(--text-secondary); overflow-x: auto; line-height: 1.7;
|
|
}
|
|
.sv__cmt { color: var(--text-muted); }
|
|
.sv__accent { color: var(--accent-text); }
|
|
.sv__mt { margin-top: 12px; }
|
|
|
|
/* Deploy prompt */
|
|
.sv__deploy-prompt { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 14px; padding: 12px 0; }
|
|
.sv__deploy-desc { font-size: var(--text-sm); color: var(--text-tertiary); max-width: 420px; line-height: 1.5; }
|
|
|
|
/* Form */
|
|
.sv__form { display: flex; flex-direction: column; gap: 16px; }
|
|
.sv__form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
|
|
.sv__col-span2 { grid-column: 1 / -1; }
|
|
.sv__form-actions { display: flex; align-items: center; gap: 10px; }
|
|
|
|
/* Deployment / Oxide stages */
|
|
.sv__stages { display: flex; flex-direction: column; gap: 10px; }
|
|
.sv__stage { display: flex; align-items: center; gap: 11px; }
|
|
.sv__stage-dot {
|
|
width: 24px; height: 24px; border-radius: 50%; flex: none;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: var(--surface-raised-2); color: var(--text-muted);
|
|
box-shadow: var(--ring-default);
|
|
}
|
|
.sv__stage-dot[data-state="active"] { background: var(--status-warn-soft); color: var(--status-warn); box-shadow: inset 0 0 0 1px var(--status-warn-border); }
|
|
.sv__stage-dot[data-state="complete"] { background: var(--status-online-soft); color: var(--status-online); box-shadow: inset 0 0 0 1px var(--status-online-border); }
|
|
.sv__stage-dot[data-state="failed"] { background: var(--status-offline-soft); color: var(--status-offline); box-shadow: inset 0 0 0 1px var(--status-offline-border); }
|
|
.sv__stage-nub { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted); }
|
|
.sv__stage-label { font-size: var(--text-sm); color: var(--text-tertiary); }
|
|
.sv__stage-label[data-state="active"] { color: var(--status-warn); font-weight: 600; }
|
|
.sv__stage-label[data-state="complete"] { color: var(--status-online); }
|
|
.sv__stage-label[data-state="failed"] { color: var(--status-offline); }
|
|
|
|
@keyframes sv-spin { to { transform: rotate(360deg); } }
|
|
.sv__spin { animation: sv-spin 0.7s linear infinite; }
|
|
|
|
/* Automation toggles */
|
|
.sv__toggles { display: flex; flex-direction: column; gap: 0; }
|
|
.sv__toggle-row {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
gap: 16px; padding: 14px 0;
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
}
|
|
.sv__toggle-row:last-child { border-bottom: 0; padding-bottom: 0; }
|
|
.sv__toggle-row:first-child { padding-top: 0; }
|
|
.sv__toggle-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
|
.sv__toggle-sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
|
</style>
|