From 4c9c322c2963f26d11379d121f91b13617fa3609 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 11 Jun 2026 10:35:58 -0400 Subject: [PATCH] feat(seo): per-route titles + meta descriptions; ci: honest runner test Every page previously titled 'Corrosion Management' with zero meta - marketing invisible to search and link previews. Router afterEach now sets title/description/og per route (no new deps); marketing pages get real content-backed descriptions, panel views mechanical titles. index.html carries defaults for pre-JS crawlers. Verified in-browser per page via Playwright. test-runner.yml: per-tool presence checks instead of green-lighting missing toolchains; workflow_dispatch instead of every push. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/test-runner.yml | 18 +++-- CHANGELOG.md | 13 ++++ frontend/index.html | 3 + frontend/src/router/index.ts | 124 ++++++++++++++++++++++++++++--- 4 files changed, 144 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/test-runner.yml b/.gitea/workflows/test-runner.yml index a543263..9c69a09 100644 --- a/.gitea/workflows/test-runner.yml +++ b/.gitea/workflows/test-runner.yml @@ -1,5 +1,6 @@ name: Test Asgard Runner -on: [push] +# On-demand only — no reason to spin a container on every push. +on: [workflow_dispatch] jobs: test: @@ -17,8 +18,15 @@ jobs: echo "Memory: $(free -h | grep Mem | awk '{print $2}')" echo "Disk: $(df -h / | tail -1 | awk '{print $4}')" echo "===========================================" - echo "Go: $(go version)" - echo "Rust: $(rustc --version)" - echo "Docker: $(docker --version)" + # Jobs run in a bare node:20-bullseye container: toolchains are NOT + # preinstalled — workflows must bootstrap them (setup-go, rustup). + # Report presence honestly instead of green-lighting a missing tool. + for tool in go rustc docker node; do + if command -v "$tool" >/dev/null 2>&1; then + echo "$tool: $($tool --version 2>&1 | head -1)" + else + echo "$tool: NOT PRESENT (workflows must install per-run)" + fi + done echo "===========================================" - echo "✅ Asgard runner is OPERATIONAL" + echo "✅ Asgard runner reachable — container is node:20-bullseye, bootstrap toolchains per-run" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bdccf9..7b777b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added (Host-Agent v2 Consumer + SEO Meta — 2026-06-11) + +**Backend (NestJS):** +- `HostAgentConsumerService` (new) — consumes wire protocol v2: `corrosion.*.host.heartbeat` updates `companion_last_seen` + `connection_status='connected'` (auto-registers the connection row on first contact); `host.going_offline` flips offline; a 60s staleness sweep marks hosts offline after 180s of silence. Previously NOTHING persisted heartbeats — `connection_status` was set once at setup and never changed again. Tenant-validated (UUID + license existence, cached) per NATS-consumer doctrine +- `NatsBridgeService` — bridges `host_heartbeat` / `host_going_offline` events to the panel WebSocket +- Verified by contract test: real agent → production NATS → captured with the backend's own `nats` lib under the real license; subjects, schema 2, real telemetry, offline beacon all confirmed + +**Frontend:** +- Per-route document titles + meta descriptions (router `afterEach`, no new deps): six marketing pages get real titles/descriptions/OG tags (previously every page was "Corrosion Management" with zero meta — invisible to search and link previews); panel views get mechanical "{View} — Corrosion" titles + +**CI:** +- `test-runner.yml` — honest per-tool presence checks (was printing "OPERATIONAL" while every toolchain probe failed); on-demand trigger instead of every push + ### Added (Corrosion Host Agent — Rust rewrite Phase 0 — 2026-06-11) **New: `corrosion-host-agent/`** — Rust rewrite of the Go companion agent (which stays in-tree as the behavior reference until parity). Wire protocol v2 (COA-B, Commander-approved): instance-scoped subjects `corrosion.{license}.{instance}.*` with host-level `corrosion.{license}.host.*` — full spec in `corrosion-host-agent/PROTOCOL.md`. diff --git a/frontend/index.html b/frontend/index.html index 919a143..3eb779f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,6 +9,9 @@ Corrosion Management + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8366d56..a1ac312 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,6 +1,17 @@ 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 // --------------------------------------------------------------------------- @@ -15,31 +26,55 @@ 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.', + }, }, ] @@ -53,25 +88,25 @@ const panelRoutes: RouteRecordRaw[] = [ path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue'), - meta: { guest: true }, + meta: { guest: true, title: 'Sign in — Corrosion' }, }, { path: '/register', name: 'register', component: () => import('@/views/auth/RegisterView.vue'), - meta: { guest: true }, + meta: { guest: true, title: 'Create account — Corrosion' }, }, { path: '/forgot-password', name: 'forgot-password', component: () => import('@/views/auth/ForgotPasswordView.vue'), - meta: { guest: true }, + meta: { guest: true, title: 'Reset password — Corrosion' }, }, { path: '/setup', name: 'setup-wizard', component: () => import('@/views/auth/SetupWizardView.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, title: 'Setup — Corrosion' }, }, // Admin dashboard routes (with sidebar layout) @@ -84,217 +119,254 @@ const panelRoutes: RouteRecordRaw[] = [ 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' }, }, // Platform Admin views (super-admin only) { path: 'admin', name: 'platform-admin', component: () => import('@/views/platform-admin/AdminDashboard.vue'), - meta: { superAdmin: true }, + meta: { superAdmin: true, title: 'Admin — Corrosion' }, }, { path: 'admin/licenses', name: 'platform-licenses', component: () => import('@/views/platform-admin/AdminLicenses.vue'), - meta: { superAdmin: true }, + meta: { superAdmin: true, title: 'Admin: Licenses — Corrosion' }, }, { path: 'admin/subscriptions', name: 'platform-subscriptions', component: () => import('@/views/platform-admin/AdminSubscriptions.vue'), - meta: { superAdmin: true }, + meta: { superAdmin: true, title: 'Admin: Subscriptions — Corrosion' }, }, { path: 'admin/users', name: 'platform-users', component: () => import('@/views/platform-admin/AdminUsers.vue'), - meta: { superAdmin: true }, + meta: { superAdmin: true, title: 'Admin: Users — Corrosion' }, }, { path: 'admin/servers', name: 'platform-servers', component: () => import('@/views/platform-admin/AdminServers.vue'), - meta: { superAdmin: true }, + meta: { superAdmin: true, title: 'Admin: Servers — Corrosion' }, }, ], }, @@ -329,6 +401,7 @@ const panelRoutes: RouteRecordRaw[] = [ path: '/status', name: 'status', component: () => import('@/views/public/StatusPageView.vue'), + meta: { title: 'Status — Corrosion' }, }, // Catch-all @@ -366,6 +439,7 @@ const marketingRoutes: RouteRecordRaw[] = [ path: '/status', name: 'status', component: () => import('@/views/public/StatusPageView.vue'), + meta: { title: 'Status — Corrosion' }, }, // Catch-all: unknown routes → landing page @@ -383,6 +457,38 @@ const router = createRouter({ 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(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()