feat(panel): Beta sweep — multi-game coherence, honesty, UX fixes
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 22s

Multi-game rebrand (no more Rust-only leftovers): game-neutral setup wizard +
deploy/store defaults; player-id labels driven by game profile (Steam ID only
for Rust); blueprint wipe type + verify-plugins gated to uMod games; oxide
command examples + Rust-only plugin pages (AutoDoors/FurnaceSplitter/BetterChat)
guarded behind mods==='umod' with empty-states for other games.

Honesty: webstore checkout shows coming-soon (backend now 503s); 'integrated
webstore' marketed as coming-soon; Discord references neutralized to
community/webhook; migration FAQ marked in-development; analytics dev phase
labels removed; Network pricing tier set to Custom/Contact (was a confusing
duplicate of Operator); docs/PRICING.md rewritten to match live subscriptions.

UX/bugs: fixed ServerView oxide-status operator-precedence bug; dead 'Deploy
server' button wired; non-functional topbar search removed; alert()/confirm()
replaced with toasts across schedules/alerts/migration/public store+server;
analytics chart arrays null-guarded; production console.logs gated to DEV.

Frontend build (vue-tsc + vite) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 22:06:10 -04:00
parent f2ea415840
commit 6f783bfac8
28 changed files with 265 additions and 99 deletions

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -30,6 +31,7 @@ interface AlertHistoryEntry {
}
const api = useApi()
const toast = useToastStore()
const config = ref<AlertConfig>({
population_drop_enabled: false,
population_drop_threshold_percent: 50,
@@ -60,9 +62,9 @@ async function saveConfig() {
isSaving.value = true
try {
await api.put('/alerts/config', config.value)
alert('Alert configuration saved')
toast.success('Alert configuration saved')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to save configuration')
toast.error(err instanceof Error ? err.message : 'Failed to save configuration')
} finally {
isSaving.value = false
}

View File

@@ -98,7 +98,7 @@ const renderCharts = () => {
},
xAxis: {
type: 'category',
data: timeseries.value.timestamps.map(ts => new Date(ts).toLocaleString('en-US', {
data: (timeseries.value.timestamps ?? []).map(ts => new Date(ts).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit'
@@ -116,7 +116,7 @@ const renderCharts = () => {
{
name: 'Players',
type: 'line',
data: timeseries.value.player_count,
data: timeseries.value.player_count ?? [],
smooth: true,
lineStyle: { color: accent, width: 2 },
areaStyle: {
@@ -160,7 +160,7 @@ const renderCharts = () => {
},
xAxis: {
type: 'category',
data: timeseries.value.timestamps.map(ts => new Date(ts).toLocaleString('en-US', {
data: (timeseries.value.timestamps ?? []).map(ts => new Date(ts).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit'
@@ -191,7 +191,7 @@ const renderCharts = () => {
name: 'FPS',
type: 'line',
yAxisIndex: 0,
data: timeseries.value.fps,
data: timeseries.value.fps ?? [],
smooth: true,
lineStyle: { color: '#10b981', width: 2 },
itemStyle: { color: '#10b981' }
@@ -200,7 +200,7 @@ const renderCharts = () => {
name: 'Entities',
type: 'line',
yAxisIndex: 1,
data: timeseries.value.entity_count,
data: timeseries.value.entity_count ?? [],
smooth: true,
lineStyle: { color: '#6366f1', width: 2 },
itemStyle: { color: '#6366f1' }
@@ -287,7 +287,7 @@ onMounted(() => {
label="Unique players"
:value="summary.unique_players ?? '—'"
icon="bar-chart-3"
note="Phase 2.2"
note="Coming soon"
/>
</div>
@@ -302,9 +302,9 @@ onMounted(() => {
</div>
<!-- Player Retention placeholder -->
<Panel eyebrow="Coming in phase 2" title="Player retention">
<Panel eyebrow="Coming soon" title="Player retention">
<template #title-append>
<Badge tone="neutral">Phase 2</Badge>
<Badge tone="neutral">Coming soon</Badge>
</template>
<div class="analytics-view__retention-grid">
<div class="analytics-view__retention-cell">
@@ -324,7 +324,7 @@ onMounted(() => {
</div>
</div>
<p class="analytics-view__retention-footer">
Player retention analytics will be available in phase 2.
Player retention analytics are coming soon.
</p>
</Panel>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useAutoDoorsStore } from '@/stores/autodoors'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.vue'
@@ -8,6 +10,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useAutoDoorsStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const showCreateModal = ref(false)
const showImportModal = ref(false)
@@ -159,6 +163,16 @@ function getBool(path: string, def: boolean): boolean {
<template>
<div class="adv">
<!-- uMod-only guard: AutoDoors is an Oxide/uMod plugin -->
<Panel v-if="gameProfile.mods !== 'umod'">
<EmptyState
icon="door-open"
title="Rust / uMod only"
description="Auto Doors is only available for Rust (uMod/Oxide) servers."
/>
</Panel>
<template v-else>
<!-- Page head -->
<div class="adv__head">
<div class="adv__head-id">
@@ -504,6 +518,7 @@ function getBool(path: string, def: boolean): boolean {
</div>
</div>
</div>
</template>
</div>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useBetterChatStore } from '@/stores/betterchat'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -13,6 +15,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useBetterChatStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const activeTab = ref<string>('groups')
const showCreateModal = ref(false)
@@ -276,6 +280,16 @@ const editGroupFormatConsole = computed<string>({
<template>
<div class="bch">
<!-- uMod-only guard: BetterChat is an Oxide/uMod plugin -->
<Panel v-if="gameProfile.mods !== 'umod'">
<EmptyState
icon="message-square"
title="Rust / uMod only"
description="Better Chat is only available for Rust (uMod/Oxide) servers."
/>
</Panel>
<template v-else>
<!-- Page head -->
<div class="bch__head">
<div class="bch__head-id">
@@ -696,6 +710,7 @@ const editGroupFormatConsole = computed<string>({
</div>
</div>
</div>
</template>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import type { ChatMessage } from '@/types'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -14,6 +15,8 @@ import Tabs from '@/components/ds/navigation/Tabs.vue'
const api = useApi()
const toast = useToastStore()
const { activeGame } = useThemeGame()
const playerIdLabel = computed(() => activeGame.value === 'rust' || activeGame.value === 'all' ? 'Steam ID' : 'Player ID')
const messages = ref<ChatMessage[]>([])
const isLoading = ref(false)
@@ -122,7 +125,7 @@ onMounted(() => {
<Input
v-model="searchQuery"
icon="search"
placeholder="Search messages, players, or Steam IDs…"
:placeholder="`Search messages, players, or ${playerIdLabel}s…`"
size="sm"
style="max-width: 340px;"
/>

View File

@@ -383,7 +383,7 @@ function navServer() { router.push('/server') }
v-model="consoleInput"
:mono="true"
size="sm"
placeholder="say, kick, ban, oxide.reload …"
:placeholder="profile.mods === 'umod' ? 'say, kick, ban, oxide.reload …' : 'say, kick, ban …'"
:disabled="!isConnected"
style="flex: 1"
@keydown.enter="sendConsoleCommand"

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.vue'
@@ -8,6 +10,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useFurnaceSplitterStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const showCreateModal = ref(false)
const showImportModal = ref(false)
@@ -116,6 +120,16 @@ function getBool(path: string, def: boolean): boolean {
<template>
<div class="fsv">
<!-- uMod-only guard: Furnace Splitter is an Oxide/uMod plugin -->
<Panel v-if="gameProfile.mods !== 'umod'">
<EmptyState
icon="flame"
title="Rust / uMod only"
description="Furnace Splitter is only available for Rust (uMod/Oxide) servers."
/>
</Panel>
<template v-else>
<!-- Page head -->
<div class="fsv__head">
<div class="fsv__head-id">
@@ -326,6 +340,7 @@ function getBool(path: string, def: boolean): boolean {
</div>
</div>
</div>
</template>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { safeFileSize, safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -20,6 +21,7 @@ interface ExportRecord {
const api = useApi()
const authStore = useAuthStore()
const toast = useToastStore()
const exports = ref<ExportRecord[]>([])
const isExporting = ref(false)
const isImporting = ref(false)
@@ -37,7 +39,7 @@ async function createExport() {
isExporting.value = true
try {
const result = await api.post<{ export_id: string }>('/migration/export', { export_type: exportType.value })
alert(`Export created: ${result.export_id}`)
toast.success(`Export created: ${result.export_id}`)
await fetchExports()
} finally {
isExporting.value = false

View File

@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
import { useServerStore } from '@/stores/server'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
@@ -15,6 +16,8 @@ import Tabs from '@/components/ds/navigation/Tabs.vue'
const server = useServerStore()
const api = useApi()
const toast = useToastStore()
const { activeGame } = useThemeGame()
const playerIdLabel = computed(() => activeGame.value === 'rust' || activeGame.value === 'all' ? 'Steam ID' : 'Player ID')
interface Player {
steam_id: string
@@ -166,7 +169,7 @@ onMounted(() => {
<Input
v-model="searchQuery"
icon="search"
placeholder="Search by name or Steam ID…"
:placeholder="`Search by name or ${playerIdLabel}…`"
size="sm"
:mono="false"
style="max-width: 320px;"
@@ -197,7 +200,7 @@ onMounted(() => {
<thead>
<tr>
<th>Player</th>
<th>Steam ID</th>
<th>{{ playerIdLabel }}</th>
<th>Status</th>
<th>Session</th>
<th>Playtime</th>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -22,6 +23,7 @@ interface ScheduledTask {
}
const api = useApi()
const toast = useToastStore()
const tasks = ref<ScheduledTask[]>([])
const isLoading = ref(false)
const showModal = ref(false)
@@ -93,7 +95,7 @@ async function saveTask() {
showModal.value = false
await fetchTasks()
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to save task')
toast.error(err instanceof Error ? err.message : 'Failed to save task')
}
}

View File

@@ -108,7 +108,7 @@ const showCreds = ref(false)
const tomlCopied = ref(false)
const deployForm = ref<DeploymentConfig>({
server_name: 'My Rust Server',
server_name: '',
max_players: 100,
world_size: 4000,
seed: Math.floor(Math.random() * 2147483647),
@@ -465,7 +465,7 @@ onMounted(async () => {
}
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') {
if (msg.data && ((msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed')) {
isInstallingOxide.value = false
}
}
@@ -935,7 +935,7 @@ onMounted(async () => {
<!-- Conan Exiles special concepts (Clans / Thralls / Purge) -->
<Panel
v-if="profile.accent === 'conan'"
v-if="activeGame === 'conan'"
title="Conan Exiles concepts"
subtitle="Key admin mechanics for Conan Exiles servers"
>

View File

@@ -166,7 +166,7 @@ onMounted(() => {
<Input
v-model="config.store_name"
label="Store name"
placeholder="My Rust Server Store"
placeholder="My server store"
:required="true"
hint="Displayed to players on the store page"
/>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import type { StoreCategory, StoreItem } from '@/types'
import { safeFixed } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
@@ -14,6 +16,8 @@ import Select from '@/components/ds/forms/Select.vue'
import Checkbox from '@/components/ds/forms/Checkbox.vue'
const api = useApi()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const tab = ref<'categories' | 'items'>('categories')
const isLoading = ref(false)
@@ -46,12 +50,19 @@ const itemForm = ref({
enabled: true
})
const itemTypes = [
const itemTypesUmod = [
{ value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' },
{ value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' },
{ value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' },
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' }
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' },
]
const itemTypesGeneric = [
{ value: 'kit', label: 'Kit', example: 'givecontent {steam_id} item_id 1' },
{ value: 'rank', label: 'Rank', example: 'setrank {steam_id} vip' },
{ value: 'currency', label: 'Currency', example: 'addcurrency {steam_id} 1000' },
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' },
]
const itemTypes = computed(() => gameProfile.value.mods === 'umod' ? itemTypesUmod : itemTypesGeneric)
const tabItems = computed(() => [
{ value: 'categories', label: 'Categories', count: categories.value.length },
@@ -251,7 +262,7 @@ function getCategoryName(categoryId: string | null): string {
}
const selectedTypeExample = computed(() => {
const type = itemTypes.find(t => t.value === itemForm.value.item_type)
const type = itemTypes.value.find(t => t.value === itemForm.value.item_type)
return type?.example ?? ''
})

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useTimedExecuteStore } from '@/stores/timedexecute'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -12,6 +14,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useTimedExecuteStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const activeTab = ref<string>('timed')
const showCreateModal = ref(false)
@@ -360,7 +364,7 @@ const importConfigNameModel = computed<string>({
<span class="te__presets-label">Quick add:</span>
<button class="te__preset" @click="addPresetTimer('server.save', 300)">server.save (5 min)</button>
<button class="te__preset" @click="addPresetTimer('say Server restart warning!', 3600)">Restart warning (1 h)</button>
<button class="te__preset" @click="addPresetTimer('oxide.reload *', 7200)">Reload plugins (2 h)</button>
<button v-if="gameProfile.mods === 'umod'" class="te__preset" @click="addPresetTimer('oxide.reload *', 7200)">Reload plugins (2 h)</button>
</div>
<EmptyState

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe'
import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import type { WipeProfile } from '@/types'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -13,6 +15,8 @@ import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const wipeStore = useWipeStore()
const toast = useToastStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const expandedId = ref<string | null>(null)
const showModal = ref(false)
@@ -242,7 +246,7 @@ onMounted(() => {
{{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }}
</Badge>
</div>
<div class="detail-kv">
<div v-if="gameProfile.mods === 'umod'" class="detail-kv">
<span class="detail-k">Verify plugins loaded</span>
<Badge :tone="profile.post_wipe_config.verify_plugins_loaded ? 'online' : 'neutral'">
{{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }}
@@ -359,6 +363,7 @@ onMounted(() => {
label="Verify correct map"
/>
<Checkbox
v-if="gameProfile.mods === 'umod'"
v-model="form.post_wipe_config.verify_plugins_loaded"
label="Verify plugins loaded"
/>

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe'
import { useServerStore } from '@/stores/server'
import { useToastStore } from '@/stores/toast'
import { useApi } from '@/composables/useApi'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import { RouterLink } from 'vue-router'
import { safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
@@ -18,6 +20,8 @@ const wipeStore = useWipeStore()
const server = useServerStore()
const toast = useToastStore()
const api = useApi()
const { activeGame } = useThemeGame()
const profile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const triggerType = ref<'map' | 'blueprint' | 'full'>('map')
const selectedProfileId = ref<string>('')
@@ -71,11 +75,18 @@ async function toggleSchedule(scheduleId: string, currentlyActive: boolean) {
}
}
const WIPE_TYPE_OPTIONS = [
const WIPE_TYPE_OPTIONS_BASE = [
{ value: 'map', label: 'Map' },
{ value: 'full', label: 'Full' },
]
const WIPE_TYPE_OPTIONS_RUST = [
{ value: 'map', label: 'Map' },
{ value: 'blueprint', label: 'Blueprint' },
{ value: 'full', label: 'Full' },
]
const wipeTypeOptions = computed(() =>
profile.value.mods === 'umod' ? WIPE_TYPE_OPTIONS_RUST : WIPE_TYPE_OPTIONS_BASE
)
function profileOptions() {
const opts: { value: string; label: string }[] = [{ value: '', label: 'No profile' }]
@@ -148,7 +159,7 @@ onMounted(async () => {
<div class="cc-field__label">Wipe type</div>
<div class="type-seg">
<button
v-for="opt in WIPE_TYPE_OPTIONS"
v-for="opt in wipeTypeOptions"
:key="opt.value"
type="button"
class="type-seg__btn"