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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions web/build/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,32 @@
<meta charset="utf-8" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/_app/immutable/entry/start.0yoIjHGC.js" rel="modulepreload">
<link href="/_app/immutable/chunks/uyNbz9wZ.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.D2DElZOc.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CGD5FpIt.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Cca5XcAr.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.CH8Ik2gF.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.o3EtqHxp.js" rel="modulepreload">
<link href="/_app/immutable/chunks/kNaey6uv.js" rel="modulepreload">
<link href="/_app/immutable/chunks/xihTtKlq.js" rel="modulepreload">
<link href="/_app/immutable/nodes/0.Col44ktl.js" rel="modulepreload">
<link href="/_app/immutable/nodes/0.SV3TCvT0.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BscTVQ9J.js" rel="modulepreload">
<link href="/_app/immutable/chunks/D_M18t6X.js" rel="modulepreload">
<link href="/_app/immutable/chunks/fe7zj1-L.js" rel="modulepreload">

<link href="/_app/immutable/assets/0.NSNMHVgJ.css" rel="stylesheet">
<link href="/_app/immutable/assets/0.Cv8yMDRc.css" rel="stylesheet">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_mhrumv = {
__sveltekit_1hf2gre = {
base: ""
};

const element = document.currentScript.parentElement;

Promise.all([
import("/_app/immutable/entry/start.0yoIjHGC.js"),
import("/_app/immutable/entry/app.CH8Ik2gF.js")
import("/_app/immutable/entry/start.D2DElZOc.js"),
import("/_app/immutable/entry/app.o3EtqHxp.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
Expand Down
33 changes: 33 additions & 0 deletions web/e2e/issues.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,37 @@ test.describe('Issue detail view', () => {
/culprit|stacktrace|stack trace|first seen|last seen|level/i.test(body);
expect(looksLikeIssueDetail).toBeTruthy();
});

test('lists events and navigates to the event detail view', async ({ page }) => {
await expect(issueRows(page).first()).toBeVisible({ timeout: 10000 });
await issueRows(page).first().click();
await expect(page).toHaveURL(/\/issues\/[a-f0-9-]+/i);

// The events table should render at least one event row.
const eventRows = page.locator('tr.cursor-pointer');
await expect(eventRows.first()).toBeVisible({ timeout: 10000 });

// Clicking an event row navigates to the event detail page.
await eventRows.first().click();
await expect(page).toHaveURL(/\/issues\/[a-f0-9-]+\/events\/[a-f0-9-]+/i);
});

test('resolve action updates the issue status badge', async ({ page }) => {
await expect(issueRows(page).first()).toBeVisible({ timeout: 10000 });
await issueRows(page).first().click();
await expect(page).toHaveURL(/\/issues\/[a-f0-9-]+/i);

// Status persists across runs, so normalize to unresolved first.
const resolveBtn = page.getByRole('button', { name: 'Resolve', exact: true });
const unresolveBtn = page.getByRole('button', { name: 'Unresolve', exact: true });
await expect(resolveBtn.or(unresolveBtn)).toBeVisible({ timeout: 10000 });
if (await unresolveBtn.isVisible()) {
await unresolveBtn.click();
await expect(resolveBtn).toBeVisible({ timeout: 10000 });
}

// Resolve, then the primary action flips to Unresolve.
await resolveBtn.click();
await expect(unresolveBtn).toBeVisible({ timeout: 10000 });
});
});
299 changes: 299 additions & 0 deletions web/src/routes/(dashboard)/issues/[issueId]/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { api, type StoredEvent, type Issue, type IssueStatus } from '$lib/api';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent } from '$lib/components/ui/card/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';

import { levelTextClass, statusTextClass, timeAgo, formatTime } from '$lib/utils';

let issue: Issue | null = $state(null);
let events: StoredEvent[] = $state([]);
let totalEvents: number = $state(0);
let eventsPage: number = $state(1);
const eventsPerPage = 10;
let loading = $state(true);
let error = $state('');
let statusError = $state('');
let updatingStatus = $state(false);

let projectSlug = $state('');

let totalEventPages: number = $derived(Math.max(1, Math.ceil(totalEvents / eventsPerPage)));

/** Extract the first exception type/value from a Sentry-format event payload. */
function exceptionSummary(event: StoredEvent): { type: string; value: string } | null {
const ex = event.data?.exception as { values?: Array<{ type?: string; value?: string }> } | undefined;
const first = ex?.values?.[0];
if (!first) return null;
return { type: first.type || 'Exception', value: first.value || '' };
}

function eventSummary(event: StoredEvent): string {
const message = event.data?.message;
if (typeof message === 'string' && message) return message;
const ex = exceptionSummary(event);
if (ex) return ex.value ? `${ex.type}: ${ex.value}` : ex.type;
return 'Event';
}

function goBack() {
if (projectSlug) {
goto('/issues?project=' + projectSlug);
} else {
goto('/issues');
}
}

function goToEvent(event: StoredEvent) {
const qs = projectSlug ? `?project=${projectSlug}` : '';
goto(`/issues/${issue!.id}/events/${event.id}${qs}`);
}

async function changeStatus(status: IssueStatus) {
if (!issue || updatingStatus || issue.status === status) return;
const prev = issue.status;
issue = { ...issue, status };
updatingStatus = true;
statusError = '';
try {
await api.setIssueStatus(issue.id, status);
} catch (e: any) {
issue = { ...issue, status: prev };
statusError = e?.message || 'Failed to update status';
} finally {
updatingStatus = false;
}
}

async function loadEvents(p: number) {
if (!issue) return;
eventsPage = p;
const res = await api.listEvents(issue.id, p, eventsPerPage);
events = res.data;
totalEvents = res.total;
}

function goToEventsPage(p: number) {
if (p < 1 || p > totalEventPages) return;
loadEvents(p);
}

function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
goBack();
}
}

