diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 923ae2a..82f9cb4 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -8,6 +8,7 @@ use App\Enums\ProjectStatus; use App\Http\Requests\Projects\StoreProjectRequest; use App\Http\Requests\Projects\UpdateProjectRequest; +use App\Models\Host; use App\Models\Project; use App\Models\Website; use App\Support\ProjectPalette; @@ -110,6 +111,27 @@ public function show( ]) ->all(); + // Per-project Hosts tab (spec 026 + 027) — Docker hosts + // registered under this project. Same 20-row cap pattern as the + // monitors list; the wider view lives at `/monitoring/hosts`. + $projectHosts = Host::query() + ->where('project_id', $project->id) + ->orderBy('name') + ->with('activeAgentToken') + ->limit(20) + ->get() + ->map(fn (Host $host) => [ + 'id' => $host->id, + 'name' => $host->name, + 'slug' => $host->slug, + 'provider' => $host->provider, + 'connection_type' => $host->connection_type?->value, + 'status' => $host->status?->value, + 'last_seen_at' => $host->last_seen_at?->diffForHumans(), + 'has_active_token' => $host->activeAgentToken !== null, + ]) + ->all(); + return Inertia::render('Projects/Show', [ 'project' => $this->transform($project), 'canUpdate' => $request->user()?->can('update', $project) ?? false, @@ -132,6 +154,7 @@ public function show( 'projectActivity' => $projectActivity, 'projectDeployments' => $projectDeployments, 'projectMonitors' => $projectMonitors, + 'projectHosts' => $projectHosts, ]); } diff --git a/resources/js/Components/Sidebar/Sidebar.vue b/resources/js/Components/Sidebar/Sidebar.vue index 7286b50..c8eeb83 100644 --- a/resources/js/Components/Sidebar/Sidebar.vue +++ b/resources/js/Components/Sidebar/Sidebar.vue @@ -5,7 +5,6 @@ import SidebarSystemStatus from '@/Components/Sidebar/SidebarSystemStatus.vue'; import SidebarUserCard from '@/Components/Sidebar/SidebarUserCard.vue'; import { Link } from '@inertiajs/vue3'; import { - Activity, BarChart3, Bell, FolderKanban, @@ -30,19 +29,20 @@ interface NavItem { soonLabel?: string; } -// Order locked to roadmap §7.6 (with Activity inserted between Alerts and -// Settings as the 12th slot — spec 018). Only Overview, Projects, -// Repositories, Issues & PRs, Activity, and Settings are wired this phase; -// the rest carry a "Soon" pill until their owning spec lands. The phase -// pill text helps readers see which spec will activate each item. +// Order follows roadmap §7.6 with two practical adjustments: +// (1) Activity sits between Alerts and Settings (spec 018). +// (2) The roadmap's separate "Pipelines" entry was folded into +// Deployments — GitHub workflow runs are both — so we ship one +// entry rather than maintain two views of the same data. +// Disabled entries carry a "Soon" pill labelled with the phase that +// activates them. const nav: NavItem[] = [ { label: 'Overview', icon: LayoutDashboard, routeName: 'overview' }, { label: 'Projects', icon: FolderKanban, routeName: 'projects.index' }, { label: 'Repositories', icon: GitBranch, routeName: 'repositories.index' }, { label: 'Issues & PRs', icon: GitPullRequest, routeName: 'work-items.index' }, - { label: 'Pipelines', icon: Activity, disabled: true, soonLabel: 'Phase 4' }, { label: 'Deployments', icon: Rocket, routeName: 'deployments.index' }, - { label: 'Hosts', icon: Server, disabled: true, soonLabel: 'Phase 6' }, + { label: 'Hosts', icon: Server, routeName: 'monitoring.hosts.index' }, { label: 'Monitoring', icon: Globe, routeName: 'monitoring.websites.index' }, { label: 'Analytics', icon: BarChart3, disabled: true, soonLabel: 'Phase 8' }, { label: 'Alerts', icon: Bell, disabled: true, soonLabel: 'Phase 7' }, @@ -50,13 +50,22 @@ const nav: NavItem[] = [ { label: 'Settings', icon: SettingsIcon, routeName: 'settings.index' }, ]; -// Match the route exactly OR any sibling under the same resource family -// (so `/projects/foo` keeps the Projects nav lit). For non-resourceful -// routes like `overview` the wildcard match harmlessly returns false. +// Match the route exactly OR any sibling under the same resource family. +// +// "Family" = the route name minus its action segment. For +// `projects.index` that's `projects.*`. For nested resources like +// `monitoring.hosts.index` it's `monitoring.hosts.*` — important so +// the Hosts and Monitoring sidebar entries don't both light up at the +// same time (they'd collide on a naive `monitoring.*` prefix). +// +// Single-segment routes like `overview` only match exactly; the +// `*` fallback returns false harmlessly. const isActive = (item: NavItem): boolean => { if (!item.routeName) return false; if (route().current(item.routeName)) return true; - const family = item.routeName.split('.')[0]; + const segments = item.routeName.split('.'); + if (segments.length < 2) return false; + const family = segments.slice(0, -1).join('.'); return route().current(`${family}.*`); }; diff --git a/resources/js/Pages/Projects/Show.vue b/resources/js/Pages/Projects/Show.vue index 1cc4b7d..1ab45b6 100644 --- a/resources/js/Pages/Projects/Show.vue +++ b/resources/js/Pages/Projects/Show.vue @@ -12,6 +12,7 @@ import { runStatusDotClass, runStatusTone, } from '@/lib/workflowRunStyles'; +import { hostStatusTone } from '@/lib/hostStyles'; import { websiteStatusTone as monitorStatusTone } from '@/lib/websiteStyles'; import AppLayout from '@/Layouts/AppLayout.vue'; import type { ActivityEvent } from '@/types'; @@ -99,6 +100,24 @@ interface MonitorRow { last_checked_at: string | null; } +interface HostRow { + id: number; + name: string; + slug: string; + provider: string | null; + connection_type: string | null; + status: + | 'pending' + | 'online' + | 'offline' + | 'degraded' + | 'archived' + | string + | null; + last_seen_at: string | null; + has_active_token: boolean; +} + const props = defineProps<{ project: ProjectShape; canUpdate: boolean; @@ -108,6 +127,7 @@ const props = defineProps<{ projectActivity: ActivityEvent[]; projectDeployments: DeploymentRow[]; projectMonitors: MonitorRow[]; + projectHosts: HostRow[]; }>(); const linkForm = useForm({ @@ -167,7 +187,7 @@ const tabs: { key: TabKey; label: string; icon: LucideIcon; pendingPhase: string { key: 'overview', label: 'Overview', icon: BarChart3, pendingPhase: null }, { key: 'repositories', label: 'Repositories', icon: GitBranch, pendingPhase: null }, { key: 'deployments', label: 'Deployments', icon: Rocket, pendingPhase: null }, - { key: 'hosts', label: 'Hosts', icon: Server, pendingPhase: 'phase 6' }, + { key: 'hosts', label: 'Hosts', icon: Server, pendingPhase: null }, { key: 'monitoring', label: 'Monitoring', icon: Globe, pendingPhase: null }, { key: 'activity', label: 'Activity', icon: Activity, pendingPhase: null }, { key: 'settings', label: 'Settings', icon: Settings, pendingPhase: null }, @@ -907,7 +927,130 @@ const confirmDelete = () => { - + +
+
+
+

