fix(audit): kill fake install cmds + dead demo CTA; production fonts; scoped error boundary; admin bootstrap seed
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Full-site fake-data audit findings: - SetupWizard showed a curl|sh installer (get.corrosionmgmt.com) and a 'corrosion-agent' binary that don't exist -> real host-agent commands - 'View live demo' CTA on 5 marketing pages linked to a login, not a demo -> honest 'Sign in' - Google Fonts @import was silently dropped from the production CSS bundle (mid-bundle @import) -> <link> tags in index.html; prod was shipping system fallback fonts - App-root ErrorBoundary bricked the entire SPA (incl. marketing) on a single failed fetch until manual reload -> resets on route change + content-scoped boundary inside DashboardLayout so nav chrome survives - Status page KPIs showed fake zeros while the fetch failed -> em dash - Login lacked the forgot-password link (flow already existed end-to-end) - AdminSeedService: fresh DB had schema but no login possible; seeds super-admin + license from ADMIN_* env when users table is empty Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed (Site Audit — Fake Data, Resilience, Fonts — 2026-06-11)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `SetupWizardView.vue` — Replaced fake install instructions (`get.corrosionmgmt.com | sh` install script and `corrosion-agent` binary, neither of which exists) with the real host-agent download + run commands matching ServerView; multi-game copy on the completion step
|
||||||
|
- Marketing views (Landing, Pricing, HowItWorks, Roadmap, EarlyAccess) — Replaced "View live demo" CTA (no demo exists; it linked to the panel login) with an honest "Sign in" link
|
||||||
|
- `ErrorBoundary.vue` — Error state now resets on route change (previously one failed view bricked the entire SPA, including marketing pages, until manual reload); added `content` variant
|
||||||
|
- `DashboardLayout.vue` — Routed views are now wrapped in a content-scoped ErrorBoundary so the sidebar/topbar survive a view failure instead of the whole panel unmounting
|
||||||
|
- `index.html` / `styles/tokens/fonts.css` — Google Fonts moved from CSS `@import` to `<link>` tags. The bundler silently dropped the mid-bundle `@import`, so production shipped system fallback fonts (Geist/JetBrains Mono/Oxanium never loaded)
|
||||||
|
- `StatusPageView.vue` — Platform KPIs show "—" until the first successful fetch instead of fake zeros
|
||||||
|
- `LoginView.vue` — Added missing "Forgot password?" link (route + backend endpoint already existed)
|
||||||
|
|
||||||
|
**Backend (NestJS):**
|
||||||
|
- `AdminSeedService` (new, auth module) — Bootstraps a super-admin user + active license from `ADMIN_EMAIL`/`ADMIN_PASSWORD`/`ADMIN_USERNAME`/`ADMIN_LICENSE_KEY` when the users table is empty. A fresh deploy previously had a schema but no possible login. Compose already passes the env vars
|
||||||
|
|
||||||
|
**Purpose:** Findings from the full-site fake-data audit. Show real data or honest empty states — never invented values, dead URLs, or fabricated zeros.
|
||||||
|
|
||||||
### Fixed (Safe Formatting Utilities — 2026-02-15)
|
### Fixed (Safe Formatting Utilities — 2026-02-15)
|
||||||
|
|
||||||
**Frontend:**
|
**Frontend:**
|
||||||
|
|||||||
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { User } from '../../entities/user.entity';
|
||||||
|
import { License } from '../../entities/license.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstraps the first admin account on a fresh database.
|
||||||
|
*
|
||||||
|
* A fresh deploy builds the schema via docker-entrypoint-initdb.d but contains
|
||||||
|
* zero users, so the panel has no possible login. If ADMIN_EMAIL and
|
||||||
|
* ADMIN_PASSWORD are set and the users table is empty, this creates a
|
||||||
|
* super-admin user plus an active license — the same rows the register flow
|
||||||
|
* would create. It never runs against a database that already has users.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AdminSeedService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(AdminSeedService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
@InjectRepository(User) private readonly userRepository: Repository<User>,
|
||||||
|
@InjectRepository(License) private readonly licenseRepository: Repository<License>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.seedAdminIfEmpty();
|
||||||
|
} catch (err) {
|
||||||
|
// A failed seed must not take the API down — surface it loudly and move on
|
||||||
|
this.logger.error(`Admin bootstrap failed: ${(err as Error).message}`, (err as Error).stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedAdminIfEmpty(): Promise<void> {
|
||||||
|
const email = this.config.get<string>('admin.email');
|
||||||
|
const password = this.config.get<string>('admin.password');
|
||||||
|
const username = this.config.get<string>('admin.username') || 'Commander';
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
this.logger.log('Admin bootstrap skipped: ADMIN_EMAIL / ADMIN_PASSWORD not set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCount = await this.userRepository.count();
|
||||||
|
if (userCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await argon2.hash(password);
|
||||||
|
const user = this.userRepository.create({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
email_verified: true,
|
||||||
|
is_super_admin: true,
|
||||||
|
});
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
const licenseKey = this.config.get<string>('admin.licenseKey') || this.generateLicenseKey();
|
||||||
|
const license = this.licenseRepository.create({
|
||||||
|
license_key: licenseKey,
|
||||||
|
owner_user_id: user.id,
|
||||||
|
status: 'active',
|
||||||
|
modules_enabled: [],
|
||||||
|
webstore_active: false,
|
||||||
|
});
|
||||||
|
await this.licenseRepository.save(license);
|
||||||
|
|
||||||
|
this.logger.log(`Bootstrap admin created: ${user.email} (license ${license.license_key})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateLicenseKey(): string {
|
||||||
|
const part1 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
const part2 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
const part3 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
return `CORR-${part1}-${part2}-${part3}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { AdminSeedService } from './admin-seed.service';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { User } from '../../entities/user.entity';
|
import { User } from '../../entities/user.entity';
|
||||||
import { License } from '../../entities/license.entity';
|
import { License } from '../../entities/license.entity';
|
||||||
@@ -27,7 +28,7 @@ import { TeamMember } from '../../entities/team-member.entity';
|
|||||||
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
|
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, AdminSeedService, JwtStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -9,6 +9,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0a0a0a" />
|
<meta name="theme-color" content="#0a0a0a" />
|
||||||
<title>Corrosion Management</title>
|
<title>Corrosion Management</title>
|
||||||
|
<!-- Fonts via <link>, NOT a CSS @import — the bundler drops @import rules
|
||||||
|
that land mid-file after concatenation, silently shipping system fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap"
|
||||||
|
/>
|
||||||
<script>
|
<script>
|
||||||
/* FOUC guard — apply persisted theme/game to <html> before the app mounts,
|
/* FOUC guard — apply persisted theme/game to <html> before the app mounts,
|
||||||
so the design-system tokens paint with the right skin from frame one. */
|
so the design-system tokens paint with the right skin from frame one. */
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onErrorCaptured } from 'vue'
|
import { ref, watch, onErrorCaptured } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import Icon from '@/components/ds/core/Icon.vue'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
import Button from '@/components/ds/core/Button.vue'
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
/** 'screen' fills the viewport (app root); 'content' fills its container (inside layout chrome) */
|
||||||
|
variant?: 'screen' | 'content'
|
||||||
|
}>(), { variant: 'screen' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const hasError = ref(false)
|
const hasError = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
|
||||||
@@ -13,6 +20,12 @@ onErrorCaptured((err) => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A failed view must not brick navigation — clear the error when the route changes
|
||||||
|
watch(() => route.fullPath, () => {
|
||||||
|
hasError.value = false
|
||||||
|
errorMessage.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
hasError.value = false
|
hasError.value = false
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
@@ -21,7 +34,7 @@ function retry() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="hasError" class="eb-screen">
|
<div v-if="hasError" class="eb-screen" :class="{ 'eb-screen--content': variant === 'content' }">
|
||||||
<div class="eb-card">
|
<div class="eb-card">
|
||||||
<div class="eb-icon-wrap">
|
<div class="eb-icon-wrap">
|
||||||
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
|
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
|
||||||
@@ -44,6 +57,11 @@ function retry() {
|
|||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.eb-screen--content {
|
||||||
|
min-height: 60vh;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.eb-card {
|
.eb-card {
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
box-shadow: var(--ring-default), var(--shadow-md);
|
box-shadow: var(--ring-default), var(--shadow-md);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useThemeGame } from '@/composables/useThemeGame'
|
|||||||
import { useGameProfile } from '@/config/gameProfiles'
|
import { useGameProfile } from '@/config/gameProfiles'
|
||||||
import type { NavSection, NavItemDef } from '@/config/gameProfiles'
|
import type { NavSection, NavItemDef } from '@/config/gameProfiles'
|
||||||
import { safeDate } from '@/utils/formatters'
|
import { safeDate } from '@/utils/formatters'
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary.vue'
|
||||||
import Logo from '@/components/ds/brand/Logo.vue'
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
import Badge from '@/components/ds/core/Badge.vue'
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||||
@@ -284,9 +285,11 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Page content -->
|
<!-- Page content — boundary keeps sidebar/topbar alive when a view fails -->
|
||||||
<main class="app__content">
|
<main class="app__content">
|
||||||
<RouterView />
|
<ErrorBoundary variant="content">
|
||||||
|
<RouterView />
|
||||||
|
</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
JetBrains Mono — console, data, IDs, telemetry
|
JetBrains Mono — console, data, IDs, telemetry
|
||||||
Oxanium — brand wordmark + marketing display (game-ops flavor)
|
Oxanium — brand wordmark + marketing display (game-ops flavor)
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
NOTE: Loaded from Google Fonts CDN. If you want these self-
|
NOTE: The Google Fonts stylesheet is loaded via <link> tags in
|
||||||
hosted (offline), send the woff2 files and these @imports
|
index.html — NOT @import here. A CSS @import that ends up
|
||||||
become @font-face rules.
|
mid-bundle after concatenation is silently dropped by the
|
||||||
|
optimizer (fonts never load in production). If you want these
|
||||||
|
self-hosted (offline), send the woff2 files and they become
|
||||||
|
@font-face rules here.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap');
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
--font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
|
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ function handleBackToLogin() {
|
|||||||
<p v-if="!showTotpInput" class="auth-footer">
|
<p v-if="!showTotpInput" class="auth-footer">
|
||||||
No account?
|
No account?
|
||||||
<router-link to="/register" class="auth-footer__link">Create one</router-link>
|
<router-link to="/register" class="auth-footer__link">Create one</router-link>
|
||||||
|
·
|
||||||
|
<router-link to="/forgot-password" class="auth-footer__link">Forgot password?</router-link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -196,14 +196,17 @@ async function completeSetup() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setup-code">
|
<div class="setup-code">
|
||||||
<p class="setup-code__comment"># Download and install the Corrosion host agent</p>
|
<p class="setup-code__comment"># Download the Corrosion host agent (Linux)</p>
|
||||||
<p class="setup-code__cmd">curl -sSL https://get.corrosionmgmt.com | sh</p>
|
<p class="setup-code__cmd">curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
||||||
<p class="setup-code__comment setup-code__comment--mt"># Start the agent with your license key</p>
|
<p class="setup-code__cmd">chmod +x corrosion-host-agent-linux-amd64</p>
|
||||||
<p class="setup-code__cmd">corrosion-agent start --key {{ auth.license?.license_key ?? 'YOUR-LICENSE-KEY' }}</p>
|
<p class="setup-code__comment setup-code__comment--mt"># Start with your license key</p>
|
||||||
|
<p class="setup-code__cmd">export LICENSE_ID="{{ auth.license?.license_key ?? 'YOUR-LICENSE-KEY' }}"</p>
|
||||||
|
<p class="setup-code__cmd">export NATS_URL="nats://nats.corrosionmgmt.com:4222"</p>
|
||||||
|
<p class="setup-code__cmd">./corrosion-host-agent-linux-amd64</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="setup-hint">
|
<p class="setup-hint">
|
||||||
The agent auto-registers with your panel. You can also use the uMod plugin for lightweight integration.
|
On Windows, download the agent from the Server page after setup. The agent connects outbound and auto-registers with your panel.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="setup-actions">
|
<div class="setup-actions">
|
||||||
@@ -235,7 +238,7 @@ async function completeSetup() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="setup-card__title">You're all set</h1>
|
<h1 class="setup-card__title">You're all set</h1>
|
||||||
<p class="setup-card__sub">Your server is configured. Head to the dashboard to start managing your Rust server.</p>
|
<p class="setup-card__sub">Your server is configured. Head to the dashboard to start managing your game server.</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
Sign up above
|
Sign up above
|
||||||
</a>
|
</a>
|
||||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
<Icon name="play" :size="17" />View live demo
|
<Icon name="key" :size="17" />Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
Join early access
|
Join early access
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
<Icon name="play" :size="17" />View live demo
|
<Icon name="key" :size="17" />Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const mockActiveGame = activeGame
|
|||||||
Join early access
|
Join early access
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
<Icon name="play" :size="17" />View live demo
|
<Icon name="key" :size="17" />Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- Game pills -->
|
<!-- Game pills -->
|
||||||
@@ -672,7 +672,7 @@ const mockActiveGame = activeGame
|
|||||||
Join early access
|
Join early access
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
<Icon name="play" :size="17" />View live demo
|
<Icon name="key" :size="17" />Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ const plans: Plan[] = [
|
|||||||
Join early access
|
Join early access
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
<Icon name="play" :size="17" />View live demo
|
<Icon name="key" :size="17" />Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
Join early access
|
Join early access
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||||
<Icon name="play" :size="17" />View live demo
|
<Icon name="key" :size="17" />Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,12 +41,8 @@ interface StatusResponse {
|
|||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const servers = ref<ServerStatus[]>([])
|
const servers = ref<ServerStatus[]>([])
|
||||||
const platformHealth = ref<PlatformHealth>({
|
// null until the first successful fetch — KPIs render '—', never fake zeros
|
||||||
total_servers: 0,
|
const platformHealth = ref<PlatformHealth | null>(null)
|
||||||
online_servers: 0,
|
|
||||||
total_players: 0,
|
|
||||||
uptime_percent: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -148,10 +144,10 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Platform KPIs -->
|
<!-- Platform KPIs -->
|
||||||
<div v-if="!loading" class="sp-kpis">
|
<div v-if="!loading" class="sp-kpis">
|
||||||
<StatCard icon="server" label="Total servers" :value="String(platformHealth.total_servers)" />
|
<StatCard icon="server" label="Total servers" :value="platformHealth ? String(platformHealth.total_servers) : '—'" />
|
||||||
<StatCard icon="activity" label="Online now" :value="String(platformHealth.online_servers)" />
|
<StatCard icon="activity" label="Online now" :value="platformHealth ? String(platformHealth.online_servers) : '—'" />
|
||||||
<StatCard icon="users" label="Total players" :value="String(platformHealth.total_players)" />
|
<StatCard icon="users" label="Total players" :value="platformHealth ? String(platformHealth.total_players) : '—'" />
|
||||||
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth.uptime_percent, 1)" unit="%" />
|
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth?.uptime_percent ?? null, 1, '—')" unit="%" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
|
|||||||
Reference in New Issue
Block a user