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 @@ -9,6 +9,7 @@
use App\Http\Requests\Projects\StoreProjectRequest;
use App\Http\Requests\Projects\UpdateProjectRequest;
use App\Models\Project;
use App\Models\Website;
use App\Support\ProjectPalette;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -88,6 +89,27 @@ public function show(
10,
);

// Per-project Monitoring tab (spec 023) — website monitors
// belonging to this project. Cap at 20 inline — the cross-repo
// monitor list at `/monitoring/websites` is the wider view.
// Phase-1 single-tenant: any monitor under the project is
// visible to its owner; cross-tenant scoping arrives uniformly
// when teams ship.
$projectMonitors = Website::query()
->where('project_id', $project->id)
->orderBy('name')
->limit(20)
->get()
->map(fn (Website $website) => [
'id' => $website->id,
'name' => $website->name,
'url' => $website->url,
'method' => $website->method,
'status' => $website->status?->value,
'last_checked_at' => $website->last_checked_at?->diffForHumans(),
])
->all();

return Inertia::render('Projects/Show', [
'project' => $this->transform($project),
'canUpdate' => $request->user()?->can('update', $project) ?? false,
Expand All @@ -109,6 +131,7 @@ public function show(
])->all(),
'projectActivity' => $projectActivity,
'projectDeployments' => $projectDeployments,
'projectMonitors' => $projectMonitors,
]);
}

Expand Down
49 changes: 47 additions & 2 deletions resources/js/Pages/Monitoring/Websites/Create.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { ChevronLeft } from 'lucide-vue-next';
import { AlertTriangle, ChevronLeft } from 'lucide-vue-next';
import { computed } from 'vue';