+ Project hosts +

+

+ Docker hosts running the Nexus agent under + this project. Up to 20 shown — full list at + /monitoring/hosts. +

+
+
+ +
+
+ + + +
+ + +

+ No hosts yet +

+

+ Add a Docker host to start tracking container + health, CPU, and memory under this project. +

+
+
+ +
has('projectActivity') ->has('projectDeployments') ->has('projectMonitors') + ->has('projectHosts') ); } @@ -126,6 +128,33 @@ public function test_show_scopes_monitors_to_this_project(): void ); } + public function test_show_scopes_hosts_to_this_project(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + Host::factory()->create([ + 'project_id' => $project->id, + 'name' => 'project-host-01', + ]); + + // Sibling project's host must NOT leak. + $sibling = Project::factory()->create(['owner_user_id' => $user->id]); + Host::factory()->create([ + 'project_id' => $sibling->id, + 'name' => 'sibling-host-01', + ]); + + $this->actingAs($user) + ->get(route('projects.show', $project)) + ->assertSuccessful() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->has('projectHosts', 1) + ->where('projectHosts.0.name', 'project-host-01') + ->where('projectHosts.0.has_active_token', false) + ); + } + public function test_show_scopes_activity_and_deployments_to_this_project(): void { $user = $this->verifiedUser();