This commit is contained in:
Doar Mihai
2026-03-21 15:25:05 +02:00
parent ac64140de7
commit f78f3aeeb7
16 changed files with 561 additions and 20 deletions
+97
View File
@@ -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'
);
}
}
+36 -2
View File
@@ -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);
}
}
+8 -2
View File
@@ -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
+46
View File
@@ -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();
}
}
+19
View File
@@ -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,
]);
}
}
+11
View File
@@ -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;