Compare commits
3 Commits
redesign/d
...
1edaaf985d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1edaaf985d | ||
|
|
f2b09b281a | ||
|
|
be57d2839a |
@@ -23,6 +23,8 @@ import {
|
|||||||
Ban, Flag,
|
Ban, Flag,
|
||||||
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
|
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
|
||||||
Pencil, Save, ShoppingBag, Target, User,
|
Pencil, Save, ShoppingBag, Target, User,
|
||||||
|
// Marketing site additions
|
||||||
|
Route, Timer, Megaphone, DatabaseBackup, Store, Undo2,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -58,6 +60,9 @@ const registry: Record<string, Component> = {
|
|||||||
'dollar-sign': DollarSign, 'flask-conical': FlaskConical, mail: Mail,
|
'dollar-sign': DollarSign, 'flask-conical': FlaskConical, mail: Mail,
|
||||||
package: Package, pencil: Pencil, save: Save, 'shopping-bag': ShoppingBag,
|
package: Package, pencil: Pencil, save: Save, 'shopping-bag': ShoppingBag,
|
||||||
target: Target, user: User,
|
target: Target, user: User,
|
||||||
|
// Marketing site additions
|
||||||
|
route: Route, timer: Timer, megaphone: Megaphone,
|
||||||
|
'database-backup': DatabaseBackup, store: Store, 'undo-2': Undo2,
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmp = computed<Component | null>(() => registry[props.name] ?? null)
|
const cmp = computed<Component | null>(() => registry[props.name] ?? null)
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* PlayersChart — themed ECharts area chart of players online.
|
* PlayersChart — themed ECharts area chart of players online.
|
||||||
* Reads the live design tokens (--accent etc.) from CSS so it matches the
|
*
|
||||||
* active theme/game, and re-renders when data-game / data-theme flip on <html>.
|
* Requires real `data` — there is NO fallback series. When `data` is absent
|
||||||
|
* or empty, an "awaiting telemetry" placeholder is shown instead of the chart.
|
||||||
|
* This is intentional: fabricated curves mislead operators.
|
||||||
|
*
|
||||||
|
* Reads live design tokens (--accent etc.) from CSS so it matches the active
|
||||||
|
* theme/game, and re-renders when data-game / data-theme flip on <html>.
|
||||||
*/
|
*/
|
||||||
import { onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
|
import { computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -12,29 +17,26 @@ const props = withDefaults(
|
|||||||
{ height: 200, max: 200 },
|
{ height: 200, max: 200 },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0)
|
||||||
|
|
||||||
const el = useTemplateRef<HTMLDivElement>('el')
|
const el = useTemplateRef<HTMLDivElement>('el')
|
||||||
let chart: echarts.ECharts | null = null
|
let chart: echarts.ECharts | null = null
|
||||||
let ro: ResizeObserver | null = null
|
let ro: ResizeObserver | null = null
|
||||||
let mo: MutationObserver | null = null
|
let mo: MutationObserver | null = null
|
||||||
|
|
||||||
const DEFAULT_SERIES = [
|
|
||||||
60, 52, 44, 38, 33, 30, 34, 46, 62, 78, 92, 104,
|
|
||||||
118, 126, 131, 138, 142, 151, 168, 182, 176, 150, 112, 84,
|
|
||||||
]
|
|
||||||
|
|
||||||
function cssVar(name: string, node?: HTMLElement): string {
|
function cssVar(name: string, node?: HTMLElement): string {
|
||||||
return getComputedStyle(node || document.documentElement).getPropertyValue(name).trim()
|
return getComputedStyle(node || document.documentElement).getPropertyValue(name).trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
if (!chart || !el.value) return
|
if (!chart || !el.value || !hasData.value) return
|
||||||
const node = el.value
|
const node = el.value
|
||||||
const accent = cssVar('--accent', node) || '#f26622'
|
const accent = cssVar('--accent', node) || '#f26622'
|
||||||
const grid = cssVar('--border-subtle', node) || 'rgba(255,255,255,0.06)'
|
const grid = cssVar('--border-subtle', node) || 'rgba(255,255,255,0.06)'
|
||||||
const text = cssVar('--text-tertiary', node) || '#767d89'
|
const text = cssVar('--text-tertiary', node) || '#767d89'
|
||||||
const mono = 'JetBrains Mono, monospace'
|
const mono = 'JetBrains Mono, monospace'
|
||||||
const hours = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
|
const hours = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
|
||||||
const series = props.data ?? DEFAULT_SERIES
|
const series = props.data as number[]
|
||||||
|
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
animationDuration: 700,
|
animationDuration: 700,
|
||||||
@@ -77,6 +79,7 @@ function render(): void {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!el.value) return
|
if (!el.value) return
|
||||||
|
if (!hasData.value) return // empty-state slot renders instead
|
||||||
chart = echarts.init(el.value, undefined, { renderer: 'canvas' })
|
chart = echarts.init(el.value, undefined, { renderer: 'canvas' })
|
||||||
render()
|
render()
|
||||||
ro = new ResizeObserver(() => chart?.resize())
|
ro = new ResizeObserver(() => chart?.resize())
|
||||||
@@ -94,5 +97,33 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="el" :style="{ width: '100%', height: height + 'px' }" />
|
<!-- Real data: render the ECharts canvas -->
|
||||||
|
<div v-if="hasData" ref="el" :style="{ width: '100%', height: height + 'px' }" />
|
||||||
|
<!-- No data: honest empty state — never show a fabricated curve -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="pc-empty"
|
||||||
|
:style="{ height: height + 'px' }"
|
||||||
|
>
|
||||||
|
<svg class="pc-empty__icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
<span class="pc-empty__label">Awaiting telemetry</span>
|
||||||
|
<span class="pc-empty__sub">Player data will appear once the server connects and reports stats</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pc-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.pc-empty__icon { margin-bottom: 4px; opacity: 0.5; }
|
||||||
|
.pc-empty__label { font-size: var(--text-sm); font-weight: 500; color: var(--text-tertiary); }
|
||||||
|
.pc-empty__sub { font-size: var(--text-xs); color: var(--text-muted); max-width: 280px; text-align: center; line-height: 1.5; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,76 +1,80 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView, RouterLink } from 'vue-router'
|
import { RouterView, RouterLink } from 'vue-router'
|
||||||
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
|
import '@/styles/marketing.css'
|
||||||
|
|
||||||
const panelUrl = import.meta.env.VITE_PANEL_URL || ''
|
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-neutral-950 flex flex-col">
|
<div>
|
||||||
<!-- Navigation -->
|
<!-- Nav -->
|
||||||
<nav class="border-b border-neutral-800 bg-neutral-950/80 backdrop-blur-sm sticky top-0 z-50">
|
<nav class="mkt-nav">
|
||||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div class="wrap mkt-nav__in">
|
||||||
<RouterLink :to="{ name: 'landing' }" class="flex items-center gap-3">
|
<RouterLink :to="{ name: 'landing' }" class="brand">
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-8 w-8" />
|
<span class="mark"><CorrosionMark :size="26" /></span>
|
||||||
<span class="text-lg font-bold text-neutral-100">Corrosion</span>
|
<b>Corrosion</b>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<div class="hidden md:flex items-center gap-6">
|
<div class="mkt-nav__links">
|
||||||
<RouterLink :to="{ name: 'how-it-works' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">How It Works</RouterLink>
|
<RouterLink :to="{ name: 'landing' }" class="scroll-link">Features</RouterLink>
|
||||||
<RouterLink :to="{ name: 'pricing' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Pricing</RouterLink>
|
<RouterLink :to="{ name: 'pricing' }">Pricing</RouterLink>
|
||||||
<RouterLink :to="{ name: 'roadmap' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Roadmap</RouterLink>
|
<RouterLink :to="{ name: 'how-it-works' }">How it works</RouterLink>
|
||||||
<RouterLink :to="{ name: 'faq' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">FAQ</RouterLink>
|
<RouterLink :to="{ name: 'faq' }">FAQ</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="mkt-nav__cta">
|
||||||
<a :href="panelUrl + '/login'" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Sign In</a>
|
<a class="mkt-nav__signin" :href="panelUrl + '/login'">Sign in</a>
|
||||||
<a :href="panelUrl + '/register'" class="px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors">Get Started</a>
|
<RouterLink class="btn btn--primary btn--sm" :to="{ name: 'early-access' }">
|
||||||
|
Early access
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Page content -->
|
<!-- Page content -->
|
||||||
<main class="flex-1">
|
<RouterView />
|
||||||
<RouterView />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="border-t border-neutral-800 py-12">
|
<footer class="mkt-footer">
|
||||||
<div class="max-w-6xl mx-auto px-6">
|
<div class="wrap">
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
|
<div class="footer__cols">
|
||||||
<div>
|
<div class="footer__brand">
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Product</h4>
|
<RouterLink :to="{ name: 'landing' }" class="brand">
|
||||||
<div class="space-y-2">
|
<span class="mark"><CorrosionMark :size="24" /></span>
|
||||||
<RouterLink :to="{ name: 'how-it-works' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">How It Works</RouterLink>
|
<b>Corrosion</b>
|
||||||
<RouterLink :to="{ name: 'pricing' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Pricing</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'roadmap' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Roadmap</RouterLink>
|
<p>Game server operations for self-hosted communities.</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="footer__col">
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Support</h4>
|
<h5>Product</h5>
|
||||||
<div class="space-y-2">
|
<RouterLink :to="{ name: 'landing' }">Supported games</RouterLink>
|
||||||
<RouterLink :to="{ name: 'faq' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">FAQ</RouterLink>
|
<RouterLink :to="{ name: 'landing' }">Features</RouterLink>
|
||||||
<a href="https://discord.gg/corrosion" target="_blank" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Discord</a>
|
<RouterLink :to="{ name: 'pricing' }">Pricing</RouterLink>
|
||||||
</div>
|
<RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="footer__col">
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Company</h4>
|
<h5>Games</h5>
|
||||||
<div class="space-y-2">
|
<RouterLink :to="{ name: 'landing' }">Rust</RouterLink>
|
||||||
<RouterLink :to="{ name: 'landing' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">About</RouterLink>
|
<RouterLink :to="{ name: 'landing' }">Dune: Awakening</RouterLink>
|
||||||
<RouterLink to="/status" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Status</RouterLink>
|
<RouterLink :to="{ name: 'landing' }">Soulmask</RouterLink>
|
||||||
</div>
|
<RouterLink :to="{ name: 'landing' }">Conan Exiles</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="footer__col">
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Legal</h4>
|
<h5>Support</h5>
|
||||||
<div class="space-y-2">
|
<RouterLink :to="{ name: 'faq' }">FAQ</RouterLink>
|
||||||
<span class="block text-sm text-neutral-600">Terms of Service</span>
|
<a href="https://discord.gg/corrosion" target="_blank" rel="noopener">Discord</a>
|
||||||
<span class="block text-sm text-neutral-600">Privacy Policy</span>
|
<RouterLink to="/status">Status</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="footer__col">
|
||||||
|
<h5>Company</h5>
|
||||||
|
<RouterLink :to="{ name: 'landing' }">About</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'roadmap' }">Changelog</RouterLink>
|
||||||
|
<a href="mailto:support@corrosionmgmt.com">Contact</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-neutral-800 pt-6 flex items-center justify-between">
|
<div class="footer__bar">
|
||||||
<div class="flex items-center gap-2">
|
<span>© 2026 Corrosion. All rights reserved.</span>
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-4 w-4 opacity-60" />
|
<span>One control plane. Every game.</span>
|
||||||
<span class="text-sm text-neutral-600">© 2026 Corrosion. All rights reserved.</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-neutral-700">The Control Plane for Rust Servers.</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
191
frontend/src/config/gameProfiles.ts
Normal file
191
frontend/src/config/gameProfiles.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* gameProfiles.ts — Source of truth for per-game UI adaptation.
|
||||||
|
*
|
||||||
|
* Every game-specific label, terminology, Steam app ID, management model,
|
||||||
|
* and stat field list lives here. The dashboard, server cards, wipe manager,
|
||||||
|
* and any future multi-game surface should key off this registry — never
|
||||||
|
* hard-code game-specific strings in components.
|
||||||
|
*
|
||||||
|
* Backend status: the backend has NO game field on licenses yet. Today every
|
||||||
|
* license is implicitly Rust. This registry is ready: when the backend adds a
|
||||||
|
* `game` column to `licenses` (or `server_config`), the frontend only needs to
|
||||||
|
* read that field and call `useGameProfile(id)` — no component changes required.
|
||||||
|
*
|
||||||
|
* To add a new game: add a GameId union member and a corresponding entry in
|
||||||
|
* GAME_PROFILES. Nothing else changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Union types — exhaustive, never widen to string
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Every supported game identifier. */
|
||||||
|
export type GameId = 'rust' | 'conan' | 'soulmask' | 'dune'
|
||||||
|
|
||||||
|
/** How the server process is managed. */
|
||||||
|
export type ManagementModel = 'process+rcon' | 'docker-compose'
|
||||||
|
|
||||||
|
/** Mod ecosystem the game uses. */
|
||||||
|
export type ModSystem = 'umod' | 'workshop' | 'none'
|
||||||
|
|
||||||
|
/** Primary console / remote-admin interface. */
|
||||||
|
export type ConsoleType = 'rcon' | 'rcon+ingame' | 'rcon+gm' | 'rabbitmq'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a "reset" is performed — each value maps to a distinct wipe code path.
|
||||||
|
* Pipe-delimited strings intentionally encode composite operations.
|
||||||
|
*/
|
||||||
|
export type ResetModel =
|
||||||
|
| 'map-bp-wipe'
|
||||||
|
| 'wipe-world-structures+decay'
|
||||||
|
| 'worlddb-delete+decay'
|
||||||
|
| 'deep-desert-coriolis-seed'
|
||||||
|
|
||||||
|
/** Cross-server or character-sharing mechanism. */
|
||||||
|
export type ClusteringModel = 'none' | 'character-transfer' | 'main-client' | 'battlegroup'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GameProfile shape
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface GameTerminology {
|
||||||
|
/** What the operator calls a reset / wipe. */
|
||||||
|
reset: string
|
||||||
|
/** What the operator calls plugins / mods (null if no mod system). */
|
||||||
|
mods: string | null
|
||||||
|
/** What the operator calls a player group / faction. */
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePorts {
|
||||||
|
game: number
|
||||||
|
query: number
|
||||||
|
rcon: number
|
||||||
|
cluster?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameProfile {
|
||||||
|
/** Human-readable game name. */
|
||||||
|
label: string
|
||||||
|
/** CSS design-token key — maps to data-game attr and --accent token. */
|
||||||
|
accent: string
|
||||||
|
managementModel: ManagementModel
|
||||||
|
steamAppId: number | { windows: number; linux: number }
|
||||||
|
/** Default ports (game-specific defaults; operator can override). */
|
||||||
|
ports?: GamePorts
|
||||||
|
mods: ModSystem
|
||||||
|
console: ConsoleType
|
||||||
|
resetModel: ResetModel
|
||||||
|
clustering: ClusteringModel
|
||||||
|
/** Available map names, if the game ships with named maps. */
|
||||||
|
maps?: string[]
|
||||||
|
terminology: GameTerminology
|
||||||
|
/** Notable game-specific mechanics that affect server administration. */
|
||||||
|
special?: string[]
|
||||||
|
/**
|
||||||
|
* Stat field labels shown on server cards and the dashboard.
|
||||||
|
* First entry is always Players; subsequent entries are game-specific.
|
||||||
|
*/
|
||||||
|
statFields: [string, string, string]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const GAME_PROFILES: Record<GameId, GameProfile> = {
|
||||||
|
rust: {
|
||||||
|
label: 'Rust',
|
||||||
|
accent: 'rust',
|
||||||
|
managementModel: 'process+rcon',
|
||||||
|
steamAppId: 258550,
|
||||||
|
mods: 'umod',
|
||||||
|
console: 'rcon',
|
||||||
|
resetModel: 'map-bp-wipe',
|
||||||
|
clustering: 'none',
|
||||||
|
terminology: {
|
||||||
|
reset: 'Wipe',
|
||||||
|
mods: 'Plugins',
|
||||||
|
group: 'Team',
|
||||||
|
},
|
||||||
|
statFields: ['Players', 'uMod', 'Wipe'],
|
||||||
|
},
|
||||||
|
|
||||||
|
conan: {
|
||||||
|
label: 'Conan Exiles',
|
||||||
|
accent: 'conan',
|
||||||
|
managementModel: 'process+rcon',
|
||||||
|
steamAppId: 443030,
|
||||||
|
ports: { game: 7777, query: 27015, rcon: 25575 },
|
||||||
|
mods: 'workshop',
|
||||||
|
console: 'rcon+ingame',
|
||||||
|
// Player progress persists across world wipes — only structures are cleared.
|
||||||
|
resetModel: 'wipe-world-structures+decay',
|
||||||
|
clustering: 'character-transfer',
|
||||||
|
maps: ['Exiled Lands', 'Isle of Siptah'],
|
||||||
|
terminology: {
|
||||||
|
reset: 'Wipe World',
|
||||||
|
mods: 'Mods',
|
||||||
|
group: 'Clan',
|
||||||
|
},
|
||||||
|
special: ['Clans', 'Thralls', 'Avatars', 'Purge', 'PvP windows'],
|
||||||
|
statFields: ['Players', 'Clans', 'Purge'],
|
||||||
|
},
|
||||||
|
|
||||||
|
soulmask: {
|
||||||
|
label: 'Soulmask',
|
||||||
|
accent: 'soulmask',
|
||||||
|
managementModel: 'process+rcon',
|
||||||
|
// Different Steam app IDs per OS (uncommon — store this explicitly).
|
||||||
|
steamAppId: { windows: 3017310, linux: 3017300 },
|
||||||
|
ports: { game: 8777, query: 27015, rcon: 19000, cluster: 20000 },
|
||||||
|
mods: 'workshop',
|
||||||
|
console: 'rcon+gm',
|
||||||
|
resetModel: 'worlddb-delete+decay',
|
||||||
|
clustering: 'main-client',
|
||||||
|
maps: ['Cloud Mist Forest', 'Shifting Sands'],
|
||||||
|
terminology: {
|
||||||
|
reset: 'World Reset',
|
||||||
|
mods: 'Workshop Mods',
|
||||||
|
group: 'Tribe',
|
||||||
|
},
|
||||||
|
special: ['Cluster', 'Tribes'],
|
||||||
|
statFields: ['Players', 'Tribe', 'Mask'],
|
||||||
|
},
|
||||||
|
|
||||||
|
dune: {
|
||||||
|
label: 'Dune: Awakening',
|
||||||
|
accent: 'dune',
|
||||||
|
managementModel: 'docker-compose',
|
||||||
|
steamAppId: 4754530,
|
||||||
|
mods: 'none',
|
||||||
|
// Dune uses RabbitMQ for its admin messaging — not a standard RCON port.
|
||||||
|
console: 'rabbitmq',
|
||||||
|
resetModel: 'deep-desert-coriolis-seed',
|
||||||
|
clustering: 'battlegroup',
|
||||||
|
terminology: {
|
||||||
|
reset: 'Deep Desert reset',
|
||||||
|
mods: null,
|
||||||
|
group: 'Guild',
|
||||||
|
},
|
||||||
|
special: ['Sietches', 'Deep Desert', 'Bases', 'Landsraad'],
|
||||||
|
statFields: ['Players', 'Sietches', 'Control'],
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the GameProfile for the given id, falling back to Rust if the id is
|
||||||
|
* unknown (forward-compatibility: unknown games show Rust defaults until their
|
||||||
|
* profile is added).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const profile = useGameProfile('rust')
|
||||||
|
* console.log(profile.terminology.reset) // 'Wipe'
|
||||||
|
*/
|
||||||
|
export function useGameProfile(id: string): GameProfile {
|
||||||
|
return (GAME_PROFILES as Record<string, GameProfile>)[id] ?? GAME_PROFILES.rust
|
||||||
|
}
|
||||||
846
frontend/src/styles/marketing.css
Normal file
846
frontend/src/styles/marketing.css
Normal file
@@ -0,0 +1,846 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion — Marketing site styles
|
||||||
|
Consumes the design-system tokens already loaded globally
|
||||||
|
via frontend/src/style.css (tokens/fonts → colors → etc.).
|
||||||
|
Class names match the design kit exactly.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.wrap { max-width: 1140px; margin: 0 auto; padding: 0 32px; }
|
||||||
|
section { position: relative; }
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: var(--tracking-caps);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2.title {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin: 16px auto 0;
|
||||||
|
max-width: 660px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent { color: var(--accent-text); }
|
||||||
|
|
||||||
|
.mark { display: inline-block; color: var(--accent); }
|
||||||
|
.mark svg { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
/* ---- Buttons ---- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 46px;
|
||||||
|
padding: 0 22px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
.btn--primary:hover { background: var(--accent-hover); }
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.btn--ghost:hover { background: var(--surface-active); }
|
||||||
|
|
||||||
|
.btn--sm { height: 36px; padding: 0 14px; font-size: var(--text-sm); }
|
||||||
|
.btn--lg { height: 52px; padding: 0 28px; font-size: var(--text-md); }
|
||||||
|
|
||||||
|
/* ---- Nav ---- */
|
||||||
|
.mkt-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
height: var(--topbar-h);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: color-mix(in srgb, var(--surface-canvas) 84%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.mkt-nav__in { display: flex; align-items: center; gap: 24px; width: 100%; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; }
|
||||||
|
.brand .mark { width: 26px; height: 26px; }
|
||||||
|
.brand b {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.mkt-nav__links { display: flex; gap: 24px; margin-left: 14px; }
|
||||||
|
.mkt-nav__links a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color var(--dur-fast);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.mkt-nav__links a:hover { color: var(--text-primary); }
|
||||||
|
.mkt-nav__cta { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.mkt-nav__signin {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--dur-fast);
|
||||||
|
}
|
||||||
|
.mkt-nav__signin:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ---- Hero ---- */
|
||||||
|
.hero { overflow: hidden; border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.hero__atmo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
transition: background var(--dur-slower) var(--ease-standard);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 50% -10%, var(--atmo-haze), transparent 55%),
|
||||||
|
radial-gradient(70% 50% at 85% 110%, color-mix(in srgb, var(--accent) 9%, transparent), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--atmo-1), var(--surface-canvas) 72%);
|
||||||
|
}
|
||||||
|
.hero__grain {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: .5;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
background-image: radial-gradient(rgba(255,255,255,.05) 1px, transparent 1px);
|
||||||
|
background-size: 3px 3px;
|
||||||
|
}
|
||||||
|
.hero__grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: .32;
|
||||||
|
-webkit-mask-image: radial-gradient(75% 60% at 50% 24%, #000, transparent 78%);
|
||||||
|
mask-image: radial-gradient(75% 60% at 50% 24%, #000, transparent 78%);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--border-subtle) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--border-subtle) 1px, transparent 1px);
|
||||||
|
background-size: 46px 46px;
|
||||||
|
}
|
||||||
|
.hero__in {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 74px 0 88px;
|
||||||
|
}
|
||||||
|
.hero__mark {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 auto 22px;
|
||||||
|
color: var(--accent);
|
||||||
|
filter: drop-shadow(0 0 26px var(--accent-glow));
|
||||||
|
transition: color var(--dur-slow);
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: var(--text-6xl);
|
||||||
|
line-height: 1.04;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.hero h1 .accent { display: block; }
|
||||||
|
.hero__sub {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin: 22px auto 0;
|
||||||
|
max-width: 640px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.hero__cta {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.hero__games {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 34px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.gpill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.gpill[data-on="true"] {
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.hero__foot {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.notpill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.notpill b { color: var(--accent-text); }
|
||||||
|
|
||||||
|
/* ---- Panel mockup ---- */
|
||||||
|
.mock {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 54px auto 0;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-base);
|
||||||
|
box-shadow: 0 50px 130px -34px rgba(0,0,0,.85), var(--ring-default);
|
||||||
|
}
|
||||||
|
.mock__bar {
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.mock__dots { display: flex; gap: 7px; }
|
||||||
|
.mock__dots span {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-active);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.mock__url {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
.mock__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 188px 1fr;
|
||||||
|
min-height: 316px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.mock__side {
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
padding: 14px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.mock__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.mock__brand .mark { width: 18px; height: 18px; }
|
||||||
|
.mock__brand b {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mock__gs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.mock__gs span {
|
||||||
|
flex: 1;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.mock__gs .on {
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.mock__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.mock__nav.on { background: var(--accent-soft); color: var(--accent-text); }
|
||||||
|
.mock__main { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.mock__kpis { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; }
|
||||||
|
.mock__kpi {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.mock__kpi .l { font-size: 10px; color: var(--text-tertiary); }
|
||||||
|
.mock__kpi .v {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 19px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.mock__kpi .v small { color: var(--text-muted); font-size: 12px; }
|
||||||
|
.mock__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mock__row::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.mock__row .g {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.mock__row .nm { flex: 1; font-size: 12px; font-weight: 600; }
|
||||||
|
.mock__row .nm small {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.mock__row .st {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--status-online);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.mock__row .st b {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--status-online);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Section spacing ---- */
|
||||||
|
.sec { padding: 88px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.sec__head { text-align: center; margin-bottom: 48px; }
|
||||||
|
.sec__head .eyebrow { display: block; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
/* ---- Problem cards ---- */
|
||||||
|
.pain {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4,1fr);
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.pain__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.pain__x {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--status-offline-soft);
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
.closing {
|
||||||
|
text-align: center;
|
||||||
|
margin: 40px auto 0;
|
||||||
|
max-width: 720px;
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Steps ---- */
|
||||||
|
.steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3,1fr);
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
padding: 28px 24px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.step__n {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.step b { font-size: var(--text-md); font-weight: 600; }
|
||||||
|
.step p { color: var(--text-tertiary); font-size: var(--text-sm); margin: 8px 0 0; }
|
||||||
|
.nots {
|
||||||
|
display: flex;
|
||||||
|
gap: 26px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 34px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.nots span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Blueprints (game cards) ---- */
|
||||||
|
.blueprints {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.bp {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 90% at 100% 0%, var(--atmo-haze), transparent 55%),
|
||||||
|
linear-gradient(160deg, color-mix(in srgb, var(--atmo-1) 80%, transparent), var(--surface-base) 70%);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.bp__head { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
|
||||||
|
.bp__ic {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.bp__name { font-family: var(--font-brand); font-weight: 700; font-size: var(--text-xl); }
|
||||||
|
.bp__accent {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-text);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
.bp__role { font-size: var(--text-sm); font-weight: 600; color: var(--text-secondary); margin: 10px 0 14px; }
|
||||||
|
.bp__list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.bp__list div { display: flex; align-items: center; gap: 9px; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ---- Capabilities (3 col) ---- */
|
||||||
|
.caps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3,1fr);
|
||||||
|
gap: 30px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.caps__col > .eyebrow { display: block; margin-bottom: 8px; }
|
||||||
|
.feat { display: flex; gap: 12px; padding: 14px 0; border-top: 1px solid var(--border-subtle); }
|
||||||
|
.feat:first-of-type { border-top: 0; }
|
||||||
|
.feat__ic {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.feat b { font-size: var(--text-sm); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---- Pipeline ---- */
|
||||||
|
.pipe {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.pchip {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 15px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.pchip--last {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.stack-lines { display: flex; flex-direction: column; gap: 8px; align-items: center; margin-top: 32px; }
|
||||||
|
.stack-lines span { color: var(--text-tertiary); font-size: var(--text-md); }
|
||||||
|
.stack-lines .hi { color: var(--accent-text); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---- Infra ---- */
|
||||||
|
.infra {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5,1fr);
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.icard {
|
||||||
|
padding: 20px 16px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.icard__ic {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.icard b { font-size: var(--text-sm); font-weight: 600; display: block; }
|
||||||
|
.icard p { margin: 5px 0 0; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.5; }
|
||||||
|
.techrow {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.techrow span {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Store ---- */
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.chip-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.chip-card--accent {
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Pricing ---- */
|
||||||
|
.pricing {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4,1fr);
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.plan {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px 22px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.plan--feature {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border), var(--glow-accent-sm);
|
||||||
|
background: linear-gradient(180deg, var(--accent-soft), var(--surface-base) 40%);
|
||||||
|
}
|
||||||
|
.plan__tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--accent-text);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.plan__name { font-family: var(--font-brand); font-weight: 700; font-size: var(--text-xl); }
|
||||||
|
.plan__price {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
margin: 12px 0 2px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.plan__price small { font-size: var(--text-sm); color: var(--text-muted); font-weight: 400; }
|
||||||
|
.plan__scope { font-size: var(--text-sm); color: var(--text-tertiary); min-height: 40px; }
|
||||||
|
.plan .btn { margin-top: 18px; width: 100%; justify-content: center; }
|
||||||
|
|
||||||
|
.fleetblock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 14px auto 0;
|
||||||
|
padding: 16px 22px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.fleetblock b { font-family: var(--font-brand); font-weight: 700; }
|
||||||
|
.fleetblock .p { font-family: var(--font-mono); color: var(--accent-text); font-weight: 600; }
|
||||||
|
.fleetblock span { color: var(--text-tertiary); font-size: var(--text-sm); }
|
||||||
|
|
||||||
|
.commercial {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 26px auto 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.commercial b { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ---- Support block (below pricing) ---- */
|
||||||
|
.support-note {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 20px auto 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.support-note b { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ---- Admins ---- */
|
||||||
|
.admins {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 11px;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.admins span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Final CTA ---- */
|
||||||
|
.finalcta {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
padding: 104px 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.finalcta__atmo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
background: radial-gradient(60% 100% at 50% 100%, var(--atmo-haze), transparent 60%);
|
||||||
|
}
|
||||||
|
.finalcta__in { position: relative; z-index: 1; }
|
||||||
|
.finalcta h2 {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: var(--text-5xl);
|
||||||
|
margin: 0 0 28px;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.finalcta .cta-row { display: flex; gap: 14px; justify-content: center; }
|
||||||
|
|
||||||
|
/* ---- Footer ---- */
|
||||||
|
.mkt-footer { padding: 56px 0 40px; }
|
||||||
|
.footer__cols { display: grid; grid-template-columns: 1.5fr 1fr 1fr 1fr 1fr; gap: 24px; }
|
||||||
|
.footer__brand .mark { width: 24px; height: 24px; }
|
||||||
|
.footer__brand p {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 12px 0 0;
|
||||||
|
max-width: 230px;
|
||||||
|
}
|
||||||
|
.footer__col h5 {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.footer__col a {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: 9px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--dur-fast);
|
||||||
|
}
|
||||||
|
.footer__col a:hover { color: var(--text-primary); }
|
||||||
|
.footer__bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 44px;
|
||||||
|
padding-top: 22px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scroll reveal ---- */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
transition: opacity .6s var(--ease-out), transform .6s var(--ease-out);
|
||||||
|
}
|
||||||
|
.reveal.in { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Responsive ---- */
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.pain { grid-template-columns: 1fr 1fr; }
|
||||||
|
.steps, .caps, .blueprints, .pricing { grid-template-columns: 1fr; }
|
||||||
|
.infra { grid-template-columns: 1fr 1fr; }
|
||||||
|
.footer__cols { grid-template-columns: 1fr 1fr; }
|
||||||
|
.mock__body { grid-template-columns: 1fr; }
|
||||||
|
.mock__side { display: none; }
|
||||||
|
.hero h1 { font-size: var(--text-5xl); }
|
||||||
|
.mkt-nav__links { display: none; }
|
||||||
|
}
|
||||||
@@ -1,136 +1,92 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* DashboardView — Fleet / Solo dashboard.
|
* DashboardView — Single-server cockpit wired entirely to real data.
|
||||||
* Fleet: multi-game server cockpit (representative mock data — pending multi-instance backend).
|
|
||||||
* Solo: single-server detail wired to the real useServerStore where data exists.
|
|
||||||
*
|
*
|
||||||
* View toggle (Fleet / Solo) lives inside the page so the shell (DashboardLayout) stays clean.
|
* Architecture:
|
||||||
* Routing stays at path '/', no new routes added.
|
* - useServerStore → connection + config + live stats (WebSocket updateStats)
|
||||||
|
* - useApi → /analytics/timeseries for 24h player history (PlayersChart)
|
||||||
|
* - useGameProfile → per-game labels/terminology (defaults to 'rust' today)
|
||||||
|
* - useWebSocket → subscribes to console_output and server_stats events
|
||||||
|
*
|
||||||
|
* Empty states:
|
||||||
|
* - No connection record → "No server connected" EmptyState with CTA to /server
|
||||||
|
* - Connection exists but stats absent → meters show '—', chart shows awaiting telemetry
|
||||||
|
* - No upcoming wipe schedules → honest empty state in the wipes panel
|
||||||
|
*
|
||||||
|
* No fabricated data anywhere in this file.
|
||||||
|
* The fleet/multi-server view has been removed — the current backend is
|
||||||
|
* single-server-per-license. When the backend supports multiple servers per
|
||||||
|
* license, restore a fleet tab wired to real data.
|
||||||
*/
|
*/
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useThemeGame } from '@/composables/useThemeGame'
|
import { useWipeStore } from '@/stores/wipe'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
||||||
|
import { useGameProfile } from '@/config/gameProfiles'
|
||||||
import Panel from '@/components/ds/data/Panel.vue'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
import ServerCard from '@/components/ds/data/ServerCard.vue'
|
import ConsoleLineDS from '@/components/ds/data/ConsoleLine.vue'
|
||||||
import ConsoleLine from '@/components/ds/data/ConsoleLine.vue'
|
|
||||||
import ResourceMeter from '@/components/ds/data/ResourceMeter.vue'
|
import ResourceMeter from '@/components/ds/data/ResourceMeter.vue'
|
||||||
import PlayersChart from '@/components/ds/data/PlayersChart.vue'
|
import PlayersChart from '@/components/ds/data/PlayersChart.vue'
|
||||||
import Badge from '@/components/ds/core/Badge.vue'
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
import Button from '@/components/ds/core/Button.vue'
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
import Icon from '@/components/ds/core/Icon.vue'
|
|
||||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
|
||||||
import Input from '@/components/ds/forms/Input.vue'
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
import Switch from '@/components/ds/forms/Switch.vue'
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
import {
|
import type { TimeseriesData, WipeSchedule } from '@/types'
|
||||||
MOCK_SERVERS, MOCK_FEED, MOCK_WIPES, buildStats,
|
import { safeDate } from '@/utils/formatters'
|
||||||
type MockServer, type GameKey,
|
|
||||||
} from './_dashboardMock'
|
|
||||||
|
|
||||||
// ---- Stores / composables ----
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stores / composables
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
|
const wipeStore = useWipeStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { activeGame } = useThemeGame()
|
const api = useApi()
|
||||||
|
|
||||||
// ---- View toggle ----
|
// Today every license is Rust. When the backend adds a `game` field to the
|
||||||
const VIEW_KEY = 'cc-dash-view'
|
// license or server_config, pass it here: useGameProfile(server.config?.game ?? 'rust')
|
||||||
const view = ref<'fleet' | 'solo'>((localStorage.getItem(VIEW_KEY) as 'fleet' | 'solo') ?? 'fleet')
|
const profile = computed(() => useGameProfile('rust'))
|
||||||
function setView(v: string) {
|
|
||||||
view.value = v as 'fleet' | 'solo'
|
|
||||||
localStorage.setItem(VIEW_KEY, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewItems = [
|
// ---------------------------------------------------------------------------
|
||||||
{ value: 'fleet', label: 'Fleet', icon: 'layout-grid' },
|
// Derived server state — all real, no fallbacks to fabricated values
|
||||||
{ value: 'solo', label: 'Solo', icon: 'square-dashed' },
|
// ---------------------------------------------------------------------------
|
||||||
]
|
|
||||||
|
|
||||||
// ---- Fleet: filter servers by activeGame ----
|
const hasConnection = computed(() => server.connection !== null)
|
||||||
const serverStatus = ref<'all' | 'online' | 'offline'>('all')
|
const isConnected = computed(() => server.connection?.connection_status === 'connected')
|
||||||
const statusItems = computed(() => [
|
|
||||||
{ value: 'all', label: 'All', count: inGame.value.length },
|
|
||||||
{ value: 'online', label: 'Running', count: inGame.value.filter((s) => s.status !== 'offline').length },
|
|
||||||
{ value: 'offline', label: 'Stopped', count: inGame.value.filter((s) => s.status === 'offline').length },
|
|
||||||
])
|
|
||||||
|
|
||||||
const GAME_LABEL: Record<string, string> = { rust: 'Rust', dune: 'Dune', conan: 'Conan Exiles', soulmask: 'Soulmask' }
|
const soloName = computed(() => server.config?.server_name ?? null)
|
||||||
|
|
||||||
const inGame = computed<MockServer[]>(() =>
|
const soloPlayers = computed(() => server.stats?.player_count ?? null)
|
||||||
activeGame.value === 'all'
|
const soloMaxPlayers = computed(() => server.stats?.max_players ?? server.config?.max_players ?? null)
|
||||||
? MOCK_SERVERS
|
const soloFps = computed(() => server.stats?.fps ?? null)
|
||||||
: MOCK_SERVERS.filter((s) => s.game === (activeGame.value as GameKey)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const shownServers = computed<MockServer[]>(() => {
|
// Memory: store gives memory_usage_mb; max must come from agent telemetry.
|
||||||
const sv = serverStatus.value
|
// We do NOT hard-code a "representative" max — show raw MB and no percentage
|
||||||
return inGame.value.filter((s) => {
|
// until the agent reports a known max.
|
||||||
if (sv === 'all') return true
|
const soloRamMb = computed(() => server.stats?.memory_usage_mb ?? null)
|
||||||
if (sv === 'online') return s.status !== 'offline'
|
const soloRamPct = computed(() => {
|
||||||
return s.status === 'offline'
|
// ServerStats has no ram_max field — we cannot compute a real percentage.
|
||||||
})
|
// Return null; ResourceMeter and StatCard will show '—'.
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
const soloRamSub = computed(() => {
|
||||||
|
const mb = soloRamMb.value
|
||||||
|
if (mb === null) return null
|
||||||
|
return `${(mb / 1024).toFixed(1)} GB used`
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Fleet KPIs ----
|
// CPU: not in ServerStats today. Show null — never fabricate.
|
||||||
const runningCount = computed(() => inGame.value.filter((s) => s.status !== 'offline').length)
|
const soloCpu = computed(() => null as number | null)
|
||||||
const playersCur = computed(() => inGame.value.reduce((a, s) => a + (s.players?.cur ?? 0), 0))
|
|
||||||
const playersMax = computed(() => inGame.value.reduce((a, s) => a + (s.players?.max ?? 0), 0))
|
|
||||||
const cpuValues = computed(() => inGame.value.filter((s) => s.cpu != null).map((s) => s.cpu as number))
|
|
||||||
const avgCpu = computed<string>(() =>
|
|
||||||
cpuValues.value.length
|
|
||||||
? String(Math.round(cpuValues.value.reduce((a, b) => a + b, 0) / cpuValues.value.length))
|
|
||||||
: '—',
|
|
||||||
)
|
|
||||||
|
|
||||||
const scopeLabel = computed(() =>
|
const soloStatus = computed<'online' | 'offline' | 'starting'>(() => {
|
||||||
activeGame.value === 'all'
|
|
||||||
? 'Fleet overview'
|
|
||||||
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} fleet`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const fleetTitle = computed(() => {
|
|
||||||
if (activeGame.value === 'all') {
|
|
||||||
const games = new Set(MOCK_SERVERS.map((s) => s.game)).size
|
|
||||||
return `${MOCK_SERVERS.length} servers · ${games} games`
|
|
||||||
}
|
|
||||||
const n = inGame.value.length
|
|
||||||
const label = GAME_LABEL[activeGame.value as string] ?? activeGame.value
|
|
||||||
return `${n} ${label} server${n === 1 ? '' : 's'}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const chartSubtitle = computed(() =>
|
|
||||||
activeGame.value === 'all'
|
|
||||||
? 'All servers · last 24 hours'
|
|
||||||
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} servers · last 24 hours`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Chart period toggle ----
|
|
||||||
const chartPeriod = ref('24h')
|
|
||||||
const periodItems = [
|
|
||||||
{ value: '24h', label: '24h' },
|
|
||||||
{ value: '7d', label: '7d' },
|
|
||||||
{ value: '30d', label: '30d' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---- Solo: real store data + representative fallbacks ----
|
|
||||||
const soloName = computed(() => server.config?.server_name ?? 'Main · 2x Vanilla')
|
|
||||||
const soloPlayers = computed(() => server.stats?.player_count ?? 0)
|
|
||||||
const soloMaxPlayers = computed(() => server.stats?.max_players ?? server.config?.max_players ?? 200)
|
|
||||||
const soloFps = computed(() => server.stats?.fps ?? 59.8)
|
|
||||||
// Memory: store gives memory_usage_mb (no max), use 8192 MB representative max for %
|
|
||||||
const soloRamMb = computed(() => server.stats?.memory_usage_mb ?? 0)
|
|
||||||
const soloRamPct = computed(() => soloRamMb.value > 0 ? Math.round((soloRamMb.value / 8192) * 100) : 68)
|
|
||||||
const soloRamSub = computed(() => soloRamMb.value > 0 ? `${(soloRamMb.value / 1024).toFixed(1)} / 8 GB` : '5.4 / 8 GB')
|
|
||||||
// CPU: not in ServerStats; use representative value
|
|
||||||
const soloCpuPct = 41
|
|
||||||
// Status badge derived from connection_status
|
|
||||||
const soloStatus = computed<'online' | 'offline' | 'starting' | 'wiping'>(() => {
|
|
||||||
const cs = server.connection?.connection_status
|
const cs = server.connection?.connection_status
|
||||||
if (cs === 'connected') return 'online'
|
if (cs === 'connected') return 'online'
|
||||||
if (cs === 'degraded') return 'starting'
|
if (cs === 'degraded') return 'starting'
|
||||||
return 'offline'
|
return 'offline'
|
||||||
})
|
})
|
||||||
const soloStatusTone = computed<'online' | 'offline' | 'starting' | 'warn'>(() => {
|
const soloStatusTone = computed<'online' | 'offline' | 'warn'>(() => {
|
||||||
if (soloStatus.value === 'online') return 'online'
|
if (soloStatus.value === 'online') return 'online'
|
||||||
if (soloStatus.value === 'starting') return 'warn'
|
if (soloStatus.value === 'starting') return 'warn'
|
||||||
return 'offline'
|
return 'offline'
|
||||||
@@ -140,217 +96,282 @@ const soloStatusLabel = computed(() => {
|
|||||||
if (soloStatus.value === 'starting') return 'Degraded'
|
if (soloStatus.value === 'starting') return 'Degraded'
|
||||||
return 'Offline'
|
return 'Offline'
|
||||||
})
|
})
|
||||||
const soloRegion = computed(() => {
|
|
||||||
const ip = server.connection?.server_ip
|
|
||||||
return ip ? 'Bare metal' : 'US-East'
|
|
||||||
})
|
|
||||||
const soloIp = computed(() => {
|
const soloIp = computed(() => {
|
||||||
const ip = server.connection?.server_ip
|
const ip = server.connection?.server_ip
|
||||||
const port = server.connection?.game_port ?? server.connection?.server_port
|
const port = server.connection?.game_port ?? server.connection?.server_port
|
||||||
if (ip && port) return `${ip}:${port}`
|
if (ip && port) return `${ip}:${port}`
|
||||||
return '89.142.0.7:28015'
|
if (ip) return ip
|
||||||
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
const soloUptime = computed(() => {
|
const soloUptime = computed(() => {
|
||||||
const sec = server.stats?.uptime_seconds ?? 0
|
const sec = server.stats?.uptime_seconds ?? 0
|
||||||
if (sec === 0) return '—'
|
if (sec === 0) return null
|
||||||
const d = Math.floor(sec / 86400)
|
const d = Math.floor(sec / 86400)
|
||||||
const h = Math.floor((sec % 86400) / 3600)
|
const h = Math.floor((sec % 86400) / 3600)
|
||||||
return `${d}d ${h}h`
|
if (d > 0) return `${d}d ${h}h`
|
||||||
|
return `${h}h`
|
||||||
})
|
})
|
||||||
|
|
||||||
// Representative plugin list (uMod plugin state not in backend store)
|
// ---------------------------------------------------------------------------
|
||||||
const pluginStates = ref([
|
// Players chart — real 24h timeseries from /analytics/timeseries
|
||||||
{ name: 'RaidableBases', ver: '2.7.4', on: true },
|
// ---------------------------------------------------------------------------
|
||||||
{ name: 'Kits', ver: '4.3.1', on: true },
|
const chartData = ref<number[] | null>(null)
|
||||||
{ name: 'CorrosionTeleportGUI', ver: '1.2.0', on: true },
|
const chartLoading = ref(false)
|
||||||
{ name: 'Economics', ver: '3.9.6', on: true },
|
|
||||||
{ name: 'ZLevels Remastered', ver: '3.2.0', on: false },
|
|
||||||
])
|
|
||||||
|
|
||||||
|
async function loadChartData() {
|
||||||
|
chartLoading.value = true
|
||||||
|
try {
|
||||||
|
const ts = await api.get<TimeseriesData>('/analytics/timeseries?range=24&granularity=hourly')
|
||||||
|
chartData.value = ts.player_count.length > 0 ? ts.player_count : null
|
||||||
|
} catch {
|
||||||
|
// API unavailable or no data yet — chart will show "awaiting telemetry"
|
||||||
|
chartData.value = null
|
||||||
|
} finally {
|
||||||
|
chartLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wipe schedules — real data from wipeStore
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const nextWipe = computed<WipeSchedule | null>(() => {
|
||||||
|
const schedules = wipeStore.schedules.filter((s) => s.is_active && s.next_scheduled_run)
|
||||||
|
if (schedules.length === 0) return null
|
||||||
|
return schedules.slice().sort((a, b) => {
|
||||||
|
const at = a.next_scheduled_run ? new Date(a.next_scheduled_run).getTime() : Infinity
|
||||||
|
const bt = b.next_scheduled_run ? new Date(b.next_scheduled_run).getTime() : Infinity
|
||||||
|
return at - bt
|
||||||
|
})[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextWipeLabel = computed(() => {
|
||||||
|
const w = nextWipe.value
|
||||||
|
if (!w?.next_scheduled_run) return null
|
||||||
|
return safeDate(w.next_scheduled_run)
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextWipeType = computed(() => {
|
||||||
|
const w = nextWipe.value
|
||||||
|
if (!w) return null
|
||||||
|
const t = w.wipe_type
|
||||||
|
if (t === 'full') return `Full ${profile.value.terminology.reset}`
|
||||||
|
if (t === 'blueprint') return 'Blueprint wipe'
|
||||||
|
return `Map ${profile.value.terminology.reset}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Console lines — real WebSocket events only
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface ConsoleLine {
|
||||||
|
time: string
|
||||||
|
level: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
|
||||||
|
who?: string
|
||||||
|
msg: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleLines = ref<ConsoleLine[]>([])
|
||||||
|
const MAX_CONSOLE_LINES = 100
|
||||||
|
|
||||||
|
function now(): string {
|
||||||
|
return new Date().toLocaleTimeString('en-US', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWsMessage(msg: WebSocketMessage) {
|
||||||
|
if (msg.type !== 'event') return
|
||||||
|
|
||||||
|
// Live server stats
|
||||||
|
if (msg.event === 'server_stats' && msg.data) {
|
||||||
|
server.updateStats(msg.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console output lines
|
||||||
|
if (msg.event === 'console_output') {
|
||||||
|
const text = msg.data?.line ?? msg.data?.output ?? msg.raw ?? ''
|
||||||
|
if (!text) return
|
||||||
|
consoleLines.value.push({
|
||||||
|
time: now(),
|
||||||
|
level: 'info',
|
||||||
|
msg: String(text),
|
||||||
|
})
|
||||||
|
if (consoleLines.value.length > MAX_CONSOLE_LINES) {
|
||||||
|
consoleLines.value.splice(0, consoleLines.value.length - MAX_CONSOLE_LINES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
// Console input
|
// Console input
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const consoleInput = ref('')
|
const consoleInput = ref('')
|
||||||
|
|
||||||
function sendConsoleCommand() {
|
function sendConsoleCommand() {
|
||||||
if (!consoleInput.value.trim()) return
|
const cmd = consoleInput.value.trim()
|
||||||
server.sendCommand(consoleInput.value.trim()).catch(() => {})
|
if (!cmd) return
|
||||||
|
consoleLines.value.push({ time: now(), level: 'cmd', who: 'admin', msg: cmd })
|
||||||
|
server.sendCommand(cmd).catch(() => {})
|
||||||
consoleInput.value = ''
|
consoleInput.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await server.fetchServer()
|
||||||
|
await wipeStore.fetchSchedules()
|
||||||
|
await loadChartData()
|
||||||
|
|
||||||
|
const ws = useWebSocket()
|
||||||
|
unsubscribe = ws.subscribe(handleWsMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unsubscribe?.()
|
||||||
|
})
|
||||||
|
|
||||||
// Navigation helpers
|
// Navigation helpers
|
||||||
function navConsole() { router.push('/console') }
|
function navConsole() { router.push('/console') }
|
||||||
function navWipes() { router.push('/wipes') }
|
function navWipes() { router.push('/wipes') }
|
||||||
|
function navServer() { router.push('/server') }
|
||||||
// ---- Lifecycle ----
|
|
||||||
onMounted(() => {
|
|
||||||
server.fetchServer()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dash">
|
<div class="dash">
|
||||||
<!-- ===== FLEET VIEW ===== -->
|
|
||||||
<template v-if="view === 'fleet'">
|
<!-- ===== NO CONNECTION: honest empty state ===== -->
|
||||||
<!-- Page head -->
|
<template v-if="!server.isLoading && !hasConnection">
|
||||||
<div class="page__head">
|
<div class="page__head">
|
||||||
<div>
|
<div>
|
||||||
<div class="t-eyebrow">{{ scopeLabel }}</div>
|
<div class="t-eyebrow">Dashboard</div>
|
||||||
<h1 class="page__title">{{ fleetTitle }}</h1>
|
<h1 class="page__title">Server cockpit</h1>
|
||||||
</div>
|
|
||||||
<div class="page__actions">
|
|
||||||
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
|
|
||||||
<Button variant="secondary" size="sm" icon="download">Export</Button>
|
|
||||||
<Button size="sm" icon="rocket">Deploy server</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- KPIs -->
|
|
||||||
<div class="dash__kpis">
|
|
||||||
<StatCard icon="server" label="Servers running" :value="String(runningCount)" :unit="'/' + inGame.length" delta="+1" note="today" />
|
|
||||||
<StatCard icon="users" label="Players online" :value="String(playersCur)" :unit="'/' + playersMax" delta="+38" note="since wipe" />
|
|
||||||
<StatCard icon="cpu" :label="activeGame === 'all' ? 'Fleet CPU' : 'Avg CPU'" :value="avgCpu" :unit="avgCpu === '—' ? '' : '%'" note="reporting agents" />
|
|
||||||
<StatCard icon="server-cog" label="Agent nodes" value="2" unit="/2" note="all reporting" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main grid -->
|
|
||||||
<div class="dash__grid">
|
|
||||||
<!-- Left column -->
|
|
||||||
<div class="dash__col">
|
|
||||||
<!-- Players chart panel — themed ECharts -->
|
|
||||||
<Panel title="Players online" :subtitle="chartSubtitle">
|
|
||||||
<template #actions>
|
|
||||||
<Tabs v-model="chartPeriod" :items="periodItems" />
|
|
||||||
</template>
|
|
||||||
<PlayersChart :height="200" :max="200" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- Servers list -->
|
|
||||||
<Panel :flush-body="true" title="Servers">
|
|
||||||
<template #actions>
|
|
||||||
<Tabs v-model="serverStatus" :items="statusItems" />
|
|
||||||
</template>
|
|
||||||
<div class="server__list">
|
|
||||||
<ServerCard
|
|
||||||
v-for="(s, i) in shownServers"
|
|
||||||
:key="i"
|
|
||||||
:game="s.game"
|
|
||||||
:game-icon="s.gameIcon"
|
|
||||||
:name="s.name"
|
|
||||||
:region="s.region"
|
|
||||||
:map="s.map"
|
|
||||||
:version="s.version"
|
|
||||||
:status="s.status"
|
|
||||||
:players="s.players"
|
|
||||||
:cpu="s.cpu"
|
|
||||||
:ram="s.ram"
|
|
||||||
:ram-sub="s.ramSub"
|
|
||||||
:ip="s.ip"
|
|
||||||
:stats="buildStats(s)"
|
|
||||||
/>
|
|
||||||
<div v-if="shownServers.length === 0" class="server__empty">
|
|
||||||
No servers match the current filter.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right sidebar column -->
|
|
||||||
<div class="dash__col dash__col--side">
|
|
||||||
<!-- Live activity -->
|
|
||||||
<Panel :flush-body="true" title="Live activity">
|
|
||||||
<template #actions>
|
|
||||||
<Badge tone="online" :dot="true" :pulse="true">Live</Badge>
|
|
||||||
</template>
|
|
||||||
<div class="feed">
|
|
||||||
<ConsoleLine
|
|
||||||
v-for="(f, i) in MOCK_FEED"
|
|
||||||
:key="i"
|
|
||||||
:time="f.time"
|
|
||||||
:level="f.level"
|
|
||||||
:who="f.who"
|
|
||||||
>{{ f.msg }}</ConsoleLine>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- Upcoming wipes -->
|
|
||||||
<Panel title="Upcoming wipes">
|
|
||||||
<div class="wipes">
|
|
||||||
<div
|
|
||||||
v-for="(w, i) in MOCK_WIPES"
|
|
||||||
:key="i"
|
|
||||||
class="wipe"
|
|
||||||
:data-game="w.game"
|
|
||||||
>
|
|
||||||
<div class="wipe__dot" />
|
|
||||||
<div class="wipe__body">
|
|
||||||
<div class="wipe__name">{{ w.name }}</div>
|
|
||||||
<div class="wipe__when">{{ w.when }}</div>
|
|
||||||
</div>
|
|
||||||
<Badge :tone="w.tone" size="md">{{ w.label }}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Panel>
|
||||||
|
<EmptyState
|
||||||
|
icon="server"
|
||||||
|
title="No server connected"
|
||||||
|
description="Install the companion agent on your host machine to begin managing your server from Corrosion."
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button icon="server" @click="navServer">Set up server</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ===== SOLO VIEW ===== -->
|
<!-- ===== SERVER COCKPIT ===== -->
|
||||||
<template v-else>
|
<template v-else-if="hasConnection">
|
||||||
|
|
||||||
<!-- Page head -->
|
<!-- Page head -->
|
||||||
<div class="page__head">
|
<div class="page__head">
|
||||||
<div class="solo-id">
|
<div class="solo-id">
|
||||||
<div class="solo-id__chip">
|
<div class="solo-id__chip">
|
||||||
<Icon name="box" :size="21" :stroke-width="2" />
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="solo-id__name">
|
<div class="solo-id__name">
|
||||||
{{ soloName }}
|
{{ soloName ?? 'Server' }}
|
||||||
<Badge :tone="soloStatusTone" :dot="true" :pulse="soloStatus === 'online'">{{ soloStatusLabel }}</Badge>
|
<Badge :tone="soloStatusTone" :dot="true" :pulse="soloStatus === 'online'">{{ soloStatusLabel }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="solo-id__meta">
|
<div class="solo-id__meta">
|
||||||
{{ soloRegion }} · {{ soloIp }}
|
<template v-if="soloIp">{{ soloIp }}</template>
|
||||||
<span v-if="soloUptime !== '—'"> · up {{ soloUptime }}</span>
|
<template v-else>No IP registered</template>
|
||||||
|
<template v-if="soloUptime"> · up {{ soloUptime }}</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="page__actions">
|
<div class="page__actions">
|
||||||
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
|
|
||||||
<Button variant="secondary" size="sm" icon="refresh-cw" @click="server.restartServer()">Restart</Button>
|
<Button variant="secondary" size="sm" icon="refresh-cw" @click="server.restartServer()">Restart</Button>
|
||||||
<Button variant="danger-soft" size="sm" icon="power" @click="server.stopServer()">Stop</Button>
|
<Button variant="danger-soft" size="sm" icon="power" @click="server.stopServer()">Stop</Button>
|
||||||
<Button size="sm" icon="terminal" @click="navConsole">Console</Button>
|
<Button size="sm" icon="terminal" @click="navConsole">Console</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KPIs -->
|
<!-- KPIs — game profile drives stat labels; null values show '—' -->
|
||||||
<div class="dash__kpis">
|
<div class="dash__kpis">
|
||||||
<StatCard icon="users" label="Players online" :value="String(soloPlayers)" :unit="'/' + soloMaxPlayers" delta="+12" note="since wipe" />
|
<StatCard
|
||||||
<StatCard icon="cpu" label="CPU" :value="String(soloCpuPct)" unit="%" delta="3%" delta-dir="down" note="agent · representative" />
|
icon="users"
|
||||||
<StatCard icon="memory-stick" label="Memory" :value="String(soloRamPct)" unit="%" :note="soloRamSub" />
|
:label="profile.statFields[0] + ' online'"
|
||||||
<StatCard icon="gauge" label="Server FPS" :value="String(soloFps)" delta-dir="flat" note="stable" />
|
:value="soloPlayers !== null ? String(soloPlayers) : '—'"
|
||||||
|
:unit="soloMaxPlayers !== null ? '/' + soloMaxPlayers : ''"
|
||||||
|
note="live via agent"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="cpu"
|
||||||
|
label="CPU"
|
||||||
|
:value="soloCpu !== null ? String(soloCpu) : '—'"
|
||||||
|
:unit="soloCpu !== null ? '%' : ''"
|
||||||
|
note="agent telemetry"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="memory-stick"
|
||||||
|
label="Memory"
|
||||||
|
:value="soloRamMb !== null ? (soloRamMb / 1024).toFixed(1) : '—'"
|
||||||
|
:unit="soloRamMb !== null ? 'GB' : ''"
|
||||||
|
:note="soloRamSub ?? 'agent telemetry'"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="gauge"
|
||||||
|
label="Server FPS"
|
||||||
|
:value="soloFps !== null ? String(soloFps) : '—'"
|
||||||
|
:unit="soloFps !== null ? 'fps' : ''"
|
||||||
|
note="live via agent"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Solo grid -->
|
<!-- Main grid -->
|
||||||
<div class="dash__grid">
|
<div class="dash__grid">
|
||||||
|
|
||||||
<!-- Left column -->
|
<!-- Left column -->
|
||||||
<div class="dash__col">
|
<div class="dash__col">
|
||||||
<!-- Players chart — themed ECharts -->
|
|
||||||
<Panel title="Players online" :subtitle="soloName + ' · last 24 hours'">
|
<!-- Players chart — real 24h data or honest empty state -->
|
||||||
<PlayersChart :height="196" :max="soloMaxPlayers" />
|
<Panel
|
||||||
|
title="Players online"
|
||||||
|
:subtitle="(soloName ?? 'Server') + ' · last 24 hours'"
|
||||||
|
>
|
||||||
|
<div v-if="chartLoading" class="chart-loading">Loading telemetry…</div>
|
||||||
|
<PlayersChart
|
||||||
|
v-else
|
||||||
|
:height="196"
|
||||||
|
:max="soloMaxPlayers ?? 200"
|
||||||
|
:data="chartData ?? undefined"
|
||||||
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Console panel -->
|
<!-- Console — real WebSocket lines only -->
|
||||||
<Panel :flush-body="true" title="Console">
|
<Panel :flush-body="true" title="Console">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Badge tone="online" :dot="true" :pulse="soloStatus === 'online'">Live</Badge>
|
<Badge
|
||||||
|
:tone="isConnected ? 'online' : 'offline'"
|
||||||
|
:dot="true"
|
||||||
|
:pulse="isConnected"
|
||||||
|
>{{ isConnected ? 'Live' : 'Disconnected' }}</Badge>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="feed feed--solo">
|
<div class="feed feed--solo">
|
||||||
<ConsoleLine
|
<template v-if="consoleLines.length > 0">
|
||||||
v-for="(f, i) in MOCK_FEED"
|
<ConsoleLineDS
|
||||||
:key="i"
|
v-for="(line, i) in consoleLines"
|
||||||
:time="f.time"
|
:key="i"
|
||||||
:level="f.level"
|
:time="line.time"
|
||||||
:who="f.who"
|
:level="line.level"
|
||||||
>{{ f.msg }}</ConsoleLine>
|
:who="line.who"
|
||||||
|
>{{ line.msg }}</ConsoleLineDS>
|
||||||
|
</template>
|
||||||
|
<div v-else class="feed__empty">
|
||||||
|
<span v-if="isConnected">Waiting for output — try sending a command below</span>
|
||||||
|
<span v-else>Console offline — server is not connected</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="console-bar">
|
<div class="console-bar">
|
||||||
<span class="console-bar__prompt">></span>
|
<span class="console-bar__prompt">></span>
|
||||||
<Input
|
<Input
|
||||||
@@ -358,58 +379,88 @@ onMounted(() => {
|
|||||||
:mono="true"
|
:mono="true"
|
||||||
size="sm"
|
size="sm"
|
||||||
placeholder="say, kick, ban, oxide.reload …"
|
placeholder="say, kick, ban, oxide.reload …"
|
||||||
|
:disabled="!isConnected"
|
||||||
style="flex: 1"
|
style="flex: 1"
|
||||||
@keydown.enter="sendConsoleCommand"
|
@keydown.enter="sendConsoleCommand"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="secondary" icon="corner-down-left" @click="sendConsoleCommand">Send</Button>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="corner-down-left"
|
||||||
|
:disabled="!isConnected"
|
||||||
|
@click="sendConsoleCommand"
|
||||||
|
>Send</Button>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right sidebar -->
|
<!-- Right sidebar -->
|
||||||
<div class="dash__col dash__col--side">
|
<div class="dash__col dash__col--side">
|
||||||
<!-- Resources -->
|
|
||||||
|
<!-- Resources — real stats from agent; null = '—' -->
|
||||||
<Panel title="Resources" subtitle="Companion agent telemetry">
|
<Panel title="Resources" subtitle="Companion agent telemetry">
|
||||||
<div class="solo-meters">
|
<div class="solo-meters">
|
||||||
<ResourceMeter label="CPU" :value="soloCpuPct" sub="representative" />
|
<ResourceMeter
|
||||||
<ResourceMeter label="Memory" :value="soloRamPct" :sub="soloRamSub" />
|
label="CPU"
|
||||||
<ResourceMeter label="Disk" :value="64" sub="representative" />
|
:value="soloCpu ?? 0"
|
||||||
|
:sub="soloCpu !== null ? soloCpu + '%' : 'awaiting telemetry'"
|
||||||
|
/>
|
||||||
|
<ResourceMeter
|
||||||
|
label="Memory"
|
||||||
|
:value="soloRamPct ?? 0"
|
||||||
|
:sub="soloRamSub ?? 'awaiting telemetry'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="soloCpu === null && soloRamMb === null" class="meters-note">
|
||||||
|
Resource metrics arrive via the companion agent heartbeat.
|
||||||
|
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
|
||||||
|
Agent setup
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Plugins -->
|
<!-- Next wipe — real schedule from wipeStore -->
|
||||||
<Panel :flush-body="true" title="Plugins" subtitle="uMod / Oxide">
|
|
||||||
<template #actions>
|
|
||||||
<Button size="sm" variant="ghost" icon="plus" @click="router.push('/plugins')">Add</Button>
|
|
||||||
</template>
|
|
||||||
<div class="plugs">
|
|
||||||
<div
|
|
||||||
v-for="(p, i) in pluginStates"
|
|
||||||
:key="i"
|
|
||||||
class="plug"
|
|
||||||
>
|
|
||||||
<div class="plug__id">
|
|
||||||
<span class="plug__name">{{ p.name }}</span>
|
|
||||||
<span class="plug__ver">{{ p.ver }}</span>
|
|
||||||
</div>
|
|
||||||
<Switch v-model="p.on" size="sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- Next wipe -->
|
|
||||||
<Panel title="Next wipe">
|
<Panel title="Next wipe">
|
||||||
<div class="solo-wipe">
|
<div v-if="nextWipe" class="solo-wipe">
|
||||||
<div>
|
<div>
|
||||||
<div class="solo-wipe__when">Thu · 18:00 UTC</div>
|
<div class="solo-wipe__type">{{ nextWipeType }}</div>
|
||||||
<div class="solo-wipe__sub">representative — configure in wipe manager</div>
|
<div class="solo-wipe__when">{{ nextWipeLabel }}</div>
|
||||||
|
<div class="solo-wipe__name">{{ nextWipe.schedule_name }}</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">Edit</Button>
|
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">Edit</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
icon="calendar"
|
||||||
|
title="No wipe scheduled"
|
||||||
|
description="Configure automatic wipes in the wipe manager."
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">
|
||||||
|
Open wipe manager
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="page__head">
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Dashboard</div>
|
||||||
|
<h1 class="page__title">Server cockpit</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Panel>
|
||||||
|
<div class="dash-loading">Loading server data…</div>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -431,23 +482,6 @@ onMounted(() => {
|
|||||||
.dash__grid { display: grid; grid-template-columns: minmax(0, 1fr) 366px; gap: 16px; align-items: start; }
|
.dash__grid { display: grid; grid-template-columns: minmax(0, 1fr) 366px; gap: 16px; align-items: start; }
|
||||||
.dash__col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
.dash__col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
||||||
|
|
||||||
/* ---------- Servers list ---------- */
|
|
||||||
.server__list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 14px; }
|
|
||||||
.server__empty { grid-column: 1 / -1; font-size: var(--text-sm); color: var(--text-muted); text-align: center; padding: 24px; }
|
|
||||||
|
|
||||||
/* ---------- Live feed ---------- */
|
|
||||||
.feed { padding: 8px 2px; max-height: 340px; overflow-y: auto; }
|
|
||||||
.feed--solo { max-height: 230px; }
|
|
||||||
|
|
||||||
/* ---------- Upcoming wipes ---------- */
|
|
||||||
.wipes { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.wipe { display: flex; align-items: center; gap: 11px; padding: 9px 6px; border-radius: var(--radius-md); }
|
|
||||||
.wipe:hover { background: var(--surface-hover); }
|
|
||||||
.wipe__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; box-shadow: 0 0 10px -1px var(--accent-glow); }
|
|
||||||
.wipe__body { flex: 1; min-width: 0; }
|
|
||||||
.wipe__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.wipe__when { font-family: var(--font-mono); font-size: 11px; color: var(--text-tertiary); margin-top: 1px; }
|
|
||||||
|
|
||||||
/* ---------- Solo identity header ---------- */
|
/* ---------- Solo identity header ---------- */
|
||||||
.solo-id { display: flex; align-items: center; gap: 13px; }
|
.solo-id { display: flex; align-items: center; gap: 13px; }
|
||||||
.solo-id__chip {
|
.solo-id__chip {
|
||||||
@@ -463,6 +497,21 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.solo-id__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 3px; }
|
.solo-id__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 3px; }
|
||||||
|
|
||||||
|
/* ---------- Chart loading ---------- */
|
||||||
|
.chart-loading {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
height: 196px; font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Console feed ---------- */
|
||||||
|
.feed { padding: 8px 2px; max-height: 340px; overflow-y: auto; }
|
||||||
|
.feed--solo { max-height: 230px; }
|
||||||
|
.feed__empty {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
height: 100px; font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Console bar ---------- */
|
/* ---------- Console bar ---------- */
|
||||||
.console-bar {
|
.console-bar {
|
||||||
display: flex; align-items: center; gap: 9px; padding: 11px 12px;
|
display: flex; align-items: center; gap: 9px; padding: 11px 12px;
|
||||||
@@ -472,24 +521,28 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* ---------- Resources ---------- */
|
/* ---------- Resources ---------- */
|
||||||
.solo-meters { display: flex; flex-direction: column; gap: 13px; }
|
.solo-meters { display: flex; flex-direction: column; gap: 13px; }
|
||||||
|
.meters-note {
|
||||||
/* ---------- Plugin list ---------- */
|
margin-top: 14px; font-size: var(--text-xs); color: var(--text-muted);
|
||||||
.plugs { display: flex; flex-direction: column; }
|
border-top: 1px solid var(--border-subtle); padding-top: 12px;
|
||||||
.plug { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); }
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
.plug:last-child { border-bottom: 0; }
|
}
|
||||||
.plug__id { display: flex; align-items: baseline; gap: 9px; min-width: 0; }
|
.meters-cta { margin-left: auto; }
|
||||||
.plug__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
|
||||||
.plug__ver { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* ---------- Next wipe ---------- */
|
/* ---------- Next wipe ---------- */
|
||||||
.solo-wipe { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
.solo-wipe { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||||
.solo-wipe__when { font-family: var(--font-mono); font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
|
.solo-wipe__type { font-size: var(--text-xs); color: var(--text-tertiary); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-wider); margin-bottom: 3px; }
|
||||||
.solo-wipe__sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
.solo-wipe__when { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
||||||
|
.solo-wipe__name { font-size: var(--text-xs); color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ---------- Loading ---------- */
|
||||||
|
.dash-loading {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 60px; font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Responsive ---------- */
|
/* ---------- Responsive ---------- */
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.dash__grid { grid-template-columns: 1fr; }
|
.dash__grid { grid-template-columns: 1fr; }
|
||||||
.server__list { grid-template-columns: 1fr; }
|
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dash__kpis { grid-template-columns: repeat(2, 1fr); }
|
.dash__kpis { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dashboard mock data — representative placeholder pending multi-instance backend.
|
|
||||||
* Current backend is single-server-per-license; the fleet view is a forward-looking
|
|
||||||
* surface that will bind to a multi-instance API. All data here is static and clearly
|
|
||||||
* labeled so it is never confused for real tenant data.
|
|
||||||
*
|
|
||||||
* Per-game fields are isolated by game key — a Dune row NEVER receives a Rust field
|
|
||||||
* like `umod`, and vice-versa. See GAME_FIELDS for the row-field contract.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type ServerStatus = 'online' | 'offline' | 'starting' | 'wiping' | 'updating'
|
|
||||||
export type GameKey = 'rust' | 'dune' | 'conan' | 'soulmask'
|
|
||||||
|
|
||||||
export interface MockServer {
|
|
||||||
game: GameKey
|
|
||||||
gameIcon: string
|
|
||||||
name: string
|
|
||||||
region: string
|
|
||||||
map: string
|
|
||||||
version: string
|
|
||||||
status: ServerStatus
|
|
||||||
players: { cur: number; max: number }
|
|
||||||
cpu?: number
|
|
||||||
ram?: number
|
|
||||||
ramSub?: string
|
|
||||||
ip: string
|
|
||||||
// Rust-only
|
|
||||||
umod?: string
|
|
||||||
wipe?: string
|
|
||||||
// Dune-only
|
|
||||||
sietches?: string
|
|
||||||
control?: string
|
|
||||||
// Conan-only
|
|
||||||
clans?: string
|
|
||||||
purge?: string
|
|
||||||
// Soulmask-only
|
|
||||||
tribe?: string
|
|
||||||
mask?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MockFeedLine {
|
|
||||||
time: string
|
|
||||||
level: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
|
|
||||||
who?: string
|
|
||||||
msg: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MockWipe {
|
|
||||||
game: GameKey
|
|
||||||
name: string
|
|
||||||
when: string
|
|
||||||
tone: 'wiping' | 'starting' | 'warn' | 'online'
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatItem {
|
|
||||||
label: string
|
|
||||||
value: string | number
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Fleet server roster
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const MOCK_SERVERS: MockServer[] = [
|
|
||||||
{
|
|
||||||
game: 'rust', gameIcon: 'box', name: 'Main · 2x Vanilla', region: 'US-East',
|
|
||||||
map: 'Procedural 4500', version: 'v2024.12', status: 'online',
|
|
||||||
players: { cur: 142, max: 200 }, cpu: 41, ram: 68, ramSub: '5.4 / 8 GB',
|
|
||||||
ip: '89.142.0.7:28015', umod: '14', wipe: '2d',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'rust', gameIcon: 'box', name: '5x Modded · Build', region: 'US-East',
|
|
||||||
map: 'Barren 3000', version: 'v2024.12', status: 'online',
|
|
||||||
players: { cur: 38, max: 100 }, ip: '89.142.0.7:28017', umod: '27', wipe: '2d',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'rust', gameIcon: 'box', name: 'Hardcore · Solo/Duo', region: 'US-West',
|
|
||||||
map: 'Procedural 3500', version: 'v2024.12', status: 'wiping',
|
|
||||||
players: { cur: 0, max: 80 }, cpu: 8, ram: 30, ramSub: '2.4 / 8 GB',
|
|
||||||
ip: '74.91.3.2:28015', umod: '9', wipe: 'now',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'dune', gameIcon: 'sun', name: 'Arrakis · Hardcore', region: 'EU-Frankfurt',
|
|
||||||
map: 'Hagga Basin', version: 'v0.9.4', status: 'online',
|
|
||||||
players: { cur: 54, max: 60 }, cpu: 63, ram: 74, ramSub: '11.8 / 16 GB',
|
|
||||||
ip: '51.83.12.4:7777', sietches: '3', control: '62%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'dune', gameIcon: 'sun', name: 'Deep Desert · PvP', region: 'EU-Frankfurt',
|
|
||||||
map: 'Deep Desert', version: 'v0.9.4', status: 'starting',
|
|
||||||
players: { cur: 0, max: 40 }, ip: '51.83.12.4:7779', sietches: '0', control: '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'dune', gameIcon: 'sun', name: 'Sietch · Roleplay', region: 'SG-Singapore',
|
|
||||||
map: 'Hagga Basin', version: 'v0.9.4', status: 'offline',
|
|
||||||
players: { cur: 0, max: 50 }, ip: '139.99.4.8:7777', sietches: '5', control: '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'conan', gameIcon: 'swords', name: 'Exiled Lands · PvP-C', region: 'US-East',
|
|
||||||
map: 'Exiled Lands', version: 'v3.0.5', status: 'online',
|
|
||||||
players: { cur: 32, max: 40 }, cpu: 48, ram: 60, ramSub: '9.6 / 16 GB',
|
|
||||||
ip: '89.142.0.7:7777', clans: '7', purge: 'Tier 4',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: 'soulmask', gameIcon: 'drama', name: 'Sienna Plateau · PvE', region: 'EU-Frankfurt',
|
|
||||||
map: 'Sienna Plateau', version: 'v1.4', status: 'online',
|
|
||||||
players: { cur: 18, max: 30 }, cpu: 35, ram: 52, ramSub: '8.3 / 16 GB',
|
|
||||||
ip: '51.83.12.4:8777', tribe: '4', mask: 'Jaguar',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Per-game stat field sets — never share slots across games
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function pl(s: MockServer): string {
|
|
||||||
return `${s.players.cur} / ${s.players.max}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GAME_FIELDS: Record<GameKey, (s: MockServer) => StatItem[]> = {
|
|
||||||
rust: (s) => [{ label: 'Players', value: pl(s) }, { label: 'uMod', value: s.umod ?? '—' }, { label: 'Wipe', value: s.wipe ?? '—' }],
|
|
||||||
dune: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Sietches', value: s.sietches ?? '—' }, { label: 'Control', value: s.control ?? '—' }],
|
|
||||||
conan: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Clans', value: s.clans ?? '—' }, { label: 'Purge', value: s.purge ?? '—' }],
|
|
||||||
soulmask: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Tribe', value: s.tribe ?? '—' }, { label: 'Mask', value: s.mask ?? '—' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildStats(s: MockServer): StatItem[] {
|
|
||||||
const fn = GAME_FIELDS[s.game] ?? GAME_FIELDS.rust
|
|
||||||
return fn(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Live activity feed
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const MOCK_FEED: MockFeedLine[] = [
|
|
||||||
{ time: '18:42:07', level: 'connect', who: 'ShadowFox', msg: 'connected — 89.142.0.7' },
|
|
||||||
{ time: '18:41:55', level: 'cmd', who: 'admin', msg: 'oxide.grant group default kits.use' },
|
|
||||||
{ time: '18:41:30', level: 'kill', who: 'ironMaiden', msg: 'was killed by Scorpion (AK-47, 84m)' },
|
|
||||||
{ time: '18:40:12', level: 'warn', msg: '5x Modded agent reconnected — telemetry resuming' },
|
|
||||||
{ time: '18:39:48', level: 'chat', who: 'BlightWalker:', msg: 'anyone selling sulfur?' },
|
|
||||||
{ time: '18:38:02', level: 'info', msg: 'RaidableBases spawned Tier-3 at G14' },
|
|
||||||
{ time: '18:36:51', level: 'connect', who: 'Vex', msg: 'connected — 51.83.12.4' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Upcoming wipes
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const MOCK_WIPES: MockWipe[] = [
|
|
||||||
{ game: 'rust', name: 'Main · 2x Vanilla', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map + BP' },
|
|
||||||
{ game: 'rust', name: '5x Modded · Build', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map only' },
|
|
||||||
{ game: 'dune', name: 'Deep Desert · PvP', when: 'Sun · 12:00 UTC', tone: 'starting', label: 'Deep Desert' },
|
|
||||||
]
|
|
||||||
@@ -1,288 +1,680 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Shield, Zap, RefreshCw, Terminal, Users, Wifi, Server, ChevronRight } from 'lucide-vue-next'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import { useThemeGame, type Game } from '@/composables/useThemeGame'
|
||||||
|
|
||||||
const panelUrl = import.meta.env.VITE_PANEL_URL || ''
|
const { setGame } = useThemeGame()
|
||||||
|
|
||||||
|
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
|
||||||
|
|
||||||
|
// ---- Game pill data ----
|
||||||
|
interface GameDef {
|
||||||
|
key: Game
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const GAMES: GameDef[] = [
|
||||||
|
{ key: 'rust', label: 'Rust', icon: 'box' },
|
||||||
|
{ key: 'dune', label: 'Dune: Awakening', icon: 'sun' },
|
||||||
|
{ key: 'soulmask', label: 'Soulmask', icon: 'drama' },
|
||||||
|
{ key: 'conan', label: 'Conan Exiles', icon: 'swords' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeGame = ref<Game>('rust')
|
||||||
|
const userPicked = ref(false)
|
||||||
|
let rotateTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let idx = 0
|
||||||
|
|
||||||
|
function pickGame(g: Game): void {
|
||||||
|
userPicked.value = true
|
||||||
|
activeGame.value = g
|
||||||
|
setGame(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
function heroIsVisible(): boolean {
|
||||||
|
const hero = document.querySelector('.hero')
|
||||||
|
if (!hero) return false
|
||||||
|
return hero.getBoundingClientRect().bottom >= 140
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRotation(): void {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
|
rotateTimer = setInterval(() => {
|
||||||
|
if (userPicked.value || !heroIsVisible()) return
|
||||||
|
idx = (idx + 1) % GAMES.length
|
||||||
|
const next = GAMES[idx]
|
||||||
|
if (next) {
|
||||||
|
activeGame.value = next.key
|
||||||
|
setGame(next.key)
|
||||||
|
}
|
||||||
|
}, 3400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Scroll-reveal via IntersectionObserver ----
|
||||||
|
let io: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
function initReveal(): void {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add('in')
|
||||||
|
io?.unobserve(e.target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
)
|
||||||
|
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Set initial game on html — stays in sync with the global composable
|
||||||
|
setGame('rust')
|
||||||
|
activeGame.value = 'rust'
|
||||||
|
startRotation()
|
||||||
|
initReveal()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (rotateTimer !== null) clearInterval(rotateTimer)
|
||||||
|
io?.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock sidebar game switcher active key mirrors activeGame
|
||||||
|
const mockActiveGame = activeGame
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<!-- HERO -->
|
||||||
<!-- Hero -->
|
<section class="hero">
|
||||||
<section class="relative overflow-hidden">
|
<div class="hero__atmo" />
|
||||||
<div class="max-w-6xl mx-auto px-6 pt-20 pb-24 text-center">
|
<div class="hero__grid" />
|
||||||
<h1 class="text-5xl md:text-6xl font-bold text-neutral-100 mb-6 tracking-tight">
|
<div class="hero__grain" />
|
||||||
The Control Plane<br />
|
<div class="wrap hero__in">
|
||||||
<span class="text-oxide-500">for Rust Servers</span>
|
<div class="hero__mark">
|
||||||
</h1>
|
<CorrosionMark :size="72" />
|
||||||
<p class="text-xl text-neutral-400 max-w-2xl mx-auto mb-10">
|
|
||||||
Deploy once. Automate everything. Never SSH again.
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center justify-center gap-4">
|
|
||||||
<a :href="panelUrl + '/register'" class="px-8 py-3.5 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors text-lg">
|
|
||||||
Buy License
|
|
||||||
</a>
|
|
||||||
<a href="#demo" class="px-8 py-3.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-semibold rounded-lg border border-neutral-700 transition-colors text-lg">
|
|
||||||
View Live Demo
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Gradient glow -->
|
<div class="notpill">
|
||||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-oxide-500/10 rounded-full blur-3xl pointer-events-none" />
|
<b>Not hosting.</b> Not a generic panel. Self-hosted, agent-based.
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- The Problem -->
|
|
||||||
<section class="py-20 border-t border-neutral-800">
|
|
||||||
<div class="max-w-4xl mx-auto px-6">
|
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-8 text-center">The Problem</h2>
|
|
||||||
<p class="text-neutral-400 text-lg mb-8 text-center">Running a Rust server today means:</p>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto mb-10">
|
|
||||||
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<span class="text-red-400 mt-0.5">✗</span>
|
|
||||||
<span class="text-neutral-300">Editing JSON configs over SFTP</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<span class="text-red-400 mt-0.5">✗</span>
|
|
||||||
<span class="text-neutral-300">Babysitting wipes</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<span class="text-red-400 mt-0.5">✗</span>
|
|
||||||
<span class="text-neutral-300">Manually installing and updating plugins</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<span class="text-red-400 mt-0.5">✗</span>
|
|
||||||
<span class="text-neutral-300">Restarting servers blindly</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg md:col-span-2 md:max-w-sm md:mx-auto">
|
|
||||||
<span class="text-red-400 mt-0.5">✗</span>
|
|
||||||
<span class="text-neutral-300">Staying online when something crashes</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-center text-lg text-neutral-300 font-medium">
|
|
||||||
Rust servers deserve <span class="text-oxide-400">orchestration</span> — not babysitting.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<h1>Run your game servers<span class="accent">like an operation.</span></h1>
|
||||||
|
<p class="hero__sub">
|
||||||
<!-- The Shift -->
|
Corrosion is a management panel for self-hosted survival game servers. Deploy servers, automate
|
||||||
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
|
wipes, manage plugins and mods, schedule maintenance, monitor players, and orchestrate
|
||||||
<div class="max-w-4xl mx-auto px-6 text-center">
|
multi-server worlds — from one command center.
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-8">The Shift</h2>
|
</p>
|
||||||
<p class="text-lg text-neutral-400 mb-10">
|
<div class="hero__cta">
|
||||||
Corrosion moves Rust server administration to a unified cloud control plane.
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
</p>
|
Join early access
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
</RouterLink>
|
||||||
<div class="p-6 bg-neutral-900 border border-neutral-800 rounded-lg">
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
<div class="w-10 h-10 bg-oxide-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
<Icon name="play" :size="17" />View live demo
|
||||||
<span class="text-oxide-400 text-xl font-bold">1</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-neutral-200 font-medium">Install one plugin</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<div class="w-10 h-10 bg-oxide-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
|
||||||
<span class="text-oxide-400 text-xl font-bold">2</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-neutral-200 font-medium">Register online</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<div class="w-10 h-10 bg-oxide-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
|
||||||
<span class="text-oxide-400 text-xl font-bold">3</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-neutral-200 font-medium">Manage everything from your browser</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col md:flex-row items-center justify-center gap-4 text-neutral-500">
|
|
||||||
<span>No open firewall ports.</span>
|
|
||||||
<span class="hidden md:inline">·</span>
|
|
||||||
<span>No manual file editing.</span>
|
|
||||||
<span class="hidden md:inline">·</span>
|
|
||||||
<span>No SSH required.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Core Capabilities -->
|
|
||||||
<section class="py-20 border-t border-neutral-800">
|
|
||||||
<div class="max-w-6xl mx-auto px-6">
|
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-12 text-center">Core Capabilities</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
||||||
<!-- Operational Control -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-oxide-400 mb-6 uppercase tracking-wider">Operational Control</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<RefreshCw class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-neutral-200 font-medium">Auto-Wiper with Rollback</p>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Full wipe sequences with health verification</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<Terminal class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-neutral-200 font-medium">Real-Time Console + Player Control</p>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Execute commands from your browser</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<Zap class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-neutral-200 font-medium">Web-Based Plugin Configuration</p>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">No more JSON editing over SFTP</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<Server class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-neutral-200 font-medium">Automated Steam Updates</p>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Stay current without manual intervention</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Infrastructure & Scale -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-oxide-400 mb-6 uppercase tracking-wider">Infrastructure & Scale</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<Shield class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-neutral-200 font-medium">Companion Agent — No SSH Required</p>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Outbound-only secure connections</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<Users class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-neutral-200 font-medium">Multi-Admin Role-Based Access Control</p>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Scale your team without losing order</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<Wifi class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-neutral-200 font-medium">Zero Inbound Ports Required</p>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Your server initiates all connections</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Wipe Orchestration -->
|
|
||||||
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
|
|
||||||
<div class="max-w-4xl mx-auto px-6">
|
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-4 text-center">Wipe Orchestration</h2>
|
|
||||||
<p class="text-lg text-neutral-400 mb-10 text-center">
|
|
||||||
Wipes aren't just "delete map and restart."
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-3 mb-10">
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Pre-Wipe</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Backup</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Map Rotation</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Steam Update</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Restart</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Health Check</span>
|
|
||||||
<ChevronRight class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="px-4 py-2 bg-oxide-500/15 border border-oxide-500/30 rounded-lg text-sm text-oxide-400">Rollback</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col md:flex-row items-center justify-center gap-6 text-neutral-400">
|
|
||||||
<span>Every wipe is logged.</span>
|
|
||||||
<span>Every step is verified.</span>
|
|
||||||
<span class="text-oxide-400 font-medium">Rollback is one click away.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Built Like Infrastructure -->
|
|
||||||
<section class="py-20 border-t border-neutral-800">
|
|
||||||
<div class="max-w-4xl mx-auto px-6 text-center">
|
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-4">Built Like Infrastructure</h2>
|
|
||||||
<p class="text-lg text-neutral-400 mb-10">
|
|
||||||
Corrosion isn't a UI wrapper. It's a hosted SaaS platform built with:
|
|
||||||
</p>
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-10">
|
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<p class="text-oxide-400 font-semibold mb-1">NestJS</p>
|
|
||||||
<p class="text-xs text-neutral-500">TypeScript backend</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<p class="text-oxide-400 font-semibold mb-1">NATS</p>
|
|
||||||
<p class="text-xs text-neutral-500">JetStream messaging</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<p class="text-oxide-400 font-semibold mb-1">PostgreSQL</p>
|
|
||||||
<p class="text-xs text-neutral-500">Multi-tenant isolation</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<p class="text-oxide-400 font-semibold mb-1">Outbound-Only</p>
|
|
||||||
<p class="text-xs text-neutral-500">Secure connections</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-center gap-2 text-neutral-500 text-sm">
|
|
||||||
<span>Every server is scoped by license.</span>
|
|
||||||
<span>Every command is namespaced.</span>
|
|
||||||
<span>Every tenant is isolated.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Public Server Sites -->
|
|
||||||
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
|
|
||||||
<div class="max-w-4xl mx-auto px-6 text-center">
|
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-4">Public Server Sites & Storefront</h2>
|
|
||||||
<p class="text-lg text-neutral-400 mb-10">Each license includes:</p>
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
|
||||||
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<p class="text-sm text-neutral-200">Public server page</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<p class="text-sm text-neutral-200">Wipe countdown</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<p class="text-sm text-neutral-200">Live player count</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
|
|
||||||
<p class="text-sm text-neutral-200">Plugin/mod list</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 bg-neutral-900 border border-oxide-500/30 rounded-lg">
|
|
||||||
<p class="text-sm text-oxide-400">Integrated webstore</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-neutral-400">Monetize your server without third-party complexity.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- For Serious Admins -->
|
|
||||||
<section class="py-20 border-t border-neutral-800">
|
|
||||||
<div class="max-w-3xl mx-auto px-6 text-center">
|
|
||||||
<h2 class="text-3xl font-bold text-neutral-100 mb-8">For Serious Admins</h2>
|
|
||||||
<p class="text-lg text-neutral-400 mb-8">If you:</p>
|
|
||||||
<div class="space-y-3 max-w-md mx-auto mb-10">
|
|
||||||
<p class="text-neutral-300 text-lg">Run scheduled wipes</p>
|
|
||||||
<p class="text-neutral-300 text-lg">Care about uptime</p>
|
|
||||||
<p class="text-neutral-300 text-lg">Want crash recovery</p>
|
|
||||||
<p class="text-neutral-300 text-lg">Want automation</p>
|
|
||||||
<p class="text-neutral-300 text-lg">Manage multiple admins</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl text-oxide-400 font-semibold">Corrosion was built for you.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- CTA -->
|
|
||||||
<section class="py-24 bg-neutral-900/50 border-t border-neutral-800">
|
|
||||||
<div class="max-w-3xl mx-auto px-6 text-center">
|
|
||||||
<p class="text-2xl text-neutral-400 mb-2">Stop babysitting your server.</p>
|
|
||||||
<p class="text-3xl font-bold text-neutral-100 mb-10">Start orchestrating it.</p>
|
|
||||||
<a :href="panelUrl + '/register'" class="inline-block px-10 py-4 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors text-lg">
|
|
||||||
Get Corrosion
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<!-- Game pills -->
|
||||||
</div>
|
<div class="hero__games">
|
||||||
|
<button
|
||||||
|
v-for="g in GAMES"
|
||||||
|
:key="g.key"
|
||||||
|
class="gpill"
|
||||||
|
:data-on="String(activeGame === g.key)"
|
||||||
|
@click="pickGame(g.key)"
|
||||||
|
>
|
||||||
|
<Icon :name="g.icon" :size="15" />
|
||||||
|
<span>{{ g.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel mockup -->
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="mock">
|
||||||
|
<div class="mock__bar">
|
||||||
|
<div class="mock__dots">
|
||||||
|
<span /><span /><span />
|
||||||
|
</div>
|
||||||
|
<div class="mock__url">panel.corrosionmgmt.com / fleet</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock__body">
|
||||||
|
<aside class="mock__side">
|
||||||
|
<div class="mock__brand">
|
||||||
|
<span class="mark"><CorrosionMark :size="18" /></span>
|
||||||
|
<b>Corrosion</b>
|
||||||
|
</div>
|
||||||
|
<div class="mock__gs">
|
||||||
|
<span :class="{ on: mockActiveGame === 'rust' }">
|
||||||
|
<Icon name="box" :size="13" />
|
||||||
|
</span>
|
||||||
|
<span :class="{ on: mockActiveGame === 'dune' }">
|
||||||
|
<Icon name="sun" :size="13" />
|
||||||
|
</span>
|
||||||
|
<span :class="{ on: mockActiveGame === 'soulmask' }">
|
||||||
|
<Icon name="drama" :size="13" />
|
||||||
|
</span>
|
||||||
|
<span :class="{ on: mockActiveGame === 'conan' }">
|
||||||
|
<Icon name="swords" :size="13" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mock__nav on"><Icon name="layout-dashboard" :size="14" />Dashboard</div>
|
||||||
|
<div class="mock__nav"><Icon name="server" :size="14" />Servers</div>
|
||||||
|
<div class="mock__nav"><Icon name="terminal" :size="14" />Console</div>
|
||||||
|
<div class="mock__nav"><Icon name="trash-2" :size="14" />Wipes</div>
|
||||||
|
<div class="mock__nav"><Icon name="cpu" :size="14" />Agents</div>
|
||||||
|
</aside>
|
||||||
|
<main class="mock__main">
|
||||||
|
<div class="mock__kpis">
|
||||||
|
<div class="mock__kpi">
|
||||||
|
<div class="l">Servers running</div>
|
||||||
|
<div class="v">5<small>/6</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="mock__kpi">
|
||||||
|
<div class="l">Players online</div>
|
||||||
|
<div class="v">234</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock__kpi">
|
||||||
|
<div class="l">Agent nodes</div>
|
||||||
|
<div class="v">2<small>/2</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock__row">
|
||||||
|
<span class="g"><Icon name="box" :size="13" /></span>
|
||||||
|
<span class="nm">
|
||||||
|
Main · 2x Vanilla
|
||||||
|
<small>asgard-01 · rust</small>
|
||||||
|
</span>
|
||||||
|
<span class="st"><b />online</span>
|
||||||
|
</div>
|
||||||
|
<div class="mock__row">
|
||||||
|
<span class="g"><Icon name="sun" :size="13" /></span>
|
||||||
|
<span class="nm">
|
||||||
|
Arrakis · Hardcore
|
||||||
|
<small>asgard-01 · dune</small>
|
||||||
|
</span>
|
||||||
|
<span class="st"><b />online</span>
|
||||||
|
</div>
|
||||||
|
<div class="mock__row">
|
||||||
|
<span class="g"><Icon name="swords" :size="13" /></span>
|
||||||
|
<span class="nm">
|
||||||
|
Exiled Lands · PvP-C
|
||||||
|
<small>asgard-02 · conan</small>
|
||||||
|
</span>
|
||||||
|
<span class="st"><b />online</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wrap" style="text-align:center">
|
||||||
|
<div class="hero__foot">
|
||||||
|
One host agent, many game instances · Rust · Dune: Awakening · Soulmask · Conan Exiles ·
|
||||||
|
Windows & Linux hosts
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="height:80px" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PROBLEM -->
|
||||||
|
<section class="sec" id="problem">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">The problem</span>
|
||||||
|
<h2 class="title">Game servers were never supposed<br>to be babysitting duty</h2>
|
||||||
|
</div>
|
||||||
|
<div class="pain reveal">
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Editing configs over SFTP
|
||||||
|
</div>
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Manually updating mods & plugins
|
||||||
|
</div>
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Guessing when a server crashed
|
||||||
|
</div>
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Running wipe day by hand
|
||||||
|
</div>
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Juggling Discord bots & cron tasks
|
||||||
|
</div>
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Linking multi-server clusters manually
|
||||||
|
</div>
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Managing admins without real permissions
|
||||||
|
</div>
|
||||||
|
<div class="pain__item">
|
||||||
|
<span class="pain__x"><Icon name="x" :size="14" /></span>
|
||||||
|
Explaining downtime to players
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="closing reveal">
|
||||||
|
Your community sees the server. You deal with the chaos.<br>
|
||||||
|
<span class="accent">Corrosion gives you the control plane.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SHIFT -->
|
||||||
|
<section class="sec" id="shift">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">The shift</span>
|
||||||
|
<h2 class="title">Drop in the agent.<br>Take control from the panel.</h2>
|
||||||
|
<p class="lead">
|
||||||
|
One lightweight host agent runs on your machine and manages every game instance you assign
|
||||||
|
to it — an outbound-only ops runtime, not an exposed panel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="steps reveal">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__n">1</div>
|
||||||
|
<b>Install the Corrosion Agent</b>
|
||||||
|
<p>One runtime on your Windows or Linux host. Outbound connection only.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__n">2</div>
|
||||||
|
<b>Register your server or fleet</b>
|
||||||
|
<p>Connect one server, a cluster, or multiple game worlds on the same box.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__n">3</div>
|
||||||
|
<b>Manage from the browser</b>
|
||||||
|
<p>Console, files, schedules, wipes, plugins, mods, players, backups, metrics.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nots reveal">
|
||||||
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No open inbound ports</span>
|
||||||
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No constant SSH sessions</span>
|
||||||
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No config spelunking</span>
|
||||||
|
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No fragile scripts</span>
|
||||||
|
</div>
|
||||||
|
<p class="closing reveal" style="font-size:var(--text-lg)">
|
||||||
|
You provide the machine.
|
||||||
|
<span class="accent">Corrosion provides the control plane.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BLUEPRINTS / SUPPORTED GAMES -->
|
||||||
|
<section class="sec" id="blueprints">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Supported games</span>
|
||||||
|
<h2 class="title">Game-aware blueprints,<br>not generic templates</h2>
|
||||||
|
<p class="lead">
|
||||||
|
Every game has a different operational reality. Corrosion models each one as an operations
|
||||||
|
blueprint — Rust wipes, Dune: Awakening battlegroups, Soulmask clusters, Conan persistent
|
||||||
|
worlds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="blueprints reveal">
|
||||||
|
<!-- Rust card — sets its own game scope via inline style on the surrounding element.
|
||||||
|
The token system resolves var(--accent) from data-game on <html> (set globally by
|
||||||
|
useThemeGame). Cards carry a data-game attr for future per-card scoping if desired. -->
|
||||||
|
<div class="bp" data-game="rust">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="box" :size="21" /></span>
|
||||||
|
<div>
|
||||||
|
<div class="bp__name">Rust</div>
|
||||||
|
<div class="bp__accent">Oxide Orange</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp__role">Modded server operations</div>
|
||||||
|
<div class="bp__list">
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />uMod / Oxide plugin browsing</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Plugin config profiles</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Map / blueprint / full wipes</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Wipe schedules & map library</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Player & admin workflows</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp" data-game="dune">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="sun" :size="21" /></span>
|
||||||
|
<div>
|
||||||
|
<div class="bp__name">Dune: Awakening</div>
|
||||||
|
<div class="bp__accent">Spice Amber</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp__role">Battlegroup orchestration</div>
|
||||||
|
<div class="bp__list">
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Deep Desert wipe scheduling</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Procedural map regeneration</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Service health checks</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Backups before maintenance</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Battlegroup lifecycle controls</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp" data-game="soulmask">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="drama" :size="21" /></span>
|
||||||
|
<div>
|
||||||
|
<div class="bp__name">Soulmask</div>
|
||||||
|
<div class="bp__accent">Ritual Jade</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp__role">Linked-world cluster deployment</div>
|
||||||
|
<div class="bp__list">
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />One-click linked-server clusters</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Linked map validation</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Port & config automation</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Cluster health</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Scheduled maintenance</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp" data-game="conan">
|
||||||
|
<div class="bp__head">
|
||||||
|
<span class="bp__ic"><Icon name="swords" :size="21" /></span>
|
||||||
|
<div>
|
||||||
|
<div class="bp__name">Conan Exiles</div>
|
||||||
|
<div class="bp__accent">Hyborian Bronze</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp__role">Persistent world management</div>
|
||||||
|
<div class="bp__list">
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Mod & server management</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Clan & player visibility</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Purge, decay & event tracking</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Backup & restart scheduling</div>
|
||||||
|
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />World maintenance workflows</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CAPABILITIES -->
|
||||||
|
<section class="sec" id="caps">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Core capabilities</span>
|
||||||
|
<h2 class="title">Everything an operator needs</h2>
|
||||||
|
</div>
|
||||||
|
<div class="caps reveal">
|
||||||
|
<div class="caps__col">
|
||||||
|
<span class="eyebrow">Operations</span>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="power" :size="16" /></span>
|
||||||
|
<b>Server lifecycle control</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="terminal" :size="16" /></span>
|
||||||
|
<b>Real-time console</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="users" :size="16" /></span>
|
||||||
|
<b>Player visibility</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="folder-open" :size="16" /></span>
|
||||||
|
<b>File manager</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="calendar-clock" :size="16" /></span>
|
||||||
|
<b>Scheduled tasks & restart windows</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="caps__col">
|
||||||
|
<span class="eyebrow">Automation</span>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="refresh-cw" :size="16" /></span>
|
||||||
|
<b>Wipe orchestration</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="database-backup" :size="16" /></span>
|
||||||
|
<b>Backup-before-change</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="download" :size="16" /></span>
|
||||||
|
<b>SteamCMD / game updates</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="bell" :size="16" /></span>
|
||||||
|
<b>Discord / status announcements</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="undo-2" :size="16" /></span>
|
||||||
|
<b>Health checks & rollback</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="caps__col">
|
||||||
|
<span class="eyebrow">Game systems</span>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="puzzle" :size="16" /></span>
|
||||||
|
<b>Rust plugins & configs</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="sun" :size="16" /></span>
|
||||||
|
<b>Dune: Awakening battlegroups</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="drama" :size="16" /></span>
|
||||||
|
<b>Soulmask clusters</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="swords" :size="16" /></span>
|
||||||
|
<b>Conan mods & events</b>
|
||||||
|
</div>
|
||||||
|
<div class="feat">
|
||||||
|
<span class="feat__ic"><Icon name="store" :size="16" /></span>
|
||||||
|
<b>Public pages & storefront</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- WIPE & MAINTENANCE ORCHESTRATION -->
|
||||||
|
<section class="sec" id="wipe">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Wipe & maintenance orchestration</span>
|
||||||
|
<h2 class="title">Wipes should be workflows,<br>not rituals</h2>
|
||||||
|
<p class="lead">
|
||||||
|
Rust map / BP / full wipes. Dune: Awakening Deep Desert wipes. Soulmask & Conan
|
||||||
|
maintenance and event resets — all as verified, logged sequences.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pipe reveal">
|
||||||
|
<span class="pchip">Pre-warning</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Backup</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Stop services</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Update</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Rotate map / config</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Restart</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip">Health check</span>
|
||||||
|
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
|
||||||
|
<span class="pchip pchip--last">Announce complete</span>
|
||||||
|
</div>
|
||||||
|
<div class="stack-lines reveal">
|
||||||
|
<span>Every operation is logged.</span>
|
||||||
|
<span>Every step is verified.</span>
|
||||||
|
<span class="hi">Rollback is one click away when supported.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BUILT LIKE INFRASTRUCTURE -->
|
||||||
|
<section class="sec" id="platform">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Built like infrastructure</span>
|
||||||
|
<h2 class="title">Not a skin over SSH</h2>
|
||||||
|
<p class="lead">
|
||||||
|
A hosted control plane plus a host agent — with tenant isolation, command namespacing,
|
||||||
|
health reporting, and outbound-only connectivity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="infra reveal">
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
|
||||||
|
<b>Agent-based control</b>
|
||||||
|
<p>Your host connects to Corrosion. No exposed management panel required.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
|
||||||
|
<b>Tenant isolated</b>
|
||||||
|
<p>Every license, server, and command is scoped.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="route" :size="16" /></div>
|
||||||
|
<b>Command namespaced</b>
|
||||||
|
<p>Server actions are routed intentionally, not sprayed blindly.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
|
||||||
|
<b>Event-driven</b>
|
||||||
|
<p>NATS-powered messaging keeps agents and panel in sync.</p>
|
||||||
|
</div>
|
||||||
|
<div class="icard">
|
||||||
|
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
|
||||||
|
<b>Observable</b>
|
||||||
|
<p>Health, metrics, task history, and agent status — all visible.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="techrow reveal">
|
||||||
|
<span>NestJS</span>
|
||||||
|
<span>NATS JetStream</span>
|
||||||
|
<span>PostgreSQL</span>
|
||||||
|
<span>Go host agent</span>
|
||||||
|
<span>Outbound-only</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PUBLIC SITES & STOREFRONT -->
|
||||||
|
<section class="sec" id="store">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Public server sites & storefront</span>
|
||||||
|
<h2 class="title">Give your players a home base</h2>
|
||||||
|
<p class="lead">
|
||||||
|
Publish a server page with live status, wipe countdowns, player counts, plugin / mod lists,
|
||||||
|
announcements, and optional storefront support.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="chips reveal">
|
||||||
|
<div class="chip-card"><Icon name="globe" :size="16" style="color:var(--accent-text)" />Public server page</div>
|
||||||
|
<div class="chip-card"><Icon name="users" :size="16" style="color:var(--accent-text)" />Live player count</div>
|
||||||
|
<div class="chip-card"><Icon name="timer" :size="16" style="color:var(--accent-text)" />Wipe countdown</div>
|
||||||
|
<div class="chip-card"><Icon name="puzzle" :size="16" style="color:var(--accent-text)" />Mod / plugin list</div>
|
||||||
|
<div class="chip-card"><Icon name="megaphone" :size="16" style="color:var(--accent-text)" />Announcements</div>
|
||||||
|
<div class="chip-card chip-card--accent"><Icon name="shopping-cart" :size="16" />Integrated webstore</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="closing reveal"
|
||||||
|
style="font-size:var(--text-md);color:var(--text-tertiary);font-weight:500"
|
||||||
|
>
|
||||||
|
Operate the server. Inform the players. Monetize without duct tape.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PRICING -->
|
||||||
|
<section class="sec" id="pricing">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">Pricing</span>
|
||||||
|
<h2 class="title">Scale from one server to a fleet</h2>
|
||||||
|
</div>
|
||||||
|
<div class="pricing reveal">
|
||||||
|
<div class="plan">
|
||||||
|
<div class="plan__tag" />
|
||||||
|
<div class="plan__name">Hobby</div>
|
||||||
|
<div class="plan__price">$9.99<small>/mo</small></div>
|
||||||
|
<div class="plan__scope">1–5 non-commercial servers.</div>
|
||||||
|
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="plan">
|
||||||
|
<div class="plan__tag" />
|
||||||
|
<div class="plan__name">Community</div>
|
||||||
|
<div class="plan__price">$19.99<small>/mo</small></div>
|
||||||
|
<div class="plan__scope">6–10 non-commercial servers.</div>
|
||||||
|
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="plan plan--feature">
|
||||||
|
<div class="plan__tag">Most popular</div>
|
||||||
|
<div class="plan__name">Operator</div>
|
||||||
|
<div class="plan__price">$99.99<small>/mo</small></div>
|
||||||
|
<div class="plan__scope">Commercial use, or up to 50 servers.</div>
|
||||||
|
<RouterLink class="btn btn--primary" :to="{ name: 'early-access' }">Get Operator</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="plan">
|
||||||
|
<div class="plan__tag" />
|
||||||
|
<div class="plan__name">Network</div>
|
||||||
|
<div class="plan__price">$99.99<small>/mo</small></div>
|
||||||
|
<div class="plan__scope">50+ servers for fleets and hosting partners. Fleet Blocks add capacity.</div>
|
||||||
|
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fleetblock reveal">
|
||||||
|
<b>Fleet Block</b>
|
||||||
|
<span class="p">+$49.99/mo</span>
|
||||||
|
<span>each additional 50 servers — stack as many as your network needs.</span>
|
||||||
|
</div>
|
||||||
|
<p class="commercial reveal">
|
||||||
|
<b>Commercial use</b> includes monetized communities, paid access, VIP slots, donations,
|
||||||
|
sponsorship-supported servers, hosting providers, or managing servers for others.
|
||||||
|
</p>
|
||||||
|
<p class="support-note reveal">
|
||||||
|
Community support is included with every plan (documentation, community forum, diagnostics,
|
||||||
|
structured bug reports).
|
||||||
|
<b>Direct 1:1 support</b> is available separately at $125/hour in prepaid 1-hour blocks.
|
||||||
|
Corrosion is a tool, not a managed service.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FOR SERIOUS ADMINS -->
|
||||||
|
<section class="sec" id="admins">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec__head reveal">
|
||||||
|
<span class="eyebrow">For serious admins</span>
|
||||||
|
<h2 class="title">Built for admins<br>who are done babysitting</h2>
|
||||||
|
</div>
|
||||||
|
<div class="admins reveal">
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />You run more than a toy server.</span>
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Your players expect uptime.</span>
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Wipe day needs a plan.</span>
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Mods and plugins need control.</span>
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Admin access needs boundaries.</span>
|
||||||
|
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Your community deserves better than guesswork.</span>
|
||||||
|
</div>
|
||||||
|
<p class="closing reveal accent">Stop babysitting your server. Start orchestrating it.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FINAL CTA -->
|
||||||
|
<section class="finalcta">
|
||||||
|
<div class="finalcta__atmo" />
|
||||||
|
<div class="wrap finalcta__in reveal">
|
||||||
|
<h2>Ready to run your servers<br>like an operation?</h2>
|
||||||
|
<div class="cta-row">
|
||||||
|
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
|
||||||
|
Join early access
|
||||||
|
</RouterLink>
|
||||||
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
|
<Icon name="play" :size="17" />View live demo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user