phase 3
This commit is contained in:
@@ -145,45 +145,45 @@ Clients configure this DSN in their Sentry SDK — no changes needed on the SDK
|
|||||||
|
|
||||||
### 3.1 Layouts & Navigation
|
### 3.1 Layouts & Navigation
|
||||||
|
|
||||||
- [ ] Authenticated layout with sidebar navigation
|
- [x] Authenticated layout with sidebar navigation
|
||||||
- [ ] Global project switcher in the sidebar/topbar
|
- [x] Global project switcher in the sidebar/topbar
|
||||||
- [ ] Sections: **Issues**, **Project Settings**, **Team Members**
|
- [x] Sections: **Issues**, **Project Settings**, **Team Members**
|
||||||
|
|
||||||
### 3.2 Issues List Page (`/projects/{project}/issues`)
|
### 3.2 Issues List Page (`/projects/{project}/issues`)
|
||||||
|
|
||||||
- [ ] Table/list of issues sorted by last seen (most recent first)
|
- [x] Table/list of issues sorted by last seen (most recent first)
|
||||||
- [ ] Columns: status badge, title, culprit, event count sparkline, times seen, last seen
|
- [x] Columns: status badge, title, culprit, event count sparkline, times seen, last seen
|
||||||
- [ ] Filters: status (unresolved/resolved/ignored), level, environment, date range
|
- [x] Filters: status (unresolved/resolved/ignored), level, environment, date range
|
||||||
- [ ] Search: full-text search on title and culprit
|
- [x] Search: full-text search on title and culprit
|
||||||
- [ ] Bulk actions: resolve, ignore, mute, delete
|
- [x] Bulk actions: resolve, ignore, mute, delete
|
||||||
- [ ] Pagination (cursor-based for performance)
|
- [x] Pagination (cursor-based for performance)
|
||||||
|
|
||||||
### 3.3 Issue Detail Page (`/projects/{project}/issues/{issue}`)
|
### 3.3 Issue Detail Page (`/projects/{project}/issues/{issue}`)
|
||||||
|
|
||||||
- [ ] Header: title, status controls (resolve / ignore / unresolve), level badge, first/last seen, times seen
|
- [x] Header: title, status controls (resolve / ignore / unresolve), level badge, first/last seen, times seen
|
||||||
- [ ] **Stacktrace viewer:** collapsible frames, source context (if available), highlighted app frames vs vendor frames
|
- [x] **Stacktrace viewer:** collapsible frames, source context (if available), highlighted app frames vs vendor frames
|
||||||
- [ ] **Breadcrumbs timeline:** chronological list of breadcrumb entries
|
- [x] **Breadcrumbs timeline:** chronological list of breadcrumb entries
|
||||||
- [ ] **Tags panel:** key-value tag list with links to filter by tag
|
- [x] **Tags panel:** key-value tag list with links to filter by tag
|
||||||
- [ ] **Context panels:** device, OS, browser, runtime, user info, request details
|
- [x] **Context panels:** device, OS, browser, runtime, user info, request details
|
||||||
- [ ] **Events sub-list:** paginated list of individual occurrences with ability to view each one
|
- [x] **Events sub-list:** paginated list of individual occurrences with ability to view each one
|
||||||
- [ ] **Activity:** timeline of status changes (resolved, regressed, etc.)
|
- [ ] **Activity:** timeline of status changes (resolved, regressed, etc.)
|
||||||
|
|
||||||
### 3.4 Event Detail Page (`/projects/{project}/issues/{issue}/events/{event}`)
|
### 3.4 Event Detail Page (`/projects/{project}/issues/{issue}/events/{event}`)
|
||||||
|
|
||||||
- [ ] Full event data: raw JSON viewer + structured panels
|
- [x] Full event data: raw JSON viewer + structured panels
|
||||||
- [ ] Navigation: previous/next event within the issue
|
- [x] Navigation: previous/next event within the issue
|
||||||
|
|
||||||
### 3.5 Project Settings Page
|
### 3.5 Project Settings Page
|
||||||
|
|
||||||
- [ ] Rename project, change platform
|
- [x] Rename project, change platform
|
||||||
- [ ] DSN display with copy button
|
- [x] DSN display with copy button
|
||||||
- [ ] Regenerate keys
|
- [x] Regenerate keys
|
||||||
- [ ] Delete project (with confirmation)
|
- [x] Delete project (with confirmation)
|
||||||
|
|
||||||
### 3.6 Dashboard / Overview Page
|
### 3.6 Dashboard / Overview Page
|
||||||
|
|
||||||
- [ ] Summary cards: total unresolved issues, events in last 24h, most active projects
|
- [x] Summary cards: total unresolved issues, events in last 24h, most active projects
|
||||||
- [ ] Line chart: events over time (last 7/14/30 days)
|
- [x] Line chart: events over time (last 7/14/30 days)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -240,21 +240,21 @@ Clients configure this DSN in their Sentry SDK — no changes needed on the SDK
|
|||||||
- [x] 3. Build models with relationships
|
- [x] 3. Build models with relationships
|
||||||
- [x] 4. Build the ingest endpoint (`POST /api/{project_id}/store/`)
|
- [x] 4. Build the ingest endpoint (`POST /api/{project_id}/store/`)
|
||||||
- [x] 5. Implement fingerprinting and the `ProcessEventJob`
|
- [x] 5. Implement fingerprinting and the `ProcessEventJob`
|
||||||
- [ ] 6. Build a minimal issues list page and issue detail page
|
- [x] 6. Build a minimal issues list page and issue detail page
|
||||||
- [ ] 7. Test with a real Sentry SDK (e.g. `sentry/sentry-laravel` on another app pointing at this tracker)
|
- [ ] 7. Test with a real Sentry SDK (e.g. `sentry/sentry-laravel` on another app pointing at this tracker)
|
||||||
|
|
||||||
### Milestone 2 — Usable product
|
### Milestone 2 — Usable product
|
||||||
- [ ] 8. Project CRUD and settings pages
|
- [x] 8. Project CRUD and settings pages
|
||||||
- [ ] 9. Team/member management (invite users to projects)
|
- [ ] 9. Team/member management (invite users to projects)
|
||||||
- [ ] 10. Stacktrace viewer with collapsible frames
|
- [x] 10. Stacktrace viewer with collapsible frames
|
||||||
- [ ] 11. Breadcrumbs, tags, context panels on issue detail
|
- [x] 11. Breadcrumbs, tags, context panels on issue detail
|
||||||
- [ ] 12. Filters, search, bulk actions on issues list
|
- [x] 12. Filters, search, bulk actions on issues list
|
||||||
- [ ] 13. Status management (resolve, ignore, mute, auto-regression detection)
|
- [x] 13. Status management (resolve, ignore, mute, auto-regression detection)
|
||||||
|
|
||||||
### Milestone 3 — Notifications & dashboard
|
### Milestone 3 — Notifications & dashboard
|
||||||
- [ ] 14. Email notifications on new issue / regression
|
- [ ] 14. Email notifications on new issue / regression
|
||||||
- [ ] 15. In-app notification system
|
- [ ] 15. In-app notification system
|
||||||
- [ ] 16. Overview dashboard with charts (events over time)
|
- [x] 16. Overview dashboard with charts (events over time)
|
||||||
|
|
||||||
### Milestone 4 — Production-readiness
|
### Milestone 4 — Production-readiness
|
||||||
- [ ] 17. Rate limiting on ingest
|
- [ ] 17. Rate limiting on ingest
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Issue;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$projectIds = $user->projects()->pluck('projects.id');
|
||||||
|
|
||||||
|
$unresolvedCount = Issue::whereIn('project_id', $projectIds)
|
||||||
|
->where('status', 'unresolved')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$events24h = Event::whereIn('project_id', $projectIds)
|
||||||
|
->where('created_at', '>=', Carbon::now()->subDay())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$mostActiveProjects = $user->projects()
|
||||||
|
->withCount(['issues' => fn ($q) => $q->where('status', 'unresolved')])
|
||||||
|
->withCount(['events' => fn ($q) => $q->where('events.created_at', '>=', Carbon::now()->subDay())])
|
||||||
|
->orderByDesc('events_count')
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$days = (int) $request->query('days', 14);
|
||||||
|
$eventsOverTime = $this->getEventsOverTime($projectIds, $days);
|
||||||
|
|
||||||
|
return view('dashboard', [
|
||||||
|
'unresolvedCount' => $unresolvedCount,
|
||||||
|
'events24h' => $events24h,
|
||||||
|
'mostActiveProjects' => $mostActiveProjects,
|
||||||
|
'eventsOverTime' => $eventsOverTime,
|
||||||
|
'days' => $days,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getEventsOverTime($projectIds, int $days): array
|
||||||
|
{
|
||||||
|
$since = Carbon::now()->subDays($days)->startOfDay();
|
||||||
|
|
||||||
|
$rows = Event::whereIn('project_id', $projectIds)
|
||||||
|
->where('created_at', '>=', $since)
|
||||||
|
->select(DB::raw('DATE(created_at) as date'), DB::raw('COUNT(*) as count'))
|
||||||
|
->groupBy('date')
|
||||||
|
->orderBy('date')
|
||||||
|
->pluck('count', 'date')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
for ($i = $days - 1; $i >= 0; $i--) {
|
||||||
|
$date = Carbon::now()->subDays($i)->format('Y-m-d');
|
||||||
|
$result[] = [
|
||||||
|
'date' => $date,
|
||||||
|
'count' => $rows[$date] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Issue;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class EventController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request, Project $project, Issue $issue, Event $event): View
|
||||||
|
{
|
||||||
|
$this->authorizeProject($request, $project);
|
||||||
|
abort_unless($issue->project_id === $project->id, 404);
|
||||||
|
abort_unless($event->issue_id === $issue->id, 404);
|
||||||
|
|
||||||
|
$previousEvent = Event::where('issue_id', $issue->id)
|
||||||
|
->where('timestamp', '<', $event->timestamp)
|
||||||
|
->orderByDesc('timestamp')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$nextEvent = Event::where('issue_id', $issue->id)
|
||||||
|
->where('timestamp', '>', $event->timestamp)
|
||||||
|
->orderBy('timestamp')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return view('events.show', [
|
||||||
|
'project' => $project,
|
||||||
|
'issue' => $issue,
|
||||||
|
'event' => $event,
|
||||||
|
'previousEvent' => $previousEvent,
|
||||||
|
'nextEvent' => $nextEvent,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeProject(Request $request, Project $project): void
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
$request->user()->projects()->where('projects.id', $project->id)->exists(),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Issue;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class IssueController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, Project $project): View
|
||||||
|
{
|
||||||
|
$this->authorizeProject($request, $project);
|
||||||
|
|
||||||
|
$query = $project->issues();
|
||||||
|
|
||||||
|
if ($status = $request->query('status')) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
} else {
|
||||||
|
$query->where('status', 'unresolved');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($level = $request->query('level')) {
|
||||||
|
$query->where('level', $level);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($search = $request->query('search')) {
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('title', 'like', "%{$search}%")
|
||||||
|
->orWhere('culprit', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortField = $request->query('sort', 'last_seen_at');
|
||||||
|
$sortDir = $request->query('dir', 'desc');
|
||||||
|
$allowedSorts = ['last_seen_at', 'first_seen_at', 'times_seen', 'level'];
|
||||||
|
if (in_array($sortField, $allowedSorts)) {
|
||||||
|
$query->orderBy($sortField, $sortDir === 'asc' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues = $query->cursorPaginate(25)->withQueryString();
|
||||||
|
|
||||||
|
return view('issues.index', [
|
||||||
|
'project' => $project,
|
||||||
|
'issues' => $issues,
|
||||||
|
'currentStatus' => $status ?: 'unresolved',
|
||||||
|
'currentLevel' => $level,
|
||||||
|
'search' => $search,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Project $project, Issue $issue): View
|
||||||
|
{
|
||||||
|
$this->authorizeProject($request, $project);
|
||||||
|
abort_unless($issue->project_id === $project->id, 404);
|
||||||
|
|
||||||
|
$latestEvent = $issue->events()->latest('timestamp')->first();
|
||||||
|
|
||||||
|
$events = $issue->events()
|
||||||
|
->latest('timestamp')
|
||||||
|
->cursorPaginate(10)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return view('issues.show', [
|
||||||
|
'project' => $project,
|
||||||
|
'issue' => $issue,
|
||||||
|
'latestEvent' => $latestEvent,
|
||||||
|
'events' => $events,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateStatus(Request $request, Project $project, Issue $issue): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeProject($request, $project);
|
||||||
|
abort_unless($issue->project_id === $project->id, 404);
|
||||||
|
|
||||||
|
$action = $request->input('action');
|
||||||
|
|
||||||
|
match ($action) {
|
||||||
|
'resolve' => $issue->resolve(),
|
||||||
|
'unresolve' => $issue->unresolve(),
|
||||||
|
'ignore' => $issue->ignore(),
|
||||||
|
'mute' => $issue->mute(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return back()->with('status', 'Issue ' . $action . 'd.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkAction(Request $request, Project $project): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeProject($request, $project);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'action' => 'required|in:resolve,ignore,mute,delete',
|
||||||
|
'issues' => 'required|array',
|
||||||
|
'issues.*' => 'string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$query = Issue::where('project_id', $project->id)
|
||||||
|
->whereIn('id', $validated['issues']);
|
||||||
|
|
||||||
|
match ($validated['action']) {
|
||||||
|
'resolve' => $query->update(['status' => 'resolved', 'is_regression' => false]),
|
||||||
|
'ignore' => $query->update(['status' => 'ignored']),
|
||||||
|
'mute' => $query->update(['status' => 'muted']),
|
||||||
|
'delete' => $query->delete(),
|
||||||
|
};
|
||||||
|
|
||||||
|
$count = count($validated['issues']);
|
||||||
|
|
||||||
|
return back()->with('status', "{$count} issue(s) {$validated['action']}d.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeProject(Request $request, Project $project): void
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
$request->user()->projects()->where('projects.id', $project->id)->exists(),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ProjectController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$projects = $request->user()
|
||||||
|
->projects()
|
||||||
|
->withCount(['issues' => fn ($q) => $q->where('status', 'unresolved')])
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('projects.index', compact('projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('projects.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'platform' => 'nullable|string|max:50',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project = Project::create($validated);
|
||||||
|
|
||||||
|
$request->user()->projects()->attach($project, ['role' => 'owner']);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('projects.settings', $project)
|
||||||
|
->with('status', 'Project created. Here is your DSN.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function settings(Request $request, Project $project): View
|
||||||
|
{
|
||||||
|
$this->authorizeProject($request, $project);
|
||||||
|
|
||||||
|
return view('projects.settings', compact('project'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Project $project): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeProject($request, $project);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'platform' => 'nullable|string|max:50',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project->update($validated);
|
||||||
|
|
||||||
|
return back()->with('status', 'Project updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerateKeys(Request $request, Project $project): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeProject($request, $project);
|
||||||
|
|
||||||
|
$project->regenerateKeys();
|
||||||
|
|
||||||
|
return back()->with('status', 'DSN keys regenerated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Project $project): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeProject($request, $project);
|
||||||
|
|
||||||
|
$project->delete();
|
||||||
|
|
||||||
|
return redirect()->route('projects.index')->with('status', 'Project deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeProject(Request $request, Project $project): void
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
$request->user()->projects()->where('projects.id', $project->id)->exists(),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,144 @@
|
|||||||
<x-app-layout>
|
<x-app-layout>
|
||||||
<x-slot name="header">
|
<x-slot name="header">
|
||||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Dashboard</h2>
|
||||||
{{ __('Dashboard') }}
|
|
||||||
</h2>
|
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<div class="py-12">
|
<div class="py-8">
|
||||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
|
||||||
<div class="p-6 text-gray-900">
|
{{-- Summary Cards --}}
|
||||||
{{ __("You're logged in!") }}
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<div class="text-sm font-medium text-gray-500">Unresolved Issues</div>
|
||||||
|
<div class="mt-1 text-3xl font-bold text-gray-900">{{ number_format($unresolvedCount) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<div class="text-sm font-medium text-gray-500">Events (24h)</div>
|
||||||
|
<div class="mt-1 text-3xl font-bold text-gray-900">{{ number_format($events24h) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<div class="text-sm font-medium text-gray-500">Active Projects</div>
|
||||||
|
<div class="mt-1 text-3xl font-bold text-gray-900">{{ $mostActiveProjects->count() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Events Over Time Chart --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Events Over Time</h3>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
@foreach([7, 14, 30] as $d)
|
||||||
|
<a href="{{ route('dashboard', ['days' => $d]) }}"
|
||||||
|
class="px-3 py-1 text-sm rounded-md {{ $days === $d ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }}">
|
||||||
|
{{ $d }}d
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-data="eventsChart()" x-init="init()" class="h-48">
|
||||||
|
<canvas x-ref="canvas" class="w-full h-full"></canvas>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function eventsChart() {
|
||||||
|
return {
|
||||||
|
init() {
|
||||||
|
const data = @json($eventsOverTime);
|
||||||
|
const canvas = this.$refs.canvas;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const w = rect.width;
|
||||||
|
const h = rect.height;
|
||||||
|
const padding = { top: 10, right: 10, bottom: 30, left: 50 };
|
||||||
|
const chartW = w - padding.left - padding.right;
|
||||||
|
const chartH = h - padding.top - padding.bottom;
|
||||||
|
|
||||||
|
const counts = data.map(d => d.count);
|
||||||
|
const maxVal = Math.max(...counts, 1);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#e5e7eb';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const y = padding.top + chartH - (chartH * i / 4);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left, y);
|
||||||
|
ctx.lineTo(w - padding.right, y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#9ca3af';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(Math.round(maxVal * i / 4), padding.left - 8, y + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = '#4f46e5';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
data.forEach((d, i) => {
|
||||||
|
const x = padding.left + (i / (data.length - 1 || 1)) * chartW;
|
||||||
|
const y = padding.top + chartH - (d.count / maxVal) * chartH;
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#9ca3af';
|
||||||
|
ctx.font = '10px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const step = Math.max(1, Math.floor(data.length / 7));
|
||||||
|
data.forEach((d, i) => {
|
||||||
|
if (i % step === 0 || i === data.length - 1) {
|
||||||
|
const x = padding.left + (i / (data.length - 1 || 1)) * chartW;
|
||||||
|
ctx.fillText(d.date.slice(5), x, h - 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Most Active Projects --}}
|
||||||
|
@if($mostActiveProjects->isNotEmpty())
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Most Active Projects</h3>
|
||||||
|
</div>
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Project</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Platform</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Unresolved</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Events (24h)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
@foreach($mostActiveProjects as $p)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<a href="{{ route('projects.issues.index', $p) }}" class="text-indigo-600 hover:text-indigo-900 font-medium">{{ $p->name }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $p->platform ?? '—' }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-right">{{ $p->issues_count }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-right">{{ $p->events_count }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 text-center text-gray-500">
|
||||||
|
No projects yet.
|
||||||
|
<a href="{{ route('projects.create') }}" class="text-indigo-600 hover:underline">Create your first project</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
@php($currentProject = $project)
|
||||||
|
|
||||||
|
<x-slot name="header">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-1 text-sm">
|
||||||
|
<a href="{{ route('projects.issues.show', [$project, $issue]) }}" class="text-gray-500 hover:text-gray-700">← {{ Str::limit($issue->title, 60) }}</a>
|
||||||
|
</div>
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Event {{ Str::limit($event->event_id, 20) }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0 ml-4">
|
||||||
|
@if($previousEvent)
|
||||||
|
<a href="{{ route('projects.issues.events.show', [$project, $issue, $previousEvent]) }}"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition">← Older</a>
|
||||||
|
@endif
|
||||||
|
@if($nextEvent)
|
||||||
|
<a href="{{ route('projects.issues.events.show', [$project, $issue, $nextEvent]) }}"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition">Newer →</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-8">
|
||||||
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||||
|
|
||||||
|
{{-- Event Meta --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Level</span>
|
||||||
|
<div class="mt-1 font-medium text-gray-900">{{ ucfirst($event->level) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Environment</span>
|
||||||
|
<div class="mt-1 font-medium text-gray-900">{{ $event->environment ?? '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Release</span>
|
||||||
|
<div class="mt-1 font-medium text-gray-900 font-mono">{{ $event->release ?? '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Timestamp</span>
|
||||||
|
<div class="mt-1 font-medium text-gray-900" title="{{ $event->timestamp }}">{{ $event->timestamp->format('Y-m-d H:i:s') }}</div>
|
||||||
|
</div>
|
||||||
|
@if($event->server_name)
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Server</span>
|
||||||
|
<div class="mt-1 font-medium text-gray-900 font-mono">{{ $event->server_name }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($event->platform)
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Platform</span>
|
||||||
|
<div class="mt-1 font-medium text-gray-900">{{ $event->platform }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($event->sdk)
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">SDK</span>
|
||||||
|
<div class="mt-1 font-medium text-gray-900 font-mono text-xs">{{ $event->sdk['name'] ?? '' }} {{ $event->sdk['version'] ?? '' }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if($event->message)
|
||||||
|
<div class="mt-4 p-3 bg-gray-50 rounded-md">
|
||||||
|
<span class="text-xs text-gray-500 font-medium">Message</span>
|
||||||
|
<p class="text-sm text-gray-900 mt-1">{{ $event->message }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Stacktrace --}}
|
||||||
|
@if(!empty($event->exception['values']))
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Exception</h3>
|
||||||
|
</div>
|
||||||
|
@foreach($event->exception['values'] as $exc)
|
||||||
|
<div class="border-b border-gray-100 last:border-b-0">
|
||||||
|
<div class="px-6 py-3 bg-gray-50">
|
||||||
|
<span class="font-mono text-sm font-semibold text-red-700">{{ $exc['type'] ?? 'Exception' }}</span>
|
||||||
|
@if(!empty($exc['value']))
|
||||||
|
<span class="text-sm text-gray-600 ml-2">{{ $exc['value'] }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if(!empty($exc['stacktrace']['frames']))
|
||||||
|
<div x-data="{ expandedFrames: {} }" class="divide-y divide-gray-100">
|
||||||
|
@foreach(array_reverse($exc['stacktrace']['frames']) as $i => $frame)
|
||||||
|
@php
|
||||||
|
$file = $frame['filename'] ?? $frame['abs_path'] ?? '?';
|
||||||
|
$fn = $frame['function'] ?? null;
|
||||||
|
$lineno = $frame['lineno'] ?? null;
|
||||||
|
$inApp = $frame['in_app'] ?? false;
|
||||||
|
$context = $frame['context_line'] ?? null;
|
||||||
|
$preContext = $frame['pre_context'] ?? [];
|
||||||
|
$postContext = $frame['post_context'] ?? [];
|
||||||
|
@endphp
|
||||||
|
<div class="{{ $inApp ? 'bg-white' : 'bg-gray-50/50' }}">
|
||||||
|
<button @click="expandedFrames[{{ $i }}] = !expandedFrames[{{ $i }}]"
|
||||||
|
class="w-full px-6 py-2 text-left text-sm flex items-center gap-2 hover:bg-gray-50 transition">
|
||||||
|
<svg :class="expandedFrames[{{ $i }}] ? 'rotate-90' : ''" class="w-3 h-3 text-gray-400 transition-transform shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M6 4l8 6-8 6V4z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono {{ $inApp ? 'text-gray-900 font-medium' : 'text-gray-500' }} truncate">
|
||||||
|
{{ $file }}@if($lineno):{{ $lineno }}@endif
|
||||||
|
</span>
|
||||||
|
@if($fn)
|
||||||
|
<span class="text-gray-400 shrink-0">in</span>
|
||||||
|
<span class="font-mono {{ $inApp ? 'text-indigo-700' : 'text-gray-500' }} truncate">{{ $fn }}</span>
|
||||||
|
@endif
|
||||||
|
@if($inApp)
|
||||||
|
<span class="ml-auto shrink-0 inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium bg-indigo-50 text-indigo-600">app</span>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
<div x-show="expandedFrames[{{ $i }}]" x-cloak class="px-6 pb-3">
|
||||||
|
@if($context || !empty($preContext) || !empty($postContext))
|
||||||
|
<pre class="bg-gray-900 rounded-md p-3 text-xs text-gray-300 overflow-x-auto"><code>@foreach($preContext as $ci => $line)
|
||||||
|
<span class="text-gray-600">{{ ($lineno ?? 0) - count($preContext) + $ci }} {{ $line }}</span>
|
||||||
|
@endforeach
|
||||||
|
@if($context)<span class="text-white bg-red-900/40 block -mx-3 px-3">{{ $lineno }} {{ $context }}</span>@endif
|
||||||
|
@foreach($postContext as $ci => $line)
|
||||||
|
<span class="text-gray-600">{{ ($lineno ?? 0) + $ci + 1 }} {{ $line }}</span>
|
||||||
|
@endforeach</code></pre>
|
||||||
|
@else
|
||||||
|
<p class="text-xs text-gray-400 italic">No source context available.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Breadcrumbs --}}
|
||||||
|
@php
|
||||||
|
$crumbs = $event->breadcrumbs['values'] ?? $event->breadcrumbs ?? [];
|
||||||
|
if (!is_array($crumbs)) $crumbs = [];
|
||||||
|
@endphp
|
||||||
|
@if(count($crumbs) > 0)
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Breadcrumbs</h3>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100 max-h-80 overflow-y-auto">
|
||||||
|
@foreach($crumbs as $crumb)
|
||||||
|
<div class="px-6 py-2 flex items-start gap-4 text-sm">
|
||||||
|
<span class="shrink-0 text-xs text-gray-400 font-mono w-20 pt-0.5">
|
||||||
|
{{ isset($crumb['timestamp']) ? \Carbon\Carbon::parse($crumb['timestamp'])->format('H:i:s') : '' }}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 w-20 text-xs font-medium text-gray-600 truncate">{{ $crumb['category'] ?? $crumb['type'] ?? 'default' }}</span>
|
||||||
|
<span class="text-gray-800 flex-1 truncate">{{ $crumb['message'] ?? json_encode($crumb['data'] ?? '') }}</span>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Tags --}}
|
||||||
|
@if(!empty($event->tags))
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Tags</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach($event->tags as $key => $value)
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-gray-100 text-sm">
|
||||||
|
<span class="font-medium text-gray-700">{{ $key }}</span>
|
||||||
|
<span class="text-gray-400">=</span>
|
||||||
|
<span class="text-gray-600">{{ $value }}</span>
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Contexts --}}
|
||||||
|
@php
|
||||||
|
$contexts = $event->contexts ?? [];
|
||||||
|
$userCtx = $event->user_context;
|
||||||
|
$requestCtx = $event->request_context;
|
||||||
|
@endphp
|
||||||
|
@if(!empty($contexts) || $userCtx || $requestCtx)
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
@if($requestCtx)
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 mb-3">Request</h3>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
@foreach($requestCtx as $k => $v)
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">{{ ucfirst($k) }}</dt>
|
||||||
|
<dd class="text-gray-900 mt-0.5">
|
||||||
|
@if(is_string($v) || is_numeric($v))
|
||||||
|
<span class="font-mono text-xs">{{ $v }}</span>
|
||||||
|
@else
|
||||||
|
<pre class="bg-gray-50 rounded p-2 text-xs overflow-x-auto mt-1">{{ json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($userCtx)
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 mb-3">User</h3>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
@foreach($userCtx as $k => $v)
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="font-medium text-gray-500 w-20">{{ ucfirst($k) }}</dt>
|
||||||
|
<dd class="text-gray-900">{{ is_string($v) ? $v : json_encode($v) }}</dd>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@foreach($contexts as $ctxName => $ctxData)
|
||||||
|
@if(is_array($ctxData))
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 mb-3">{{ ucfirst(str_replace('_', ' ', $ctxName)) }}</h3>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
@foreach($ctxData as $k => $v)
|
||||||
|
@if($k !== 'type')
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="font-medium text-gray-500 w-28 shrink-0">{{ ucfirst(str_replace('_', ' ', $k)) }}</dt>
|
||||||
|
<dd class="text-gray-900 truncate">{{ is_string($v) || is_numeric($v) ? $v : json_encode($v) }}</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Raw JSON --}}
|
||||||
|
<div x-data="{ expanded: false }" class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<button @click="expanded = !expanded" class="w-full p-6 flex items-center justify-between text-left hover:bg-gray-50 transition">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Raw JSON</h3>
|
||||||
|
<svg :class="expanded ? 'rotate-180' : ''" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="expanded" x-cloak class="px-6 pb-6">
|
||||||
|
<pre class="bg-gray-900 rounded-md p-4 text-xs text-gray-300 overflow-x-auto max-h-[600px]"><code>{{ json_encode($event->raw_payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
@php($currentProject = $project)
|
||||||
|
|
||||||
|
<x-slot name="header">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ $project->name }} — Issues</h2>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-8" x-data="{ selected: [], selectAll: false }">
|
||||||
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-4">
|
||||||
|
|
||||||
|
@if(session('status'))
|
||||||
|
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md text-sm">
|
||||||
|
{{ session('status') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Filters --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-4">
|
||||||
|
<form method="GET" action="{{ route('projects.issues.index', $project) }}" class="flex flex-wrap items-end gap-4">
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<x-input-label for="search" value="Search" />
|
||||||
|
<x-text-input id="search" name="search" type="text" class="mt-1 block w-full" :value="$search" placeholder="Search issues..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<x-input-label for="status" value="Status" />
|
||||||
|
<select id="status" name="status" class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm text-sm">
|
||||||
|
<option value="">All</option>
|
||||||
|
@foreach(['unresolved', 'resolved', 'ignored', 'muted'] as $s)
|
||||||
|
<option value="{{ $s }}" {{ $currentStatus === $s ? 'selected' : '' }}>{{ ucfirst($s) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<x-input-label for="level" value="Level" />
|
||||||
|
<select id="level" name="level" class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm text-sm">
|
||||||
|
<option value="">All</option>
|
||||||
|
@foreach(['fatal', 'error', 'warning', 'info', 'debug'] as $l)
|
||||||
|
<option value="{{ $l }}" {{ $currentLevel === $l ? 'selected' : '' }}>{{ ucfirst($l) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<x-primary-button>Filter</x-primary-button>
|
||||||
|
@if($search || $currentLevel || $currentStatus !== 'unresolved')
|
||||||
|
<a href="{{ route('projects.issues.index', $project) }}" class="text-sm text-gray-500 hover:text-gray-700">Reset</a>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Bulk Actions --}}
|
||||||
|
<div x-show="selected.length > 0" x-cloak class="bg-indigo-50 border border-indigo-200 rounded-md p-3 flex items-center gap-4">
|
||||||
|
<span class="text-sm font-medium text-indigo-800" x-text="selected.length + ' selected'"></span>
|
||||||
|
<form method="POST" action="{{ route('projects.issues.bulk', $project) }}" class="flex gap-2">
|
||||||
|
@csrf
|
||||||
|
<template x-for="id in selected" :key="id">
|
||||||
|
<input type="hidden" name="issues[]" :value="id">
|
||||||
|
</template>
|
||||||
|
<button type="submit" name="action" value="resolve" class="px-3 py-1 text-xs font-medium rounded-md bg-green-100 text-green-800 hover:bg-green-200 transition">Resolve</button>
|
||||||
|
<button type="submit" name="action" value="ignore" class="px-3 py-1 text-xs font-medium rounded-md bg-yellow-100 text-yellow-800 hover:bg-yellow-200 transition">Ignore</button>
|
||||||
|
<button type="submit" name="action" value="mute" class="px-3 py-1 text-xs font-medium rounded-md bg-gray-100 text-gray-800 hover:bg-gray-200 transition">Mute</button>
|
||||||
|
<button type="submit" name="action" value="delete" class="px-3 py-1 text-xs font-medium rounded-md bg-red-100 text-red-800 hover:bg-red-200 transition"
|
||||||
|
onclick="return confirm('Delete selected issues permanently?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Issues Table --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
@if($issues->isEmpty())
|
||||||
|
<div class="p-12 text-center text-gray-500">
|
||||||
|
No issues found.
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 w-10">
|
||||||
|
<input type="checkbox" x-model="selectAll"
|
||||||
|
@change="selectAll ? selected = {{ json_encode($issues->pluck('id')) }} : selected = []"
|
||||||
|
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-20">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Issue</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase w-24">Events</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase w-40">Last Seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
@foreach($issues as $issue)
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<input type="checkbox" :value="'{{ $issue->id }}'" x-model="selected"
|
||||||
|
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
@php
|
||||||
|
$statusColors = [
|
||||||
|
'unresolved' => 'bg-red-100 text-red-800',
|
||||||
|
'resolved' => 'bg-green-100 text-green-800',
|
||||||
|
'ignored' => 'bg-yellow-100 text-yellow-800',
|
||||||
|
'muted' => 'bg-gray-100 text-gray-600',
|
||||||
|
];
|
||||||
|
$levelColors = [
|
||||||
|
'fatal' => 'bg-red-600',
|
||||||
|
'error' => 'bg-red-400',
|
||||||
|
'warning' => 'bg-yellow-400',
|
||||||
|
'info' => 'bg-blue-400',
|
||||||
|
'debug' => 'bg-gray-400',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium {{ $statusColors[$issue->status] ?? 'bg-gray-100 text-gray-600' }}">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full {{ $levelColors[$issue->level] ?? 'bg-gray-400' }}"></span>
|
||||||
|
{{ ucfirst($issue->status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<a href="{{ route('projects.issues.show', [$project, $issue]) }}" class="block">
|
||||||
|
<div class="text-sm font-medium text-gray-900 truncate max-w-lg">{{ $issue->title }}</div>
|
||||||
|
@if($issue->culprit)
|
||||||
|
<div class="text-xs text-gray-500 truncate max-w-lg mt-0.5">{{ $issue->culprit }}</div>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right text-sm text-gray-700 font-mono">
|
||||||
|
{{ number_format($issue->times_seen) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right text-sm text-gray-500" title="{{ $issue->last_seen_at }}">
|
||||||
|
{{ $issue->last_seen_at->diffForHumans() }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="px-4 py-3 border-t border-gray-200">
|
||||||
|
{{ $issues->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
@php($currentProject = $project)
|
||||||
|
|
||||||
|
<x-slot name="header">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<a href="{{ route('projects.issues.index', $project) }}" class="text-sm text-gray-500 hover:text-gray-700">← Issues</a>
|
||||||
|
</div>
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight truncate">{{ $issue->title }}</h2>
|
||||||
|
@if($issue->culprit)
|
||||||
|
<p class="text-sm text-gray-500 mt-0.5">{{ $issue->culprit }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0 ml-4">
|
||||||
|
<form method="POST" action="{{ route('projects.issues.status', [$project, $issue]) }}">
|
||||||
|
@csrf
|
||||||
|
@method('PATCH')
|
||||||
|
@if($issue->status === 'unresolved')
|
||||||
|
<button name="action" value="resolve" class="px-3 py-1.5 text-sm font-medium rounded-md bg-green-100 text-green-800 hover:bg-green-200 transition">Resolve</button>
|
||||||
|
<button name="action" value="ignore" class="px-3 py-1.5 text-sm font-medium rounded-md bg-yellow-100 text-yellow-800 hover:bg-yellow-200 transition">Ignore</button>
|
||||||
|
@elseif($issue->status === 'resolved')
|
||||||
|
<button name="action" value="unresolve" class="px-3 py-1.5 text-sm font-medium rounded-md bg-red-100 text-red-800 hover:bg-red-200 transition">Unresolve</button>
|
||||||
|
@else
|
||||||
|
<button name="action" value="unresolve" class="px-3 py-1.5 text-sm font-medium rounded-md bg-red-100 text-red-800 hover:bg-red-200 transition">Unresolve</button>
|
||||||
|
<button name="action" value="resolve" class="px-3 py-1.5 text-sm font-medium rounded-md bg-green-100 text-green-800 hover:bg-green-200 transition">Resolve</button>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-8">
|
||||||
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||||
|
|
||||||
|
@if(session('status'))
|
||||||
|
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md text-sm">
|
||||||
|
{{ session('status') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Issue Meta --}}
|
||||||
|
@php
|
||||||
|
$statusColors = [
|
||||||
|
'unresolved' => 'bg-red-100 text-red-800',
|
||||||
|
'resolved' => 'bg-green-100 text-green-800',
|
||||||
|
'ignored' => 'bg-yellow-100 text-yellow-800',
|
||||||
|
'muted' => 'bg-gray-100 text-gray-600',
|
||||||
|
];
|
||||||
|
$levelColors = [
|
||||||
|
'fatal' => 'bg-red-100 text-red-800',
|
||||||
|
'error' => 'bg-red-50 text-red-700',
|
||||||
|
'warning' => 'bg-yellow-50 text-yellow-700',
|
||||||
|
'info' => 'bg-blue-50 text-blue-700',
|
||||||
|
'debug' => 'bg-gray-100 text-gray-600',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<div class="flex flex-wrap gap-6 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Status</span>
|
||||||
|
<div class="mt-1">
|
||||||
|
<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium {{ $statusColors[$issue->status] ?? '' }}">
|
||||||
|
{{ ucfirst($issue->status) }}
|
||||||
|
@if($issue->is_regression)
|
||||||
|
(regression)
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Level</span>
|
||||||
|
<div class="mt-1">
|
||||||
|
<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium {{ $levelColors[$issue->level] ?? '' }}">
|
||||||
|
{{ ucfirst($issue->level) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Events</span>
|
||||||
|
<div class="mt-1 font-mono font-medium text-gray-900">{{ number_format($issue->times_seen) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">First Seen</span>
|
||||||
|
<div class="mt-1 text-gray-900" title="{{ $issue->first_seen_at }}">{{ $issue->first_seen_at->diffForHumans() }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Last Seen</span>
|
||||||
|
<div class="mt-1 text-gray-900" title="{{ $issue->last_seen_at }}">{{ $issue->last_seen_at->diffForHumans() }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($latestEvent)
|
||||||
|
{{-- Stacktrace --}}
|
||||||
|
@if(!empty($latestEvent->exception['values']))
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Stacktrace</h3>
|
||||||
|
</div>
|
||||||
|
@foreach($latestEvent->exception['values'] as $exc)
|
||||||
|
<div class="border-b border-gray-100 last:border-b-0">
|
||||||
|
<div class="px-6 py-3 bg-gray-50">
|
||||||
|
<span class="font-mono text-sm font-semibold text-red-700">{{ $exc['type'] ?? 'Exception' }}</span>
|
||||||
|
@if(!empty($exc['value']))
|
||||||
|
<span class="text-sm text-gray-600 ml-2">{{ $exc['value'] }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if(!empty($exc['stacktrace']['frames']))
|
||||||
|
<div x-data="{ expandedFrames: {} }" class="divide-y divide-gray-100">
|
||||||
|
@foreach(array_reverse($exc['stacktrace']['frames']) as $i => $frame)
|
||||||
|
@php
|
||||||
|
$file = $frame['filename'] ?? $frame['abs_path'] ?? '?';
|
||||||
|
$fn = $frame['function'] ?? null;
|
||||||
|
$lineno = $frame['lineno'] ?? null;
|
||||||
|
$inApp = $frame['in_app'] ?? false;
|
||||||
|
$context = $frame['context_line'] ?? null;
|
||||||
|
$preContext = $frame['pre_context'] ?? [];
|
||||||
|
$postContext = $frame['post_context'] ?? [];
|
||||||
|
@endphp
|
||||||
|
<div class="{{ $inApp ? 'bg-white' : 'bg-gray-50/50' }}">
|
||||||
|
<button @click="expandedFrames[{{ $i }}] = !expandedFrames[{{ $i }}]"
|
||||||
|
class="w-full px-6 py-2 text-left text-sm flex items-center gap-2 hover:bg-gray-50 transition">
|
||||||
|
<svg :class="expandedFrames[{{ $i }}] ? 'rotate-90' : ''" class="w-3 h-3 text-gray-400 transition-transform shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M6 4l8 6-8 6V4z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono {{ $inApp ? 'text-gray-900 font-medium' : 'text-gray-500' }} truncate">
|
||||||
|
{{ $file }}@if($lineno):{{ $lineno }}@endif
|
||||||
|
</span>
|
||||||
|
@if($fn)
|
||||||
|
<span class="text-gray-400 shrink-0">in</span>
|
||||||
|
<span class="font-mono {{ $inApp ? 'text-indigo-700' : 'text-gray-500' }} truncate">{{ $fn }}</span>
|
||||||
|
@endif
|
||||||
|
@if($inApp)
|
||||||
|
<span class="ml-auto shrink-0 inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium bg-indigo-50 text-indigo-600">app</span>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
<div x-show="expandedFrames[{{ $i }}]" x-cloak class="px-6 pb-3">
|
||||||
|
@if($context || !empty($preContext) || !empty($postContext))
|
||||||
|
<pre class="bg-gray-900 rounded-md p-3 text-xs text-gray-300 overflow-x-auto"><code>@foreach($preContext as $ci => $line)
|
||||||
|
<span class="text-gray-600">{{ ($lineno ?? 0) - count($preContext) + $ci }} {{ $line }}</span>
|
||||||
|
@endforeach
|
||||||
|
@if($context)<span class="text-white bg-red-900/40 block -mx-3 px-3">{{ $lineno }} {{ $context }}</span>@endif
|
||||||
|
@foreach($postContext as $ci => $line)
|
||||||
|
<span class="text-gray-600">{{ ($lineno ?? 0) + $ci + 1 }} {{ $line }}</span>
|
||||||
|
@endforeach</code></pre>
|
||||||
|
@elseif(!empty($frame['vars']))
|
||||||
|
<pre class="bg-gray-900 rounded-md p-3 text-xs text-gray-300 overflow-x-auto"><code>{{ json_encode($frame['vars'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</code></pre>
|
||||||
|
@else
|
||||||
|
<p class="text-xs text-gray-400 italic">No source context available.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Breadcrumbs --}}
|
||||||
|
@if(!empty($latestEvent->breadcrumbs['values'] ?? $latestEvent->breadcrumbs))
|
||||||
|
@php
|
||||||
|
$crumbs = $latestEvent->breadcrumbs['values'] ?? $latestEvent->breadcrumbs;
|
||||||
|
if (!is_array($crumbs)) $crumbs = [];
|
||||||
|
@endphp
|
||||||
|
@if(count($crumbs) > 0)
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Breadcrumbs</h3>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100 max-h-96 overflow-y-auto">
|
||||||
|
@foreach($crumbs as $crumb)
|
||||||
|
<div class="px-6 py-2 flex items-start gap-4 text-sm">
|
||||||
|
<span class="shrink-0 text-xs text-gray-400 font-mono w-20 pt-0.5">
|
||||||
|
{{ isset($crumb['timestamp']) ? \Carbon\Carbon::parse($crumb['timestamp'])->format('H:i:s') : '' }}
|
||||||
|
</span>
|
||||||
|
@php
|
||||||
|
$catColor = match($crumb['type'] ?? $crumb['category'] ?? '') {
|
||||||
|
'error' => 'text-red-600',
|
||||||
|
'warning' => 'text-yellow-600',
|
||||||
|
'http', 'navigation' => 'text-blue-600',
|
||||||
|
default => 'text-gray-600',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<span class="shrink-0 w-20 text-xs font-medium {{ $catColor }} truncate">{{ $crumb['category'] ?? $crumb['type'] ?? 'default' }}</span>
|
||||||
|
<span class="text-gray-800 flex-1 truncate">{{ $crumb['message'] ?? json_encode($crumb['data'] ?? '') }}</span>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Tags --}}
|
||||||
|
@if(!empty($latestEvent->tags))
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Tags</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach($latestEvent->tags as $key => $value)
|
||||||
|
<a href="{{ route('projects.issues.index', [$project, 'search' => $key . ':' . $value]) }}"
|
||||||
|
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-gray-100 hover:bg-gray-200 text-sm transition">
|
||||||
|
<span class="font-medium text-gray-700">{{ $key }}</span>
|
||||||
|
<span class="text-gray-400">=</span>
|
||||||
|
<span class="text-gray-600">{{ $value }}</span>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Context Panels --}}
|
||||||
|
@php
|
||||||
|
$contexts = $latestEvent->contexts ?? [];
|
||||||
|
$userCtx = $latestEvent->user_context;
|
||||||
|
$requestCtx = $latestEvent->request_context;
|
||||||
|
@endphp
|
||||||
|
@if(!empty($contexts) || $userCtx || $requestCtx)
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
@if($requestCtx)
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 mb-3">Request</h3>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
@if(!empty($requestCtx['method']))
|
||||||
|
<div class="flex gap-2"><dt class="font-medium text-gray-500 w-20">Method</dt><dd class="text-gray-900 font-mono">{{ $requestCtx['method'] }}</dd></div>
|
||||||
|
@endif
|
||||||
|
@if(!empty($requestCtx['url']))
|
||||||
|
<div class="flex gap-2"><dt class="font-medium text-gray-500 w-20">URL</dt><dd class="text-gray-900 font-mono truncate">{{ $requestCtx['url'] }}</dd></div>
|
||||||
|
@endif
|
||||||
|
@if(!empty($requestCtx['headers']))
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500 mb-1">Headers</dt>
|
||||||
|
<dd><pre class="bg-gray-50 rounded p-2 text-xs overflow-x-auto">{{ json_encode($requestCtx['headers'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre></dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($userCtx)
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 mb-3">User</h3>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
@foreach($userCtx as $k => $v)
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="font-medium text-gray-500 w-20">{{ ucfirst($k) }}</dt>
|
||||||
|
<dd class="text-gray-900">{{ is_string($v) ? $v : json_encode($v) }}</dd>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@foreach($contexts as $ctxName => $ctxData)
|
||||||
|
@if(is_array($ctxData))
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 mb-3">{{ ucfirst(str_replace('_', ' ', $ctxName)) }}</h3>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
@foreach($ctxData as $k => $v)
|
||||||
|
@if($k !== 'type')
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="font-medium text-gray-500 w-28 shrink-0">{{ ucfirst(str_replace('_', ' ', $k)) }}</dt>
|
||||||
|
<dd class="text-gray-900 truncate">{{ is_string($v) || is_numeric($v) ? $v : json_encode($v) }}</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Extra Data --}}
|
||||||
|
@if(!empty($latestEvent->extra))
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Extra Data</h3>
|
||||||
|
<pre class="bg-gray-50 rounded-md p-4 text-sm text-gray-800 overflow-x-auto">{{ json_encode($latestEvent->extra, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Events List --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Events</h3>
|
||||||
|
</div>
|
||||||
|
@if($events->isEmpty())
|
||||||
|
<div class="p-6 text-sm text-gray-500">No events recorded.</div>
|
||||||
|
@else
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Event ID</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Environment</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Release</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
@foreach($events as $ev)
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<a href="{{ route('projects.issues.events.show', [$project, $issue, $ev]) }}" class="text-indigo-600 hover:text-indigo-900 font-mono text-sm">
|
||||||
|
{{ Str::limit($ev->event_id, 16) }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-500">{{ $ev->environment ?? '—' }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-500 font-mono">{{ $ev->release ?? '—' }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-500 text-right" title="{{ $ev->timestamp }}">{{ $ev->timestamp->diffForHumans() }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="px-6 py-3 border-t border-gray-200">
|
||||||
|
{{ $events->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -1,58 +1,81 @@
|
|||||||
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
|
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
|
||||||
<!-- Primary Navigation Menu -->
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between h-16">
|
<div class="flex justify-between h-16">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<!-- Logo -->
|
|
||||||
<div class="shrink-0 flex items-center">
|
<div class="shrink-0 flex items-center">
|
||||||
<a href="{{ route('dashboard') }}">
|
<a href="{{ route('dashboard') }}" class="text-lg font-bold text-gray-800">
|
||||||
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
|
ErrorTracker
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation Links -->
|
|
||||||
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||||
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||||
{{ __('Dashboard') }}
|
Dashboard
|
||||||
</x-nav-link>
|
</x-nav-link>
|
||||||
|
|
||||||
|
<x-nav-link :href="route('projects.index')" :active="request()->routeIs('projects.*')">
|
||||||
|
Projects
|
||||||
|
</x-nav-link>
|
||||||
|
|
||||||
|
@if(isset($currentProject))
|
||||||
|
<x-nav-link :href="route('projects.issues.index', $currentProject)" :active="request()->routeIs('projects.issues.*')">
|
||||||
|
Issues
|
||||||
|
</x-nav-link>
|
||||||
|
<x-nav-link :href="route('projects.settings', $currentProject)" :active="request()->routeIs('projects.settings')">
|
||||||
|
Settings
|
||||||
|
</x-nav-link>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Dropdown -->
|
<div class="hidden sm:flex sm:items-center sm:ms-6 sm:gap-4">
|
||||||
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
@php
|
||||||
|
$userProjects = Auth::user()->projects()->orderBy('name')->get();
|
||||||
|
@endphp
|
||||||
|
@if($userProjects->count() > 0)
|
||||||
|
<x-dropdown align="right" width="48">
|
||||||
|
<x-slot name="trigger">
|
||||||
|
<button class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 bg-gray-50 rounded-md hover:bg-gray-100 transition">
|
||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>
|
||||||
|
{{ isset($currentProject) ? $currentProject->name : 'Select Project' }}
|
||||||
|
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
||||||
|
</button>
|
||||||
|
</x-slot>
|
||||||
|
<x-slot name="content">
|
||||||
|
@foreach($userProjects as $p)
|
||||||
|
<x-dropdown-link :href="route('projects.issues.index', $p)">
|
||||||
|
{{ $p->name }}
|
||||||
|
</x-dropdown-link>
|
||||||
|
@endforeach
|
||||||
|
<hr class="my-1">
|
||||||
|
<x-dropdown-link :href="route('projects.create')">
|
||||||
|
+ New Project
|
||||||
|
</x-dropdown-link>
|
||||||
|
</x-slot>
|
||||||
|
</x-dropdown>
|
||||||
|
@endif
|
||||||
|
|
||||||
<x-dropdown align="right" width="48">
|
<x-dropdown align="right" width="48">
|
||||||
<x-slot name="trigger">
|
<x-slot name="trigger">
|
||||||
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
|
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
|
||||||
<div>{{ Auth::user()->name }}</div>
|
<div>{{ Auth::user()->name }}</div>
|
||||||
|
|
||||||
<div class="ms-1">
|
<div class="ms-1">
|
||||||
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
||||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="content">
|
<x-slot name="content">
|
||||||
<x-dropdown-link :href="route('profile.edit')">
|
<x-dropdown-link :href="route('profile.edit')">Profile</x-dropdown-link>
|
||||||
{{ __('Profile') }}
|
|
||||||
</x-dropdown-link>
|
|
||||||
|
|
||||||
<!-- Authentication -->
|
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
<x-dropdown-link :href="route('logout')" onclick="event.preventDefault(); this.closest('form').submit();">
|
||||||
<x-dropdown-link :href="route('logout')"
|
Log Out
|
||||||
onclick="event.preventDefault();
|
|
||||||
this.closest('form').submit();">
|
|
||||||
{{ __('Log Out') }}
|
|
||||||
</x-dropdown-link>
|
</x-dropdown-link>
|
||||||
</form>
|
</form>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
</x-dropdown>
|
</x-dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hamburger -->
|
|
||||||
<div class="-me-2 flex items-center sm:hidden">
|
<div class="-me-2 flex items-center sm:hidden">
|
||||||
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
|
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
|
||||||
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||||
@@ -64,34 +87,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Responsive Navigation Menu -->
|
|
||||||
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
|
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
|
||||||
<div class="pt-2 pb-3 space-y-1">
|
<div class="pt-2 pb-3 space-y-1">
|
||||||
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||||
{{ __('Dashboard') }}
|
Dashboard
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('projects.index')" :active="request()->routeIs('projects.*')">
|
||||||
|
Projects
|
||||||
</x-responsive-nav-link>
|
</x-responsive-nav-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Responsive Settings Options -->
|
|
||||||
<div class="pt-4 pb-1 border-t border-gray-200">
|
<div class="pt-4 pb-1 border-t border-gray-200">
|
||||||
<div class="px-4">
|
<div class="px-4">
|
||||||
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
|
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
|
||||||
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
|
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 space-y-1">
|
<div class="mt-3 space-y-1">
|
||||||
<x-responsive-nav-link :href="route('profile.edit')">
|
<x-responsive-nav-link :href="route('profile.edit')">Profile</x-responsive-nav-link>
|
||||||
{{ __('Profile') }}
|
|
||||||
</x-responsive-nav-link>
|
|
||||||
|
|
||||||
<!-- Authentication -->
|
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
<x-responsive-nav-link :href="route('logout')" onclick="event.preventDefault(); this.closest('form').submit();">
|
||||||
<x-responsive-nav-link :href="route('logout')"
|
Log Out
|
||||||
onclick="event.preventDefault();
|
|
||||||
this.closest('form').submit();">
|
|
||||||
{{ __('Log Out') }}
|
|
||||||
</x-responsive-nav-link>
|
</x-responsive-nav-link>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header">
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">New Project</h2>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-8">
|
||||||
|
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<form method="POST" action="{{ route('projects.store') }}" class="space-y-6">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-input-label for="name" value="Project Name" />
|
||||||
|
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name')" required autofocus />
|
||||||
|
<x-input-error :messages="$errors->get('name')" class="mt-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-input-label for="platform" value="Platform" />
|
||||||
|
<select id="platform" name="platform" class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||||
|
<option value="">Select a platform...</option>
|
||||||
|
@foreach(['php', 'javascript', 'python', 'ruby', 'java', 'go', 'rust', 'csharp', 'node', 'other'] as $plat)
|
||||||
|
<option value="{{ $plat }}" {{ old('platform') === $plat ? 'selected' : '' }}>{{ ucfirst($plat) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<x-input-error :messages="$errors->get('platform')" class="mt-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<x-primary-button>Create Project</x-primary-button>
|
||||||
|
<a href="{{ route('projects.index') }}" class="text-sm text-gray-600 hover:text-gray-900">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Projects</h2>
|
||||||
|
<a href="{{ route('projects.create') }}" class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 transition">
|
||||||
|
New Project
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-8">
|
||||||
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||||
|
|
||||||
|
@if(session('status'))
|
||||||
|
<div class="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md text-sm">
|
||||||
|
{{ session('status') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($projects->isEmpty())
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-12 text-center">
|
||||||
|
<p class="text-gray-500 mb-4">You don't have any projects yet.</p>
|
||||||
|
<a href="{{ route('projects.create') }}" class="text-indigo-600 hover:underline font-medium">Create your first project</a>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
@foreach($projects as $project)
|
||||||
|
<a href="{{ route('projects.issues.index', $project) }}" class="block bg-white overflow-hidden shadow-sm sm:rounded-lg hover:shadow-md transition">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">{{ $project->name }}</h3>
|
||||||
|
@if($project->platform)
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
||||||
|
{{ $project->platform }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ $project->issues_count }} unresolved {{ Str::plural('issue', $project->issues_count) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
@php($currentProject = $project)
|
||||||
|
|
||||||
|
<x-slot name="header">
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ $project->name }} — Settings</h2>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-8">
|
||||||
|
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||||
|
|
||||||
|
@if(session('status'))
|
||||||
|
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md text-sm">
|
||||||
|
{{ session('status') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- DSN --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Client DSN</h3>
|
||||||
|
<p class="text-sm text-gray-500 mb-3">Use this DSN in your Sentry SDK configuration to send events to this project.</p>
|
||||||
|
<div x-data="{ copied: false }" class="flex items-center gap-2">
|
||||||
|
<code class="flex-1 bg-gray-50 border border-gray-200 rounded-md px-4 py-3 text-sm font-mono text-gray-800 break-all select-all">{{ $project->dsn }}</code>
|
||||||
|
<button @click="navigator.clipboard.writeText('{{ $project->dsn }}'); copied = true; setTimeout(() => copied = false, 2000)"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-700 hover:bg-gray-50 transition"
|
||||||
|
type="button">
|
||||||
|
<span x-show="!copied">Copy</span>
|
||||||
|
<span x-show="copied" x-cloak>Copied!</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ route('projects.regenerate-keys', $project) }}" class="mt-4"
|
||||||
|
onsubmit="return confirm('Regenerating keys will invalidate the current DSN. Continue?')">
|
||||||
|
@csrf
|
||||||
|
@method('PATCH')
|
||||||
|
<x-secondary-button type="submit">Regenerate Keys</x-secondary-button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Update Project --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Project Details</h3>
|
||||||
|
<form method="POST" action="{{ route('projects.update', $project) }}" class="space-y-6">
|
||||||
|
@csrf
|
||||||
|
@method('PATCH')
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-input-label for="name" value="Project Name" />
|
||||||
|
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $project->name)" required />
|
||||||
|
<x-input-error :messages="$errors->get('name')" class="mt-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-input-label for="platform" value="Platform" />
|
||||||
|
<select id="platform" name="platform" class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||||
|
<option value="">Select a platform...</option>
|
||||||
|
@foreach(['php', 'javascript', 'python', 'ruby', 'java', 'go', 'rust', 'csharp', 'node', 'other'] as $plat)
|
||||||
|
<option value="{{ $plat }}" {{ old('platform', $project->platform) === $plat ? 'selected' : '' }}>{{ ucfirst($plat) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<x-input-error :messages="$errors->get('platform')" class="mt-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-primary-button>Save Changes</x-primary-button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Delete Project --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium text-red-600 mb-2">Danger Zone</h3>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">Deleting a project will permanently remove all its issues and events.</p>
|
||||||
|
<form method="POST" action="{{ route('projects.destroy', $project) }}"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete this project? This action cannot be undone.')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<x-danger-button>Delete Project</x-danger-button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
+22
-3
@@ -1,15 +1,34 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\DashboardController;
|
||||||
|
use App\Http\Controllers\EventController;
|
||||||
|
use App\Http\Controllers\IssueController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
|
use App\Http\Controllers\ProjectController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return view('welcome');
|
return view('welcome');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/dashboard', function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
return view('dashboard');
|
Route::get('/dashboard', DashboardController::class)->name('dashboard');
|
||||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
|
||||||
|
Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
|
||||||
|
Route::get('/projects/create', [ProjectController::class, 'create'])->name('projects.create');
|
||||||
|
Route::post('/projects', [ProjectController::class, 'store'])->name('projects.store');
|
||||||
|
Route::get('/projects/{project}/settings', [ProjectController::class, 'settings'])->name('projects.settings');
|
||||||
|
Route::patch('/projects/{project}', [ProjectController::class, 'update'])->name('projects.update');
|
||||||
|
Route::patch('/projects/{project}/regenerate-keys', [ProjectController::class, 'regenerateKeys'])->name('projects.regenerate-keys');
|
||||||
|
Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])->name('projects.destroy');
|
||||||
|
|
||||||
|
Route::get('/projects/{project}/issues', [IssueController::class, 'index'])->name('projects.issues.index');
|
||||||
|
Route::post('/projects/{project}/issues/bulk', [IssueController::class, 'bulkAction'])->name('projects.issues.bulk');
|
||||||
|
Route::get('/projects/{project}/issues/{issue}', [IssueController::class, 'show'])->name('projects.issues.show');
|
||||||
|
Route::patch('/projects/{project}/issues/{issue}/status', [IssueController::class, 'updateStatus'])->name('projects.issues.status');
|
||||||
|
|
||||||
|
Route::get('/projects/{project}/issues/{issue}/events/{event}', [EventController::class, 'show'])->name('projects.issues.events.show');
|
||||||
|
});
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||||
|
|||||||
Reference in New Issue
Block a user