From 7a07d600e7e9017cffbfcb8e5182336b1f0c7023 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 11 Jun 2026 12:32:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(fleet):=20Phase=20B=20=E2=80=94=20fleet=20?= =?UTF-8?q?overview=20UI=20+=20GET=20/api/fleet=20read=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend-nest/src/app.module.ts | 2 + .../src/modules/fleet/fleet.controller.ts | 19 + .../src/modules/fleet/fleet.module.ts | 14 + .../src/modules/fleet/fleet.service.ts | 134 +++++ frontend/src/config/gameProfiles.ts | 8 +- frontend/src/router/index.ts | 6 + frontend/src/stores/fleet.ts | 87 ++++ frontend/src/views/admin/FleetView.vue | 467 ++++++++++++++++++ 8 files changed, 734 insertions(+), 3 deletions(-) create mode 100644 backend-nest/src/modules/fleet/fleet.controller.ts create mode 100644 backend-nest/src/modules/fleet/fleet.module.ts create mode 100644 backend-nest/src/modules/fleet/fleet.service.ts create mode 100644 frontend/src/stores/fleet.ts create mode 100644 frontend/src/views/admin/FleetView.vue diff --git a/backend-nest/src/app.module.ts b/backend-nest/src/app.module.ts index 8e10d89..8b1df41 100644 --- a/backend-nest/src/app.module.ts +++ b/backend-nest/src/app.module.ts @@ -45,6 +45,7 @@ import { BetterChatModule } from './modules/betterchat/betterchat.module'; import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module'; import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module'; import { EarlyAccessModule } from './modules/early-access/early-access.module'; +import { FleetModule } from './modules/fleet/fleet.module'; // Shared Services import { NatsService } from './services/nats.service'; @@ -133,6 +134,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway'; TimedExecuteModule, RaidableBasesModule, EarlyAccessModule, + FleetModule, ], providers: [ // Global guards (order matters: auth first, then license, then permissions) diff --git a/backend-nest/src/modules/fleet/fleet.controller.ts b/backend-nest/src/modules/fleet/fleet.controller.ts new file mode 100644 index 0000000..784491d --- /dev/null +++ b/backend-nest/src/modules/fleet/fleet.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { FleetService } from './fleet.service'; +import { CurrentTenant } from '../../common/decorators/current-tenant.decorator'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; + +@ApiTags('fleet') +@ApiBearerAuth() +@Controller('fleet') +export class FleetController { + constructor(private readonly fleetService: FleetService) {} + + @Get() + @RequirePermission('server.view') + @ApiOperation({ summary: 'Get fleet overview — hosts and game instances for this license' }) + async getFleet(@CurrentTenant() licenseId: string) { + return this.fleetService.getFleet(licenseId); + } +} diff --git a/backend-nest/src/modules/fleet/fleet.module.ts b/backend-nest/src/modules/fleet/fleet.module.ts new file mode 100644 index 0000000..24e690b --- /dev/null +++ b/backend-nest/src/modules/fleet/fleet.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FleetController } from './fleet.controller'; +import { FleetService } from './fleet.service'; +import { AgentHost } from '../../entities/agent-host.entity'; +import { GameInstance } from '../../entities/game-instance.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([AgentHost, GameInstance])], + controllers: [FleetController], + providers: [FleetService], + exports: [FleetService], +}) +export class FleetModule {} diff --git a/backend-nest/src/modules/fleet/fleet.service.ts b/backend-nest/src/modules/fleet/fleet.service.ts new file mode 100644 index 0000000..913b9a2 --- /dev/null +++ b/backend-nest/src/modules/fleet/fleet.service.ts @@ -0,0 +1,134 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AgentHost } from '../../entities/agent-host.entity'; +import { GameInstance } from '../../entities/game-instance.entity'; + +export interface FleetInstanceDto { + id: string; + agent_instance_id: string; + game: string; + label: string | null; + state: string; + uptime_seconds: number; + last_seen_at: string | null; +} + +export interface FleetHostDto { + id: string; + hostname: string; + status: string; + agent_version: string | null; + os: string | null; + arch: string | null; + cpu_percent: number | null; + cpu_cores: number | null; + mem_total_mb: number | null; + mem_used_mb: number | null; + uptime_seconds: number | null; + disks: AgentHost['disks']; + last_heartbeat_at: string | null; + instances: FleetInstanceDto[]; +} + +export interface FleetSummaryDto { + host_count: number; + instance_count: number; + online_host_count: number; +} + +export interface FleetResponseDto { + hosts: FleetHostDto[]; + summary: FleetSummaryDto; +} + +@Injectable() +export class FleetService { + constructor( + @InjectRepository(AgentHost) + private readonly hostRepo: Repository, + @InjectRepository(GameInstance) + private readonly instanceRepo: Repository, + ) {} + + async getFleet(licenseId: string): Promise { + const [hosts, instances] = await Promise.all([ + this.hostRepo.find({ + where: { license_id: licenseId }, + order: { hostname: 'ASC' }, + }), + this.instanceRepo.find({ + where: { license_id: licenseId }, + order: { game: 'ASC', label: 'ASC' }, + }), + ]); + + // Group instances by host_id. Bigint columns come back as strings from pg — coerce. + const instancesByHost = new Map(); + for (const inst of instances) { + const key = inst.host_id ?? null; + if (!instancesByHost.has(key)) { + instancesByHost.set(key, []); + } + instancesByHost.get(key)!.push({ + id: inst.id, + agent_instance_id: inst.agent_instance_id, + game: inst.game, + label: inst.label, + state: inst.state, + uptime_seconds: Number(inst.uptime_seconds), + last_seen_at: inst.last_seen_at ? inst.last_seen_at.toISOString() : null, + }); + } + + const hostDtos: FleetHostDto[] = hosts.map((h) => ({ + id: h.id, + hostname: h.hostname, + status: h.status, + agent_version: h.agent_version, + os: h.os, + arch: h.arch, + cpu_percent: h.cpu_percent !== null && h.cpu_percent !== undefined ? Number(h.cpu_percent) : null, + cpu_cores: h.cpu_cores !== null && h.cpu_cores !== undefined ? Number(h.cpu_cores) : null, + mem_total_mb: h.mem_total_mb !== null && h.mem_total_mb !== undefined ? Number(h.mem_total_mb) : null, + mem_used_mb: h.mem_used_mb !== null && h.mem_used_mb !== undefined ? Number(h.mem_used_mb) : null, + uptime_seconds: h.uptime_seconds !== null && h.uptime_seconds !== undefined ? Number(h.uptime_seconds) : null, + disks: h.disks, + last_heartbeat_at: h.last_heartbeat_at ? h.last_heartbeat_at.toISOString() : null, + instances: instancesByHost.get(h.id) ?? [], + })); + + // Append synthetic "unassigned" bucket only if orphaned instances exist + const unassigned = instancesByHost.get(null) ?? []; + if (unassigned.length > 0) { + hostDtos.push({ + id: '__unassigned__', + hostname: 'Unassigned', + status: 'offline', + agent_version: null, + os: null, + arch: null, + cpu_percent: null, + cpu_cores: null, + mem_total_mb: null, + mem_used_mb: null, + uptime_seconds: null, + disks: null, + last_heartbeat_at: null, + instances: unassigned, + }); + } + + const online_host_count = hosts.filter((h) => h.status === 'connected').length; + const instance_count = instances.length; + + return { + hosts: hostDtos, + summary: { + host_count: hosts.length, + instance_count, + online_host_count, + }, + }; + } +} diff --git a/frontend/src/config/gameProfiles.ts b/frontend/src/config/gameProfiles.ts index 107e95c..80b9fc6 100644 --- a/frontend/src/config/gameProfiles.ts +++ b/frontend/src/config/gameProfiles.ts @@ -124,6 +124,7 @@ export interface GameProfile { // --------------------------------------------------------------------------- const NAV_DASHBOARD: NavItemDef = { label: 'Dashboard', route: '/', icon: 'layout-dashboard', permission: null } +const NAV_FLEET: NavItemDef = { label: 'Fleet', route: '/fleet', icon: 'server-cog', permission: 'server.view' } const NAV_SERVER: NavItemDef = { label: 'Server', route: '/server', icon: 'server', permission: 'server.view' } const NAV_CONSOLE: NavItemDef = { label: 'Console', route: '/console', icon: 'terminal', permission: 'console.view' } const NAV_PLAYERS: NavItemDef = { label: 'Players', route: '/players', icon: 'users', permission: 'players.view' } @@ -147,7 +148,7 @@ const RUST_NAV: NavSection[] = [ { label: '', items: [NAV_DASHBOARD] }, { label: 'Server', - items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_PLUGINS, NAV_FILES], + items: [NAV_FLEET, NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_PLUGINS, NAV_FILES], }, { label: 'Plugin configs', items: [NAV_PLUGIN_CONFIGS] }, { @@ -211,7 +212,7 @@ export const GAME_PROFILES: Record = { { label: 'Server', // Conan: no uMod/Oxide; has RCON console, maps, players, files - items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES], + items: [NAV_FLEET, NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES], }, { label: 'Operations', @@ -256,7 +257,7 @@ export const GAME_PROFILES: Record = { { label: 'Server', // Soulmask: no uMod/Oxide; has RCON+GM console, players, files - items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES], + items: [NAV_FLEET, NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES], }, { label: 'Operations', @@ -299,6 +300,7 @@ export const GAME_PROFILES: Record = { label: 'Server', // Dune: no RCON (uses RabbitMQ); label console "Broadcast"; no maps route; no plugins items: [ + NAV_FLEET, NAV_SERVER, { label: 'Broadcast', route: '/console', icon: 'radio', permission: 'console.view' }, NAV_PLAYERS, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bcc9535..a15ac33 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -343,6 +343,12 @@ const panelRoutes: RouteRecordRaw[] = [ 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', diff --git a/frontend/src/stores/fleet.ts b/frontend/src/stores/fleet.ts new file mode 100644 index 0000000..68b29e4 --- /dev/null +++ b/frontend/src/stores/fleet.ts @@ -0,0 +1,87 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useApi } from '@/composables/useApi' + +// --------------------------------------------------------------------------- +// Types — mirrors the FleetResponseDto from the backend +// --------------------------------------------------------------------------- + +export interface FleetDisk { + mount: string + total_mb: number + free_mb: number +} + +export interface FleetInstance { + id: string + agent_instance_id: string + game: string + label: string | null + state: string + uptime_seconds: number + last_seen_at: string | null +} + +export interface FleetHost { + id: string + hostname: string + status: string + agent_version: string | null + os: string | null + arch: string | null + cpu_percent: number | null + cpu_cores: number | null + mem_total_mb: number | null + mem_used_mb: number | null + uptime_seconds: number | null + disks: FleetDisk[] | null + last_heartbeat_at: string | null + instances: FleetInstance[] +} + +export interface FleetSummary { + host_count: number + instance_count: number + online_host_count: number +} + +export interface FleetData { + hosts: FleetHost[] + summary: FleetSummary +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +export const useFleetStore = defineStore('fleet', () => { + const hosts = ref([]) + const summary = ref({ host_count: 0, instance_count: 0, online_host_count: 0 }) + const loading = ref(false) + const error = ref(null) + + const api = useApi() + + async function fetchFleet() { + loading.value = true + error.value = null + try { + const data = await api.get('/fleet') + hosts.value = data.hosts + summary.value = data.summary + } catch (e) { + console.error('Failed to fetch fleet:', e) + error.value = e instanceof Error ? e.message : 'Failed to load fleet data' + } finally { + loading.value = false + } + } + + return { + hosts, + summary, + loading, + error, + fetchFleet, + } +}) diff --git a/frontend/src/views/admin/FleetView.vue b/frontend/src/views/admin/FleetView.vue new file mode 100644 index 0000000..05f0383 --- /dev/null +++ b/frontend/src/views/admin/FleetView.vue @@ -0,0 +1,467 @@ + + + + +