Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/Http/Controllers/ProjectController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -132,6 +154,7 @@ public function show(
'projectActivity' => $projectActivity,
'projectDeployments' => $projectDeployments,
'projectMonitors' => $projectMonitors,
'projectHosts' => $projectHosts,
]);
}

Expand Down
33 changes: 21 additions & 12 deletions resources/js/Components/Sidebar/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,33 +29,43 @@ 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' },
{ label: 'Activity', icon: History, routeName: 'activity.index' },
{ 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}.*`);
};

Expand Down
147 changes: 145 additions & 2 deletions resources/js/Pages/Projects/Show.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -108,6 +127,7 @@ const props = defineProps<{
projectActivity: ActivityEvent[];
projectDeployments: DeploymentRow[];
projectMonitors: MonitorRow[];
projectHosts: HostRow[];
}>();

const linkForm = useForm({
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -907,7 +927,130 @@ const confirmDelete = () => {
</div>
</section>

<!-- Phase-pending placeholder for hosts / monitoring tabs. -->
<!-- Hosts panel — Docker hosts under this project (specs 026 + 027). -->
<section
v-else-if="activeTab === 'hosts'"
aria-label="Hosts"
class="glass-card p-6 sm:p-8"
>
<header class="mb-4 flex items-center justify-between gap-3">
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-text-primary">
Project hosts
</h3>
<p class="text-xs text-text-muted">
Docker hosts running the Nexus agent under
this project. Up to 20 shown — full list at
<span class="font-mono">/monitoring/hosts</span>.
</p>
</div>
<div class="flex items-center gap-2">
<Link
v-if="canUpdate"
:href="
route('monitoring.hosts.create', {
project_id: project.id,
})
"
class="inline-flex items-center gap-1.5 rounded-md border border-accent-cyan/40 bg-accent-cyan/15 px-2.5 py-1.5 text-xs font-semibold text-accent-cyan transition hover:border-accent-cyan/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-cyan/60"
>
<Plus class="h-3.5 w-3.5" aria-hidden="true" />
Add host
</Link>
<Link
:href="route('monitoring.hosts.index')"
class="inline-flex items-center gap-1.5 rounded-md border border-border-subtle bg-background-panel-hover px-2.5 py-1.5 text-xs font-semibold text-text-secondary transition hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-cyan/60"
>
Browse all
<ArrowRight class="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
</header>

<ul
v-if="projectHosts.length > 0"
class="divide-y divide-border-subtle"
>
<li
v-for="host in projectHosts"
:key="host.id"
class="flex items-center gap-4 py-3"
>
<Server
class="h-4 w-4 shrink-0 text-text-muted"
aria-hidden="true"
/>
<Link
:href="route('monitoring.hosts.show', host.id)"
class="flex min-w-0 flex-1 flex-col gap-1 transition hover:text-accent-cyan"
>
<span class="truncate text-sm font-semibold text-text-primary">
{{ host.name }}
</span>
<p
class="flex flex-wrap items-center gap-x-2 truncate text-xs text-text-muted"
>
<span
v-if="host.provider"
class="font-mono text-text-secondary"
>
{{ host.provider }}
</span>
<span
v-if="host.connection_type"
class="font-mono uppercase"
>
{{ host.connection_type }}
</span>
<span v-if="host.last_seen_at">
· Last seen {{ host.last_seen_at }}
</span>
<span
v-else
class="text-text-muted/70"
>
· Awaiting first telemetry
</span>
<span
v-if="!host.has_active_token"
class="text-status-warning"
>
· No active agent token
</span>
</p>
</Link>
<StatusBadge :tone="hostStatusTone(host.status)">
{{ host.status }}
</StatusBadge>
</li>
</ul>

<div
v-else
class="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center"
>
<span
class="flex h-12 w-12 items-center justify-center rounded-full border border-border-subtle bg-slate-950/60"
>
<Server
class="h-5 w-5 text-text-muted"
aria-hidden="true"
/>
</span>
<p class="text-sm font-medium text-text-secondary">
No hosts yet
</p>
<p class="max-w-sm text-xs text-text-muted">
Add a Docker host to start tracking container
health, CPU, and memory under this project.
</p>
</div>
</section>

<!-- Phase-pending placeholder for any tab still flagged as
not-yet-shipped. With Hosts wired in this fix, no tab
carries `pendingPhase` today; the block stays so future
tabs (e.g. health-score in phase 8) can drop in cheaply. -->
<section
v-else
:aria-label="`${activeTab} (coming soon)`"
Expand Down
29 changes: 29 additions & 0 deletions tests/Feature/Projects/ProjectControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Tests\Feature\Projects;

use App\Models\ActivityEvent;
use App\Models\Host;
use App\Models\Project;
use App\Models\Repository;
use App\Models\User;
Expand Down Expand Up @@ -97,6 +98,7 @@ public function test_show_renders_the_project(): void
->has('projectActivity')
->has('projectDeployments')
->has('projectMonitors')
->has('projectHosts')
);
}

Expand Down Expand Up @@ -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();
Expand Down
Loading