interface ProjectOption {
id: number;
Expand Down Expand Up @@ -45,6 +46,28 @@ const form = useForm({
const submit = () => {
form.post(route('monitoring.websites.store'));
};

/**
* Detect when the entered URL points back at the same Nexus instance.
* `php artisan serve`'s default single-process worker deadlocks when
* a sync request loops back to itself (probe → controller → probe).
* Surface a heads-up so the user knows to use a different URL or run
* the dev server with `--workers=4`.
*
* Match by hostname only — port, scheme, and path don't matter for
* the loop. SSR-safe: `window` may not exist during initial render.
*/
const selfProbeWarning = computed(() => {
if (typeof window === 'undefined' || !form.url) return false;
try {
const target = new URL(form.url);
return target.hostname === window.location.hostname;
} catch {
// `new URL()` throws on malformed input; the form's url
// validation will catch that path on submit.
return false;
}
});
</script>

<template>
Expand Down Expand Up @@ -107,8 +130,30 @@ const submit = () => {
id="url"
v-model="form.url"
type="url"
placeholder="https://example.com/health"
placeholder="https://example.com/up"
/>
<p class="text-xs text-text-muted">
Tip: for Laravel apps, monitor
<span class="font-mono">/up</span> — Laravel's
built-in health endpoint.
</p>
<p
v-if="selfProbeWarning"
class="flex items-start gap-1.5 rounded-md border border-status-warning/40 bg-status-warning/10 p-2 text-xs text-status-warning"
>
<AlertTriangle
class="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span class="break-words">
Heads up — this URL points at the same host
as Nexus itself. If you're running
<span class="font-mono">php artisan serve</span>,
the probe will deadlock (single worker). Use
<span class="font-mono">--workers=4</span> or
point this monitor at a different URL.
</span>
</p>
<InputError :message="form.errors.url" />
</div>

Expand Down
13 changes: 3 additions & 10 deletions resources/js/Pages/Monitoring/Websites/Index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import StatusBadge from '@/Components/Dashboard/StatusBadge.vue';
import { websiteStatusTone as statusTone } from '@/lib/websiteStyles';
import AppLayout from '@/Layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import { ExternalLink, Globe, Plus } from 'lucide-vue-next';
Expand Down Expand Up @@ -33,16 +34,8 @@ defineProps<{
websites: WebsiteRow[];
}>();

const statusTone = (status: string | null) =>
(
({
pending: 'muted',
up: 'success',
down: 'danger',
slow: 'warning',
error: 'danger',
}) as const
)[status ?? ''] ?? 'muted';
// `statusTone` re-exported from `@/lib/websiteStyles` above so the
// four consumers stay in sync when the WebsiteStatus enum grows.

const projectAccentClass = (color: string | null) =>
(
Expand Down
13 changes: 3 additions & 10 deletions resources/js/Pages/Monitoring/Websites/Show.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import StatusBadge from '@/Components/Dashboard/StatusBadge.vue';
import { websiteStatusTone as statusTone } from '@/lib/websiteStyles';
import AppLayout from '@/Layouts/AppLayout.vue';
import { Head, Link, router } from '@inertiajs/vue3';
import {
Expand Down Expand Up @@ -52,16 +53,8 @@ const props = defineProps<{
canProbe: boolean;
}>();

const statusTone = (status: string | null) =>
(
({
pending: 'muted',
up: 'success',
down: 'danger',
slow: 'warning',
error: 'danger',
}) as const
)[status ?? ''] ?? 'muted';
// `statusTone` re-exported from `@/lib/websiteStyles` above so the
// four consumers stay in sync when the WebsiteStatus enum grows.

const projectAccentClass = (color: string | null) =>
(
Expand Down
122 changes: 121 additions & 1 deletion 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 { websiteStatusTone as monitorStatusTone } from '@/lib/websiteStyles';
import AppLayout from '@/Layouts/AppLayout.vue';
import type { ActivityEvent } from '@/types';
import { Head, Link, router, useForm } from '@inertiajs/vue3';
Expand Down Expand Up @@ -82,6 +83,22 @@ interface DeploymentRow {
repository: { id: number; full_name: string; name: string } | null;
}

interface MonitorRow {
id: number;
name: string;
url: string;
method: string;
status:
| 'pending'
| 'up'
| 'down'
| 'slow'
| 'error'
| string
| null;
last_checked_at: string | null;
}

const props = defineProps<{
project: ProjectShape;
canUpdate: boolean;
Expand All @@ -90,6 +107,7 @@ const props = defineProps<{
hasGithubConnection: boolean;
projectActivity: ActivityEvent[];
projectDeployments: DeploymentRow[];
projectMonitors: MonitorRow[];
}>();

const linkForm = useForm({
Expand Down Expand Up @@ -130,6 +148,10 @@ const syncStatusTone = (status: string | null) =>
}) as const
)[status ?? ''] ?? 'muted';

// `monitorStatusTone` lives in `@/lib/websiteStyles` so the four
// consumers (this page + Monitoring/Websites/Index + Show + future
// fourth) stay in sync when the WebsiteStatus enum grows a new case.

// Tab definitions. Overview + Settings have content this spec; the others
// advertise the phase that ships their real implementation.
type TabKey =
Expand All @@ -146,7 +168,7 @@ const tabs: { key: TabKey; label: string; icon: LucideIcon; pendingPhase: string
{ 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: 'monitoring', label: 'Monitoring', icon: Globe, pendingPhase: 'phase 5' },
{ 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 @@ -787,6 +809,104 @@ const confirmDelete = () => {
</div>
</section>

<!-- Monitoring panel — website monitors under this project. -->
<section
v-else-if="activeTab === 'monitoring'"
aria-label="Monitoring"
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 monitors
</h3>
<p class="text-xs text-text-muted">
Website health checks under this project. Up to
20 shown — full list at
<span class="font-mono">/monitoring/websites</span>.
</p>
</div>
<div class="flex items-center gap-2">
<Link
v-if="canUpdate"
:href="
route('monitoring.websites.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 monitor
</Link>
<Link
:href="route('monitoring.websites.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="projectMonitors.length > 0"
class="divide-y divide-border-subtle"
>
<li
v-for="monitor in projectMonitors"
:key="monitor.id"
class="flex items-center gap-4 py-3"
>
<Globe
class="h-4 w-4 shrink-0 text-text-muted"
aria-hidden="true"
/>
<Link
:href="route('monitoring.websites.show', monitor.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">
{{ monitor.name }}
</span>
<p
class="flex flex-wrap items-center gap-x-2 truncate text-xs text-text-muted"
>
<span class="font-mono text-text-secondary">{{ monitor.method }}</span>
<span class="truncate font-mono">{{ monitor.url }}</span>
<span v-if="monitor.last_checked_at">
· Last check {{ monitor.last_checked_at }}
</span>
</p>
</Link>
<StatusBadge :tone="monitorStatusTone(monitor.status)">
{{ monitor.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"
>
<Globe
class="h-5 w-5 text-text-muted"
aria-hidden="true"
/>
</span>
<p class="text-sm font-medium text-text-secondary">
No monitors yet
</p>
<p class="max-w-sm text-xs text-text-muted">
Add a website URL to start tracking response time
and uptime under this project.
</p>
</div>
</section>

<!-- Phase-pending placeholder for hosts / monitoring tabs. -->
<section
v-else
Expand Down
2 changes: 1 addition & 1 deletion resources/js/Pages/Welcome.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const capabilities: Capability[] = [
{
title: 'Website performance',
description:
'Probes for uptime, response time, and TLS health, charted against your SLA targets — coming with phase 5.',
'Probes for uptime, response time, and TLS health, charted against your SLA targets.',
accent: 'success',
},
{
Expand Down
12 changes: 6 additions & 6 deletions resources/js/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ export function getCommands(): Command[] {
label: 'Go to Issues & PRs',
group: 'navigation',
icon: GitPullRequest,
disabled: true,
soonLabel: 'Phase 2',
keywords: ['issues', 'pull requests', 'prs', 'work items'],
run: () => router.visit(route('work-items.index')),
},
{
id: 'go-pipelines',
Expand All @@ -118,8 +118,8 @@ export function getCommands(): Command[] {
label: 'Go to Deployments',
group: 'navigation',
icon: Rocket,
disabled: true,
soonLabel: 'Phase 4',
keywords: ['deploy', 'workflow runs', 'actions', 'ci'],
run: () => router.visit(route('deployments.index')),
},
{
id: 'go-hosts',
Expand All @@ -134,8 +134,8 @@ export function getCommands(): Command[] {
label: 'Go to Monitoring',
group: 'navigation',
icon: Globe,
disabled: true,
soonLabel: 'Phase 5',
keywords: ['monitor', 'websites', 'uptime', 'probes'],
run: () => router.visit(route('monitoring.websites.index')),
},
{
id: 'go-analytics',
Expand Down
Loading
Loading