onMount(async () => {
const issueId = page.params.issueId;
if (!issueId) return;

projectSlug = page.url.searchParams.get('project') || '';

try {
issue = await api.getIssue(issueId);
await loadEvents(1);
} catch (e: any) {
error = e?.message || 'Failed to load issue';
} finally {
loading = false;
}
});
</script>

<svelte:head>
<title>{issue ? issue.title : 'Issue'} · TrapFall</title>
</svelte:head>

<svelte:window onkeydown={handleKeydown} />

<div class="p-4 lg:p-6 space-y-4">
{#if loading}
<div class="space-y-4">
<Skeleton class="h-6 w-32" />
<Skeleton class="h-8 w-2/3" />
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
{#each Array(4) as _}
<Skeleton class="h-20 w-full rounded-xl" />
{/each}
</div>
<Skeleton class="h-48 w-full" />
</div>
{:else if error}
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<p class="text-sm text-destructive">{error}</p>
</div>
{:else if issue}
<!-- Back nav -->
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Button variant="ghost" size="sm" onclick={goBack} class="gap-1 px-2">
&#x2190;
Back
</Button>
{#if projectSlug}
<span class="text-muted-foreground/50">&#xB7;</span>
<Badge variant="outline" class="text-xs">{projectSlug}</Badge>
{/if}
<span class="text-muted-foreground/50">&#xB7;</span>
<span class="text-xs">Press <kbd class="rounded border px-1 py-0.5 text-[10px] font-mono">ESC</kbd> to go back</span>
</div>

<!-- Header -->
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline" class={levelTextClass(issue.level)}>
{issue.level}
</Badge>
<Badge variant="outline" class={statusTextClass(issue.status)}>
{issue.status}
</Badge>
</div>
<div class="flex flex-wrap items-start justify-between gap-3">
<h1 class="text-xl font-bold min-w-0 break-words">{issue.title}</h1>
<div class="flex items-center gap-2 shrink-0">
{#if issue.status === 'resolved'}
<Button variant="outline" size="sm" disabled={updatingStatus} onclick={() => changeStatus('unresolved')}>
Unresolve
</Button>
{:else}
<Button variant="outline" size="sm" disabled={updatingStatus} onclick={() => changeStatus('resolved')}>
Resolve
</Button>
{/if}
{#if issue.status === 'ignored'}
<Button variant="outline" size="sm" disabled={updatingStatus} onclick={() => changeStatus('unresolved')}>
Unignore
</Button>
{:else}
<Button variant="outline" size="sm" disabled={updatingStatus} onclick={() => changeStatus('ignored')}>
Ignore
</Button>
{/if}
</div>
</div>
{#if issue.culprit}
<p class="font-mono text-xs text-muted-foreground break-all">{issue.culprit}</p>
{/if}
{#if statusError}
<p class="text-xs text-destructive">{statusError}</p>
{/if}
</div>

<!-- Metadata stats -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent class="p-4">
<p class="text-xs font-medium text-muted-foreground">Events</p>
<p class="text-2xl font-bold tabular-nums mt-1">{issue.count}</p>
</CardContent>
</Card>
<Card>
<CardContent class="p-4">
<p class="text-xs font-medium text-muted-foreground">Users affected</p>
<p class="text-2xl font-bold tabular-nums mt-1">{issue.user_count}</p>
</CardContent>
</Card>
<Card>
<CardContent class="p-4">
<p class="text-xs font-medium text-muted-foreground">First seen</p>
<p class="text-sm font-medium mt-1.5" title={formatTime(issue.first_seen)}>
{timeAgo(issue.first_seen)}
</p>
</CardContent>
</Card>
<Card>
<CardContent class="p-4">
<p class="text-xs font-medium text-muted-foreground">Last seen</p>
<p class="text-sm font-medium mt-1.5" title={formatTime(issue.last_seen)}>
{timeAgo(issue.last_seen)}
</p>
</CardContent>
</Card>
</div>

<!-- Events -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Events</h2>
{#if totalEvents > 0}
<span class="text-xs text-muted-foreground tabular-nums">{totalEvents} total</span>
{/if}
</div>

{#if events.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-center">
<p class="text-sm text-muted-foreground">No events recorded for this issue yet.</p>
</div>
{:else}
<div class="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[50%]">Summary</TableHead>
<TableHead class="hidden md:table-cell">Exception</TableHead>
<TableHead>Received</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each events as event (event.id)}
<TableRow
class="cursor-pointer hover:bg-muted/50"
onclick={() => goToEvent(event)}
>
<TableCell class="font-medium whitespace-normal">
{eventSummary(event)}
</TableCell>
<TableCell class="hidden md:table-cell font-mono text-xs text-muted-foreground">
{exceptionSummary(event)?.type || '—'}
</TableCell>
<TableCell class="text-muted-foreground text-sm whitespace-nowrap" title={formatTime(event.received_at)}>
{timeAgo(event.received_at)}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>

<!-- Events pagination -->
{#if totalEventPages > 1}
<div class="flex items-center justify-between">
<p class="text-xs text-muted-foreground tabular-nums">
Page {eventsPage} of {totalEventPages}
</p>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={eventsPage <= 1}
onclick={() => goToEventsPage(eventsPage - 1)}
>
Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={eventsPage >= totalEventPages}
onclick={() => goToEventsPage(eventsPage + 1)}
>
Next
</Button>
</div>
</div>
{/if}
{/if}
</div>
{/if}
</div>
Loading