phase 5
This commit is contained in:
@@ -2,6 +2,20 @@
|
||||
|
||||
A self-hosted error tracking application (inspired by [Bugsink](https://www.bugsink.com/) / Sentry) built on this Laravel 13 app.
|
||||
|
||||
## Overview
|
||||
|
||||
This is an **error tracking application similar to Sentry**, designed to receive and aggregate errors from applications running in production. Users integrate SDKs into their applications (PHP, JavaScript, Python, etc.), and those SDKs send error events **directly** to our tracking server in real-time when errors occur.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **SDK Integration**: Developers install a Sentry-compatible SDK in their application (e.g., `sentry/sentry-laravel` for Laravel, `@sentry/browser` for JavaScript, `sentry-python` for Python).
|
||||
2. **Configuration**: They configure the SDK with a DSN (Data Source Name) pointing to our tracking server.
|
||||
3. **Automatic Error Capture**: When an error occurs in their application, the SDK automatically captures the error context (stacktrace, breadcrumbs, user info, request details) and sends it to our server.
|
||||
4. **Real-Time Processing**: Our server receives the error payload, groups similar errors into issues, and displays them in a web dashboard.
|
||||
5. **Notifications**: Teams are notified when new issues occur or when resolved issues regress.
|
||||
|
||||
This architecture means we don't need to build custom SDKs — we leverage the entire ecosystem of existing Sentry SDKs by implementing a Sentry-compatible ingestion API.
|
||||
|
||||
## Stack Summary
|
||||
|
||||
| Layer | Technology |
|
||||
@@ -12,6 +26,52 @@ A self-hosted error tracking application (inspired by [Bugsink](https://www.bugs
|
||||
| Queue | Database driver (upgrade to Redis for production) |
|
||||
| Auth | Laravel Breeze (installed) |
|
||||
|
||||
## SDK Support & Multi-Language Integration
|
||||
|
||||
### Supported Languages & Frameworks
|
||||
|
||||
Our tracking server accepts error events from **any Sentry-compatible SDK**, enabling broad language and framework support without custom development:
|
||||
|
||||
| Language/Platform | Official Sentry SDK | Notes |
|
||||
|-------------------|---------------------|-------|
|
||||
| **PHP** | `sentry/sentry-laravel`, `sentry/sentry-symfony`, `sentry/sentry` | Full Laravel, Symfony, and native PHP support |
|
||||
| **JavaScript/Node.js** | `@sentry/browser`, `@sentry/node`, `@sentry/react`, `@sentry/vue`, `@sentry/angular` | Frontend and backend JS, plus framework-specific integrations |
|
||||
| **Python** | `sentry-sdk` | Django, Flask, FastAPI, etc. |
|
||||
| **Ruby** | `sentry-ruby`, `sentry-rails` | Rails and native Ruby support |
|
||||
| **Java** | `io.sentry:sentry` | Spring, Android, and JVM languages |
|
||||
| **Go** | `github.com/getsentry/sentry-go` | Native Go support |
|
||||
| **.NET/C#** | `Sentry.AspNetCore`, `Sentry` | ASP.NET Core, .NET Framework |
|
||||
| **Mobile** | `@sentry/react-native`, `sentry-android`, `sentry-cocoa` | React Native, Android (Java/Kotlin), iOS (Swift/Obj-C) |
|
||||
| **Rust** | `sentry` | Native Rust support |
|
||||
| **Elixir** | `sentry` | Phoenix and Elixir support |
|
||||
|
||||
### SDK Configuration Example
|
||||
|
||||
Users simply point the official Sentry SDK to our server:
|
||||
|
||||
**PHP (Laravel):**
|
||||
```php
|
||||
// config/sentry.php
|
||||
'dsn' => 'https://<public_key>@trackbugs.example.com/api/<project_id>/store/',
|
||||
```
|
||||
|
||||
**JavaScript:**
|
||||
```javascript
|
||||
Sentry.init({
|
||||
dsn: 'https://<public_key>@trackbugs.example.com/api/<project_id>/store/',
|
||||
});
|
||||
```
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
import sentry_sdk
|
||||
sentry_sdk.init(
|
||||
dsn="https://<public_key>@trackbugs.example.com/api/<project_id>/store/"
|
||||
)
|
||||
```
|
||||
|
||||
No custom SDK development is required — our server implements the Sentry protocol, so all existing SDKs work out of the box.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Foundation & Auth
|
||||
@@ -206,19 +266,19 @@ Clients configure this DSN in their Sentry SDK — no changes needed on the SDK
|
||||
|
||||
### 5.1 Release Tracking
|
||||
|
||||
- [ ] Parse `release` field from events
|
||||
- [ ] Show which release introduced an issue
|
||||
- [ ] Mark issues as resolved-in-release; auto-regress if they appear in a newer release
|
||||
- [x] Parse `release` field from events
|
||||
- [x] Show which release introduced an issue
|
||||
- [x] Mark issues as resolved-in-release; auto-regress if they appear in a newer release
|
||||
|
||||
### 5.2 Environment Filtering
|
||||
|
||||
- [ ] Global environment selector (production / staging / etc.)
|
||||
- [ ] Filter issues and events by environment
|
||||
- [x] Global environment selector (production / staging / etc.)
|
||||
- [x] Filter issues and events by environment
|
||||
|
||||
### 5.3 Retention & Cleanup
|
||||
|
||||
- [ ] Artisan command / scheduled job to delete events older than N days (configurable)
|
||||
- [ ] Keep issue summaries even after events are pruned
|
||||
- [x] Artisan command / scheduled job to delete events older than N days (configurable)
|
||||
- [x] Keep issue summaries even after events are pruned
|
||||
|
||||
### 5.4 Source Maps / Source Context (optional, stretch)
|
||||
|
||||
@@ -227,8 +287,8 @@ Clients configure this DSN in their Sentry SDK — no changes needed on the SDK
|
||||
|
||||
### 5.5 API Tokens & External API
|
||||
|
||||
- [ ] Personal API tokens for programmatic access
|
||||
- [ ] REST API for listing/resolving issues (useful for CI/CD integrations)
|
||||
- [x] Personal API tokens for programmatic access
|
||||
- [x] REST API for listing/resolving issues (useful for CI/CD integrations)
|
||||
|
||||
---
|
||||
|
||||
@@ -258,14 +318,14 @@ Clients configure this DSN in their Sentry SDK — no changes needed on the SDK
|
||||
|
||||
### Milestone 4 — Production-readiness
|
||||
- [ ] 17. Rate limiting on ingest
|
||||
- [ ] 18. Event retention / cleanup scheduler
|
||||
- [ ] 19. Release tracking
|
||||
- [ ] 20. Environment filtering
|
||||
- [x] 18. Event retention / cleanup scheduler
|
||||
- [x] 19. Release tracking
|
||||
- [x] 20. Environment filtering
|
||||
- [ ] 21. Performance optimization (indexes, query tuning, caching)
|
||||
|
||||
### Milestone 5 — Advanced (stretch)
|
||||
- [ ] 22. Source map upload & processing
|
||||
- [ ] 23. API tokens & external REST API
|
||||
- [x] 23. API tokens & external REST API
|
||||
- [ ] 24. Webhook / Slack notifications
|
||||
- [ ] 25. CQRS-style read models for heavy dashboards (if needed)
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Issue;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
#[Signature('events:cleanup {--days=90 : Delete events older than this many days}')]
|
||||
#[Description('Delete old events to free up database space while keeping issue summaries')]
|
||||
class CleanupOldEvents extends Command
|
||||
{
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
|
||||
if ($days < 1) {
|
||||
$this->error('Days must be at least 1');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$cutoffDate = Carbon::now()->subDays($days);
|
||||
|
||||
$this->info("Deleting events older than {$days} days (before {$cutoffDate->toDateTimeString()})...");
|
||||
|
||||
// Get count of events to be deleted
|
||||
$eventsToDelete = Event::where('created_at', '<', $cutoffDate)->count();
|
||||
|
||||
if ($eventsToDelete === 0) {
|
||||
$this->info('No events to delete.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$this->confirm("This will delete {$eventsToDelete} events. Continue?", true)) {
|
||||
$this->info('Cleanup cancelled.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Delete events in chunks to avoid memory issues
|
||||
$deleted = 0;
|
||||
$bar = $this->output->createProgressBar($eventsToDelete);
|
||||
$bar->start();
|
||||
|
||||
Event::where('created_at', '<', $cutoffDate)
|
||||
->chunkById(1000, function ($events) use (&$deleted, $bar) {
|
||||
foreach ($events as $event) {
|
||||
$event->delete();
|
||||
$deleted++;
|
||||
$bar->advance();
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$this->info("Deleted {$deleted} events.");
|
||||
|
||||
// Update issue counters based on remaining events
|
||||
$this->info('Updating issue counters...');
|
||||
|
||||
$issues = Issue::all();
|
||||
$issuesUpdated = 0;
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$eventsCount = $issue->events()->count();
|
||||
|
||||
if ($eventsCount === 0) {
|
||||
// If no events remain, optionally delete the issue or mark it
|
||||
// For now, we'll keep the issue but mark times_seen as 0
|
||||
$issue->update(['times_seen' => 0]);
|
||||
$issuesUpdated++;
|
||||
} else {
|
||||
// Update counters based on remaining events
|
||||
$firstEvent = $issue->events()->oldest('timestamp')->first();
|
||||
$lastEvent = $issue->events()->latest('timestamp')->first();
|
||||
|
||||
$issue->update([
|
||||
'times_seen' => $eventsCount,
|
||||
'first_seen_at' => $firstEvent?->timestamp ?? $issue->first_seen_at,
|
||||
'last_seen_at' => $lastEvent?->timestamp ?? $issue->last_seen_at,
|
||||
]);
|
||||
$issuesUpdated++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Updated {$issuesUpdated} issues.");
|
||||
$this->info('Cleanup completed successfully!');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Issue;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ApiIssueController extends Controller
|
||||
{
|
||||
/**
|
||||
* List issues for a project
|
||||
*/
|
||||
public function index(Request $request, Project $project): JsonResponse
|
||||
{
|
||||
$this->authorizeProject($request, $project);
|
||||
|
||||
$query = $project->issues();
|
||||
|
||||
// Apply filters
|
||||
if ($status = $request->query('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($level = $request->query('level')) {
|
||||
$query->where('level', $level);
|
||||
}
|
||||
|
||||
if ($environment = $request->query('environment')) {
|
||||
$query->whereHas('events', fn($q) => $q->where('environment', $environment));
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$perPage = min((int) $request->query('per_page', 25), 100);
|
||||
$issues = $query->orderBy('last_seen_at', 'desc')->paginate($perPage);
|
||||
|
||||
return response()->json($issues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific issue
|
||||
*/
|
||||
public function show(Request $request, Project $project, Issue $issue): JsonResponse
|
||||
{
|
||||
$this->authorizeProject($request, $project);
|
||||
abort_unless($issue->project_id === $project->id, 404);
|
||||
|
||||
$issue->load('events');
|
||||
|
||||
return response()->json($issue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update issue status
|
||||
*/
|
||||
public function update(Request $request, Project $project, Issue $issue): JsonResponse
|
||||
{
|
||||
$this->authorizeProject($request, $project);
|
||||
abort_unless($issue->project_id === $project->id, 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:resolved,unresolved,ignored,muted',
|
||||
'resolved_in_release' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($validated['status'] === 'resolved') {
|
||||
$issue->resolve($validated['resolved_in_release'] ?? null);
|
||||
} elseif ($validated['status'] === 'unresolved') {
|
||||
$issue->unresolve();
|
||||
} elseif ($validated['status'] === 'ignored') {
|
||||
$issue->ignore();
|
||||
} elseif ($validated['status'] === 'muted') {
|
||||
$issue->mute();
|
||||
}
|
||||
|
||||
return response()->json($issue->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an issue
|
||||
*/
|
||||
public function destroy(Request $request, Project $project, Issue $issue): JsonResponse
|
||||
{
|
||||
$this->authorizeProject($request, $project);
|
||||
abort_unless($issue->project_id === $project->id, 404);
|
||||
|
||||
$issue->delete();
|
||||
|
||||
return response()->json(['message' => 'Issue deleted successfully']);
|
||||
}
|
||||
|
||||
private function authorizeProject(Request $request, Project $project): void
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless(
|
||||
$user && $user->projects()->where('projects.id', $project->id)->exists(),
|
||||
403,
|
||||
'Unauthorized'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@ class IssueController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
if ($environment = $request->query('environment')) {
|
||||
$query->whereHas('events', function ($q) use ($environment) {
|
||||
$q->where('environment', $environment);
|
||||
});
|
||||
}
|
||||
|
||||
$sortField = $request->query('sort', 'last_seen_at');
|
||||
$sortDir = $request->query('dir', 'desc');
|
||||
$allowedSorts = ['last_seen_at', 'first_seen_at', 'times_seen', 'level'];
|
||||
@@ -42,12 +48,22 @@ class IssueController extends Controller
|
||||
|
||||
$issues = $query->cursorPaginate(25)->withQueryString();
|
||||
|
||||
// Get distinct environments from events for this project
|
||||
$environments = $project->events()
|
||||
->whereNotNull('environment')
|
||||
->distinct()
|
||||
->pluck('environment')
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
return view('issues.index', [
|
||||
'project' => $project,
|
||||
'issues' => $issues,
|
||||
'currentStatus' => $status ?: 'unresolved',
|
||||
'currentLevel' => $level,
|
||||
'currentEnvironment' => $environment,
|
||||
'search' => $search,
|
||||
'environments' => $environments,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -56,18 +72,36 @@ class IssueController extends Controller
|
||||
$this->authorizeProject($request, $project);
|
||||
abort_unless($issue->project_id === $project->id, 404);
|
||||
|
||||
$latestEvent = $issue->events()->latest('timestamp')->first();
|
||||
$environment = $request->query('environment');
|
||||
|
||||
$events = $issue->events()
|
||||
$eventsQuery = $issue->events();
|
||||
|
||||
if ($environment) {
|
||||
$eventsQuery->where('environment', $environment);
|
||||
}
|
||||
|
||||
$latestEvent = (clone $eventsQuery)->latest('timestamp')->first();
|
||||
|
||||
$events = $eventsQuery
|
||||
->latest('timestamp')
|
||||
->cursorPaginate(10)
|
||||
->withQueryString();
|
||||
|
||||
// Get distinct environments for this issue
|
||||
$environments = $issue->events()
|
||||
->whereNotNull('environment')
|
||||
->distinct()
|
||||
->pluck('environment')
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
return view('issues.show', [
|
||||
'project' => $project,
|
||||
'issue' => $issue,
|
||||
'latestEvent' => $latestEvent,
|
||||
'events' => $events,
|
||||
'environments' => $environments,
|
||||
'currentEnvironment' => $environment,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\PersonalAccessToken;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateApiToken
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
|
||||
if (!$token) {
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$hashedToken = hash('sha256', $token);
|
||||
$accessToken = PersonalAccessToken::where('token', $hashedToken)->first();
|
||||
|
||||
if (!$accessToken) {
|
||||
return response()->json(['error' => 'Invalid token'], 401);
|
||||
}
|
||||
|
||||
if ($accessToken->isExpired()) {
|
||||
return response()->json(['error' => 'Token expired'], 401);
|
||||
}
|
||||
|
||||
// Set the authenticated user
|
||||
$request->setUserResolver(fn() => $accessToken->tokenable);
|
||||
|
||||
// Mark token as used
|
||||
$accessToken->markAsUsed();
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ class Issue extends Model
|
||||
'times_seen',
|
||||
'is_regression',
|
||||
'metadata',
|
||||
'first_release',
|
||||
'resolved_in_release',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -51,9 +53,13 @@ class Issue extends Model
|
||||
return $this->events()->latest('timestamp')->limit(1);
|
||||
}
|
||||
|
||||
public function resolve(): void
|
||||
public function resolve(?string $release = null): void
|
||||
{
|
||||
$this->update(['status' => 'resolved', 'is_regression' => false]);
|
||||
$this->update([
|
||||
'status' => 'resolved',
|
||||
'is_regression' => false,
|
||||
'resolved_in_release' => $release,
|
||||
]);
|
||||
}
|
||||
|
||||
public function ignore(): void
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class PersonalAccessToken extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'token',
|
||||
'abilities',
|
||||
'last_used_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'abilities' => 'array',
|
||||
'last_used_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function tokenable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function can(string $ability): bool
|
||||
{
|
||||
return in_array('*', $this->abilities ?? []) || in_array($ability, $this->abilities ?? []);
|
||||
}
|
||||
|
||||
public function markAsUsed(): void
|
||||
{
|
||||
$this->update(['last_used_at' => now()]);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at && $this->expires_at->isPast();
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
@@ -52,4 +54,21 @@ class User extends Authenticatable
|
||||
['email_enabled' => true, 'database_enabled' => true, 'muted' => false],
|
||||
);
|
||||
}
|
||||
|
||||
public function tokens(): MorphMany
|
||||
{
|
||||
return $this->morphMany(PersonalAccessToken::class, 'tokenable');
|
||||
}
|
||||
|
||||
public function createToken(string $name, array $abilities = ['*'], ?\DateTimeInterface $expiresAt = null): PersonalAccessToken
|
||||
{
|
||||
$token = Str::random(64);
|
||||
|
||||
return $this->tokens()->create([
|
||||
'name' => $name,
|
||||
'token' => hash('sha256', $token),
|
||||
'abilities' => $abilities,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ class EventProcessor
|
||||
'last_seen_at' => $now,
|
||||
'times_seen' => 1,
|
||||
'is_regression' => false,
|
||||
'first_release' => $data['release'],
|
||||
'metadata' => array_filter([
|
||||
'platform' => $data['platform'],
|
||||
'sdk' => $data['sdk'],
|
||||
@@ -115,11 +116,21 @@ class EventProcessor
|
||||
private function updateExistingIssue(Issue $issue, array $data, Carbon $now): bool
|
||||
{
|
||||
$wasResolved = $issue->status === 'resolved';
|
||||
$currentRelease = $data['release'];
|
||||
$resolvedInRelease = $issue->resolved_in_release;
|
||||
|
||||
$issue->increment('times_seen');
|
||||
$issue->update(['last_seen_at' => $now]);
|
||||
|
||||
// Check for regression: issue was resolved, and now appears again
|
||||
if ($wasResolved) {
|
||||
// If we have release tracking and the issue was resolved in a release,
|
||||
// only mark as regression if the current release is different (assumed newer)
|
||||
if ($resolvedInRelease && $currentRelease && $currentRelease === $resolvedInRelease) {
|
||||
// Same release as resolved - don't mark as regression
|
||||
return false;
|
||||
}
|
||||
|
||||
$issue->markRegression();
|
||||
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('issues', function (Blueprint $table) {
|
||||
$table->string('first_release')->nullable()->after('first_seen_at');
|
||||
$table->string('resolved_in_release')->nullable()->after('status');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('issues', function (Blueprint $table) {
|
||||
$table->dropColumn(['first_release', 'resolved_in_release']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->morphs('tokenable');
|
||||
$table->string('name');
|
||||
$table->string('token', 64)->unique();
|
||||
$table->text('abilities')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('personal_access_tokens');
|
||||
}
|
||||
};
|
||||
@@ -20,6 +20,7 @@ class DatabaseSeeder extends Seeder
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "test_app_two",
|
||||
"name": "html",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<x-app-layout>
|
||||
@php($currentProject = $project)
|
||||
@php
|
||||
$currentProject = $project;
|
||||
@endphp
|
||||
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -41,8 +43,19 @@
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@if($environments->isNotEmpty())
|
||||
<div>
|
||||
<x-input-label for="environment" value="Environment" />
|
||||
<select id="environment" name="environment" 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($environments as $env)
|
||||
<option value="{{ $env }}" {{ $currentEnvironment === $env ? 'selected' : '' }}>{{ ucfirst($env) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
<x-primary-button>Filter</x-primary-button>
|
||||
@if($search || $currentLevel || $currentStatus !== 'unresolved')
|
||||
@if($search || $currentLevel || $currentEnvironment || $currentStatus !== 'unresolved')
|
||||
<a href="{{ route('projects.issues.index', $project) }}" class="text-sm text-gray-500 hover:text-gray-700">Reset</a>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
@@ -39,6 +39,26 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Environment Filter --}}
|
||||
@if($environments->isNotEmpty())
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-4">
|
||||
<form method="GET" action="{{ route('projects.issues.show', [$project, $issue]) }}" class="flex items-end gap-4">
|
||||
<div>
|
||||
<x-input-label for="environment" value="Filter by Environment" />
|
||||
<select id="environment" name="environment" class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm text-sm" onchange="this.form.submit()">
|
||||
<option value="">All Environments</option>
|
||||
@foreach($environments as $env)
|
||||
<option value="{{ $env }}" {{ $currentEnvironment === $env ? 'selected' : '' }}>{{ ucfirst($env) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@if($currentEnvironment)
|
||||
<a href="{{ route('projects.issues.show', [$project, $issue]) }}" class="text-sm text-gray-500 hover:text-gray-700 pb-2">Reset</a>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Issue Meta --}}
|
||||
@php
|
||||
$statusColors = [
|
||||
@@ -88,6 +108,18 @@
|
||||
<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>
|
||||
@if($issue->first_release)
|
||||
<div>
|
||||
<span class="text-gray-500">First Release</span>
|
||||
<div class="mt-1 font-mono text-sm text-gray-900">{{ $issue->first_release }}</div>
|
||||
</div>
|
||||
@endif
|
||||
@if($issue->resolved_in_release)
|
||||
<div>
|
||||
<span class="text-gray-500">Resolved In</span>
|
||||
<div class="mt-1 font-mono text-sm text-green-700">{{ $issue->resolved_in_release }}</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ApiIssueController;
|
||||
use App\Http\Controllers\Api\EventIngestController;
|
||||
use App\Http\Middleware\AuthenticateApiToken;
|
||||
use App\Http\Middleware\AuthenticateDsn;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Event ingestion (uses DSN authentication)
|
||||
Route::post('/{project}/store/', [EventIngestController::class, 'store'])
|
||||
->middleware(AuthenticateDsn::class)
|
||||
->name('api.events.store');
|
||||
|
||||
// REST API (uses token authentication)
|
||||
Route::middleware(AuthenticateApiToken::class)->prefix('v1')->group(function () {
|
||||
Route::prefix('projects/{project}/issues')->group(function () {
|
||||
Route::get('/', [ApiIssueController::class, 'index'])->name('api.v1.issues.index');
|
||||
Route::get('/{issue}', [ApiIssueController::class, 'show'])->name('api.v1.issues.show');
|
||||
Route::patch('/{issue}', [ApiIssueController::class, 'update'])->name('api.v1.issues.update');
|
||||
Route::delete('/{issue}', [ApiIssueController::class, 'destroy'])->name('api.v1.issues.destroy');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user