Tenant-scoped fleet read: GET /api/fleet returns agent_hosts (host metrics) each with their game_instances, plus a summary (host/instance/online counts). FleetView lists host cards (status, CPU/ mem/disk/uptime/last-heartbeat) with their instances (game, state badge, uptime); honest empty state -> Server page when no hosts. New 'Fleet' sidebar nav item across all four game profiles, /fleet route. Store follows the no-throw-on-fetch pattern (error state, never bricks). The marketing hero made real from the live fleet tables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
520 lines
17 KiB
TypeScript
520 lines
17 KiB
TypeScript
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
// Extend vue-router's RouteMeta so title/description are typed throughout
|
||
declare module 'vue-router' {
|
||
interface RouteMeta {
|
||
title?: string
|
||
description?: string
|
||
requiresAuth?: boolean
|
||
guest?: boolean
|
||
superAdmin?: boolean
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Domain detection — runs once at module load
|
||
// Env-driven so www./staging hosts route correctly; an exact-match literal
|
||
// here once meant any non-canonical marketing host silently got the panel.
|
||
// ---------------------------------------------------------------------------
|
||
const hostname = typeof window !== 'undefined' ? window.location.hostname : ''
|
||
const marketingHosts = (import.meta.env.VITE_MARKETING_HOSTS ?? 'corrosionmgmt.com,www.corrosionmgmt.com')
|
||
.split(',')
|
||
.map((h: string) => h.trim().toLowerCase())
|
||
.filter(Boolean)
|
||
const isMarketingDomain = marketingHosts.includes(hostname.toLowerCase())
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Marketing page children — shared between both domain route sets
|
||
// ---------------------------------------------------------------------------
|
||
const marketingChildren: RouteRecordRaw[] = [
|
||
{
|
||
path: '',
|
||
name: 'landing',
|
||
component: () => import('@/views/marketing/LandingView.vue'),
|
||
meta: {
|
||
title: 'Corrosion — Game Server Operations for Self-Hosted Communities',
|
||
description: 'Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server.',
|
||
},
|
||
},
|
||
{
|
||
path: 'pricing',
|
||
name: 'pricing',
|
||
component: () => import('@/views/marketing/PricingView.vue'),
|
||
meta: {
|
||
title: 'Pricing — Corrosion',
|
||
description: 'Plans from $9.99/mo (Hobby, 1–5 servers) to Network ($99.99+/mo, 50+ servers). Non-commercial and commercial tiers. No hosting fees — bring your own server.',
|
||
},
|
||
},
|
||
{
|
||
path: 'how-it-works',
|
||
name: 'how-it-works',
|
||
component: () => import('@/views/marketing/HowItWorksView.vue'),
|
||
meta: {
|
||
title: 'How It Works — Corrosion',
|
||
description: 'Install one host agent on Windows or Linux. It connects outbound-only to Corrosion — no inbound ports, no SSH. Manage every game instance from the browser.',
|
||
},
|
||
},
|
||
{
|
||
path: 'faq',
|
||
name: 'faq',
|
||
component: () => import('@/views/marketing/FaqView.vue'),
|
||
meta: {
|
||
title: 'FAQ — Corrosion',
|
||
description: 'Honest answers: Corrosion is self-service (BYOS, no hosting). Support is docs + community; 1:1 at $125/hr. Supports Rust, Dune, Conan Exiles, Soulmask.',
|
||
},
|
||
},
|
||
{
|
||
path: 'roadmap',
|
||
name: 'roadmap',
|
||
component: () => import('@/views/marketing/RoadmapView.vue'),
|
||
meta: {
|
||
title: 'Roadmap — Corrosion',
|
||
description: 'Phase 1 shipped: core control plane, auto-wiper, plugin management. In progress: Dune, Conan, Soulmask multi-game blueprints. Planned: API access, integrations.',
|
||
},
|
||
},
|
||
{
|
||
path: 'early-access',
|
||
name: 'early-access',
|
||
component: () => import('@/views/marketing/EarlyAccessView.vue'),
|
||
meta: {
|
||
title: 'Early Access — Corrosion',
|
||
description: 'Join the early access list. Get full control plane access — wipe automation, plugin management, real-time console — and lock in launch pricing.',
|
||
},
|
||
},
|
||
]
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Panel domain routes — panel.corrosionmgmt.com, localhost, etc.
|
||
// Existing behavior, unchanged.
|
||
// ---------------------------------------------------------------------------
|
||
const panelRoutes: RouteRecordRaw[] = [
|
||
// Auth routes (no layout)
|
||
{
|
||
path: '/login',
|
||
name: 'login',
|
||
component: () => import('@/views/auth/LoginView.vue'),
|
||
meta: { guest: true, title: 'Sign in — Corrosion' },
|
||
},
|
||
{
|
||
path: '/register',
|
||
name: 'register',
|
||
component: () => import('@/views/auth/RegisterView.vue'),
|
||
meta: { guest: true, title: 'Create account — Corrosion' },
|
||
},
|
||
{
|
||
path: '/forgot-password',
|
||
name: 'forgot-password',
|
||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||
meta: { guest: true, title: 'Reset password — Corrosion' },
|
||
},
|
||
{
|
||
path: '/setup',
|
||
name: 'setup-wizard',
|
||
component: () => import('@/views/auth/SetupWizardView.vue'),
|
||
meta: { requiresAuth: true, title: 'Setup — Corrosion' },
|
||
},
|
||
|
||
// Admin dashboard routes (with sidebar layout)
|
||
{
|
||
path: '/',
|
||
component: () => import('@/components/layout/DashboardLayout.vue'),
|
||
meta: { requiresAuth: true },
|
||
children: [
|
||
{
|
||
path: '',
|
||
name: 'dashboard',
|
||
component: () => import('@/views/admin/DashboardView.vue'),
|
||
meta: { title: 'Dashboard — Corrosion' },
|
||
},
|
||
{
|
||
path: 'server',
|
||
name: 'server',
|
||
component: () => import('@/views/admin/ServerView.vue'),
|
||
meta: { title: 'Server — Corrosion' },
|
||
},
|
||
{
|
||
path: 'console',
|
||
name: 'console',
|
||
component: () => import('@/views/admin/ConsoleView.vue'),
|
||
meta: { title: 'Console — Corrosion' },
|
||
},
|
||
{
|
||
path: 'players',
|
||
name: 'players',
|
||
component: () => import('@/views/admin/PlayersView.vue'),
|
||
meta: { title: 'Players — Corrosion' },
|
||
},
|
||
{
|
||
path: 'plugins',
|
||
name: 'plugins',
|
||
component: () => import('@/views/admin/PluginsView.vue'),
|
||
meta: { title: 'Plugins — Corrosion' },
|
||
},
|
||
{
|
||
path: 'files',
|
||
name: 'files',
|
||
component: () => import('@/views/admin/FileManagerView.vue'),
|
||
meta: { title: 'Files — Corrosion' },
|
||
},
|
||
{
|
||
path: 'plugin-configs',
|
||
name: 'plugin-configs',
|
||
component: () => import('@/views/admin/PluginConfigsView.vue'),
|
||
meta: { title: 'Plugin Configs — Corrosion' },
|
||
},
|
||
{
|
||
path: 'loot-builder',
|
||
name: 'loot-builder',
|
||
component: () => import('@/views/admin/LootBuilderView.vue'),
|
||
meta: { title: 'Loot Builder — Corrosion' },
|
||
},
|
||
{
|
||
path: 'teleport-config',
|
||
name: 'teleport-config',
|
||
component: () => import('@/views/admin/TeleportConfigView.vue'),
|
||
meta: { title: 'Teleport Config — Corrosion' },
|
||
},
|
||
{
|
||
path: 'gather-manager',
|
||
name: 'gather-manager',
|
||
component: () => import('@/views/admin/GatherManagerView.vue'),
|
||
meta: { title: 'Gather Manager — Corrosion' },
|
||
},
|
||
{
|
||
path: 'autodoors',
|
||
name: 'autodoors',
|
||
component: () => import('@/views/admin/AutoDoorsView.vue'),
|
||
meta: { title: 'Auto Doors — Corrosion' },
|
||
},
|
||
{
|
||
path: 'kits',
|
||
name: 'kits-config',
|
||
component: () => import('@/views/admin/KitsView.vue'),
|
||
meta: { title: 'Kits — Corrosion' },
|
||
},
|
||
{
|
||
path: 'furnace-splitter',
|
||
name: 'furnace-splitter',
|
||
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
|
||
meta: { title: 'Furnace Splitter — Corrosion' },
|
||
},
|
||
{
|
||
path: 'better-chat',
|
||
name: 'better-chat',
|
||
component: () => import('@/views/admin/BetterChatView.vue'),
|
||
meta: { title: 'Better Chat — Corrosion' },
|
||
},
|
||
{
|
||
path: 'timed-execute',
|
||
name: 'timed-execute',
|
||
component: () => import('@/views/admin/TimedExecuteView.vue'),
|
||
meta: { title: 'Timed Execute — Corrosion' },
|
||
},
|
||
{
|
||
path: 'raidable-bases',
|
||
name: 'raidable-bases',
|
||
component: () => import('@/views/admin/RaidableBasesView.vue'),
|
||
meta: { title: 'Raidable Bases — Corrosion' },
|
||
},
|
||
{
|
||
path: 'wipes',
|
||
name: 'wipes',
|
||
component: () => import('@/views/admin/WipesView.vue'),
|
||
meta: { title: 'Wipes — Corrosion' },
|
||
},
|
||
{
|
||
path: 'wipes/profiles',
|
||
name: 'wipe-profiles',
|
||
component: () => import('@/views/admin/WipeProfilesView.vue'),
|
||
meta: { title: 'Wipe Profiles — Corrosion' },
|
||
},
|
||
{
|
||
path: 'wipes/calendar',
|
||
name: 'wipe-calendar',
|
||
component: () => import('@/views/admin/WipeCalendarView.vue'),
|
||
meta: { title: 'Wipe Calendar — Corrosion' },
|
||
},
|
||
{
|
||
path: 'wipes/history',
|
||
name: 'wipe-history',
|
||
component: () => import('@/views/admin/WipeHistoryView.vue'),
|
||
meta: { title: 'Wipe History — Corrosion' },
|
||
},
|
||
{
|
||
path: 'wipes/analytics',
|
||
name: 'wipe-analytics',
|
||
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
|
||
meta: { title: 'Wipe Analytics — Corrosion' },
|
||
},
|
||
{
|
||
path: 'maps',
|
||
name: 'maps',
|
||
component: () => import('@/views/admin/MapsView.vue'),
|
||
meta: { title: 'Maps — Corrosion' },
|
||
},
|
||
{
|
||
path: 'maps/analytics',
|
||
name: 'map-analytics',
|
||
component: () => import('@/views/admin/MapAnalyticsView.vue'),
|
||
meta: { title: 'Map Analytics — Corrosion' },
|
||
},
|
||
{
|
||
path: 'chat',
|
||
name: 'chat',
|
||
component: () => import('@/views/admin/ChatLogView.vue'),
|
||
meta: { title: 'Chat Log — Corrosion' },
|
||
},
|
||
{
|
||
path: 'analytics',
|
||
name: 'analytics',
|
||
component: () => import('@/views/admin/AnalyticsView.vue'),
|
||
meta: { title: 'Analytics — Corrosion' },
|
||
},
|
||
{
|
||
path: 'retention',
|
||
name: 'retention',
|
||
component: () => import('@/views/admin/PlayerRetentionView.vue'),
|
||
meta: { title: 'Player Retention — Corrosion' },
|
||
},
|
||
{
|
||
path: 'notifications',
|
||
name: 'notifications',
|
||
component: () => import('@/views/admin/NotificationsView.vue'),
|
||
meta: { title: 'Notifications — Corrosion' },
|
||
},
|
||
{
|
||
path: 'team',
|
||
name: 'team',
|
||
component: () => import('@/views/admin/TeamView.vue'),
|
||
meta: { title: 'Team — Corrosion' },
|
||
},
|
||
{
|
||
path: 'store/config',
|
||
name: 'store-config',
|
||
component: () => import('@/views/admin/StoreConfigView.vue'),
|
||
meta: { title: 'Store Config — Corrosion' },
|
||
},
|
||
{
|
||
path: 'store/items',
|
||
name: 'store-items',
|
||
component: () => import('@/views/admin/StoreItemsView.vue'),
|
||
meta: { title: 'Store Items — Corrosion' },
|
||
},
|
||
{
|
||
path: 'store/revenue',
|
||
name: 'store-revenue',
|
||
component: () => import('@/views/admin/StoreRevenueView.vue'),
|
||
meta: { title: 'Store Revenue — Corrosion' },
|
||
},
|
||
{
|
||
path: 'modules',
|
||
name: 'modules',
|
||
component: () => import('@/views/admin/ModuleStoreView.vue'),
|
||
meta: { title: 'Modules — Corrosion' },
|
||
},
|
||
{
|
||
path: 'settings',
|
||
name: 'settings',
|
||
component: () => import('@/views/admin/SettingsView.vue'),
|
||
meta: { title: 'Settings — Corrosion' },
|
||
},
|
||
{
|
||
path: 'schedules',
|
||
name: 'schedules',
|
||
component: () => import('@/views/admin/SchedulesView.vue'),
|
||
meta: { title: 'Schedules — Corrosion' },
|
||
},
|
||
{
|
||
path: 'migration',
|
||
name: 'migration',
|
||
component: () => import('@/views/admin/MigrationView.vue'),
|
||
meta: { title: 'Migration — Corrosion' },
|
||
},
|
||
{
|
||
path: 'changelog',
|
||
name: 'changelog',
|
||
component: () => import('@/views/admin/ChangelogView.vue'),
|
||
meta: { title: 'Changelog — Corrosion' },
|
||
},
|
||
{
|
||
path: 'alerts',
|
||
name: 'alerts',
|
||
component: () => import('@/views/admin/AlertsView.vue'),
|
||
meta: { title: 'Alerts — Corrosion' },
|
||
},
|
||
{
|
||
path: 'fleet',
|
||
name: 'fleet',
|
||
component: () => import('@/views/admin/FleetView.vue'),
|
||
meta: { title: 'Fleet — Corrosion', requiresAuth: true },
|
||
},
|
||
// Platform Admin views (super-admin only)
|
||
{
|
||
path: 'admin',
|
||
name: 'platform-admin',
|
||
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
|
||
meta: { superAdmin: true, title: 'Admin — Corrosion' },
|
||
},
|
||
{
|
||
path: 'admin/licenses',
|
||
name: 'platform-licenses',
|
||
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
|
||
meta: { superAdmin: true, title: 'Admin: Licenses — Corrosion' },
|
||
},
|
||
{
|
||
path: 'admin/subscriptions',
|
||
name: 'platform-subscriptions',
|
||
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
|
||
meta: { superAdmin: true, title: 'Admin: Subscriptions — Corrosion' },
|
||
},
|
||
{
|
||
path: 'admin/users',
|
||
name: 'platform-users',
|
||
component: () => import('@/views/platform-admin/AdminUsers.vue'),
|
||
meta: { superAdmin: true, title: 'Admin: Users — Corrosion' },
|
||
},
|
||
{
|
||
path: 'admin/servers',
|
||
name: 'platform-servers',
|
||
component: () => import('@/views/platform-admin/AdminServers.vue'),
|
||
meta: { superAdmin: true, title: 'Admin: Servers — Corrosion' },
|
||
},
|
||
],
|
||
},
|
||
|
||
// Public server site routes (different layout)
|
||
{
|
||
path: '/s/:subdomain',
|
||
component: () => import('@/components/layout/PublicLayout.vue'),
|
||
children: [
|
||
{
|
||
path: '',
|
||
name: 'public-server',
|
||
component: () => import('@/views/public/ServerInfoView.vue'),
|
||
},
|
||
{
|
||
path: 'store',
|
||
name: 'public-store',
|
||
component: () => import('@/views/public/StoreView.vue'),
|
||
},
|
||
],
|
||
},
|
||
|
||
// Marketing site (accessible on panel domain at /site/*)
|
||
{
|
||
path: '/site',
|
||
component: () => import('@/components/layout/MarketingLayout.vue'),
|
||
children: marketingChildren,
|
||
},
|
||
|
||
// Status page
|
||
{
|
||
path: '/status',
|
||
name: 'status',
|
||
component: () => import('@/views/public/StatusPageView.vue'),
|
||
meta: { title: 'Status — Corrosion' },
|
||
},
|
||
|
||
// Catch-all
|
||
{
|
||
path: '/:pathMatch(.*)*',
|
||
redirect: '/',
|
||
},
|
||
]
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Marketing domain routes — corrosionmgmt.com (bare domain)
|
||
// Marketing pages at root. No admin/auth routes.
|
||
// ---------------------------------------------------------------------------
|
||
const marketingRoutes: RouteRecordRaw[] = [
|
||
// Marketing layout at /
|
||
{
|
||
path: '/',
|
||
component: () => import('@/components/layout/MarketingLayout.vue'),
|
||
children: marketingChildren,
|
||
},
|
||
|
||
// Backward compat: /site/* → root-level equivalents
|
||
{
|
||
path: '/site/:pathMatch(.*)*',
|
||
redirect: (to) => {
|
||
const sub = to.params.pathMatch
|
||
if (!sub || (Array.isArray(sub) && sub.length === 0)) return '/'
|
||
const subPath = Array.isArray(sub) ? sub.join('/') : sub
|
||
return subPath ? `/${subPath}` : '/'
|
||
},
|
||
},
|
||
|
||
// Status page
|
||
{
|
||
path: '/status',
|
||
name: 'status',
|
||
component: () => import('@/views/public/StatusPageView.vue'),
|
||
meta: { title: 'Status — Corrosion' },
|
||
},
|
||
|
||
// Catch-all: unknown routes → landing page
|
||
{
|
||
path: '/:pathMatch(.*)*',
|
||
redirect: '/',
|
||
},
|
||
]
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Router instance
|
||
// ---------------------------------------------------------------------------
|
||
const router = createRouter({
|
||
history: createWebHistory(),
|
||
routes: isMarketingDomain ? marketingRoutes : panelRoutes,
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Document title + meta description/OG update on every navigation
|
||
// ---------------------------------------------------------------------------
|
||
function setOrClearMeta(selector: string, attr: string, value: string): void {
|
||
let el = document.querySelector<HTMLMetaElement>(selector)
|
||
if (!el) {
|
||
el = document.createElement('meta')
|
||
// Parse the selector to set the right attribute (name="..." or property="...")
|
||
const nameMatch = selector.match(/\[name="([^"]+)"\]/)
|
||
const propMatch = selector.match(/\[property="([^"]+)"\]/)
|
||
if (nameMatch?.[1]) el.setAttribute('name', nameMatch[1])
|
||
if (propMatch?.[1]) el.setAttribute('property', propMatch[1])
|
||
document.head.appendChild(el)
|
||
}
|
||
el.setAttribute(attr, value)
|
||
}
|
||
|
||
router.afterEach((to) => {
|
||
// Title
|
||
document.title = to.meta.title ?? 'Corrosion Management'
|
||
|
||
// Description
|
||
const desc = to.meta.description ?? ''
|
||
setOrClearMeta('meta[name="description"]', 'content', desc)
|
||
|
||
// OG title
|
||
setOrClearMeta('meta[property="og:title"]', 'content', to.meta.title ?? 'Corrosion Management')
|
||
|
||
// OG description
|
||
setOrClearMeta('meta[property="og:description"]', 'content', desc)
|
||
})
|
||
|
||
// Auth guard — only meaningful on panel domain (marketing has no requiresAuth routes)
|
||
router.beforeEach((to, _from, next) => {
|
||
const auth = useAuthStore()
|
||
|
||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||
next({ name: 'login', query: { redirect: to.fullPath } })
|
||
} else if (to.meta.superAdmin && !auth.isSuperAdmin) {
|
||
next({ name: 'dashboard' })
|
||
} else if (to.meta.guest && auth.isAuthenticated) {
|
||
next({ name: 'dashboard' })
|
||
} else {
|
||
next()
|
||
}
|
||
})
|
||
|
||
export default router
|