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
18 changes: 9 additions & 9 deletions web/build/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@
<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.ZBdqMrTD.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CiRPKAwS.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.DhMbJzVq.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BUohbZ8j.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DtfuHTUv.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.zQYyvZcz.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.D9IKs6iZ.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.DKJm-K02.js" rel="modulepreload">
<link href="/_app/immutable/nodes/0.aF9dtSfR.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DKyKpwK7.js" rel="modulepreload">
<link href="/_app/immutable/chunks/5Qa-t89S.js" rel="modulepreload">
<link href="/_app/immutable/chunks/hKaPXkrQ.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BscTVQ9J.js" rel="modulepreload">
<link href="/_app/immutable/chunks/B0RXJPsG.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CCc3LmDV.js" rel="modulepreload">
<link href="/_app/immutable/chunks/J-rJHSB7.js" rel="modulepreload">
<link href="/_app/immutable/chunks/fe7zj1-L.js" rel="modulepreload">

<link href="/_app/immutable/assets/0.DtaeUL2f.css" rel="stylesheet">
Expand All @@ -24,15 +24,15 @@
<div style="display: contents">
<script>
{
__sveltekit_9284yn = {
__sveltekit_1vybhoy = {
base: ""
};

const element = document.currentScript.parentElement;

Promise.all([
import("/_app/immutable/entry/start.ZBdqMrTD.js"),
import("/_app/immutable/entry/app.zQYyvZcz.js")
import("/_app/immutable/entry/start.DhMbJzVq.js"),
import("/_app/immutable/entry/app.D9IKs6iZ.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
Expand Down
1 change: 0 additions & 1 deletion web/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "vega",
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app.css",
"baseColor": "zinc"
},
Expand Down
34 changes: 34 additions & 0 deletions web/e2e/mobile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test';
import { login, requireServer } from './helpers';

test.describe('Mobile dashboard', () => {
test.use({ viewport: { width: 390, height: 844 } });

test.beforeEach(async ({ page }) => {
await requireServer(page);
await login(page);
});

test('mobile drawer navigation opens and routes between pages', async ({ page }) => {
// The desktop nav is hidden; the hamburger opens the Sheet drawer.
await expect(page.getByRole('link', { name: 'Settings' })).toBeHidden();
await page.getByRole('button', { name: 'Open menu' }).click();
await expect(page.getByRole('heading', { name: 'TrapFall' })).toBeVisible();

await page.getByRole('link', { name: 'Projects' }).click();
await expect(page).toHaveURL(/\/projects/);
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible();
});

test('issue detail stats stack on a narrow viewport', async ({ page }) => {
await page.locator('tr.cursor-pointer').first().click();
await expect(page).toHaveURL(/\/issues\/[a-f0-9-]+/i);
await expect(page.getByText('First seen')).toBeVisible();
await expect(page.getByText('Last seen')).toBeVisible();
// No horizontal overflow at 390px.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
);
expect(overflow).toBe(false);
});
});
74 changes: 26 additions & 48 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,31 @@ class ApiClient {
return this.get<string[]>(`/projects/${projectSlug}/environments`);
}

// ── Alert Rules ─────────────────────────────────────────────────

async listAlertRules(projectSlug: string): Promise<AlertRule[]> {
return this.get<AlertRule[]>(`/projects/${projectSlug}/rules`);
}

async createAlertRule(projectSlug: string, rule: CreateAlertRule): Promise<AlertRule> {
return this.post<AlertRule>(`/projects/${projectSlug}/rules`, rule);
}

async deleteAlertRule(ruleId: string): Promise<void> {
await this.delete(`/rules/${ruleId}`);
}

async toggleAlertRule(ruleId: string, enabled: boolean): Promise<void> {
await this.post(`/rules/${ruleId}/toggle`, { enabled });
}

// ── Attachments ─────────────────────────────────────────────────

async listAttachments(eventId: string): Promise<AttachmentItem[]> {
const data = await this.get<{ items?: AttachmentItem[] }>(`/events/${eventId}/attachments`);
return data.items || [];
}

async getPublicConfig(): Promise<PublicConfig> {
return this.get<PublicConfig>('/config');
}
Expand Down Expand Up @@ -357,46 +382,7 @@ export interface CreateAlertRule {
cooldown_seconds?: number;
}

// ── Standalone API Functions (not yet on ApiClient) ─────────────────────
// These use raw fetch to match the existing auth pattern.
// TODO: migrate into ApiClient class methods.

export async function listAlertRules(projectSlug: string): Promise<AlertRule[]> {
const res = await fetch(`${API_BASE}/projects/${projectSlug}/rules`);
if (res.status === 401) { gotoLogin(); throw new ApiClientError(401, 'Not authenticated'); }
if (!res.ok) throw new ApiClientError(res.status, await res.text());
return res.json();
}

export async function createAlertRule(
projectSlug: string,
rule: CreateAlertRule
): Promise<AlertRule> {
const res = await fetch(`${API_BASE}/projects/${projectSlug}/rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule)
});
if (res.status === 401) { gotoLogin(); throw new ApiClientError(401, 'Not authenticated'); }
if (!res.ok) throw new ApiClientError(res.status, await res.text());
return res.json();
}

export async function deleteAlertRule(ruleId: string): Promise<void> {
const res = await fetch(`${API_BASE}/rules/${ruleId}`, { method: 'DELETE' });
if (res.status === 401) { gotoLogin(); throw new ApiClientError(401, 'Not authenticated'); }
if (!res.ok && res.status !== 200) throw new ApiClientError(res.status, await res.text());
}

export async function toggleAlertRule(ruleId: string, enabled: boolean): Promise<void> {
const res = await fetch(`${API_BASE}/rules/${ruleId}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
if (res.status === 401) { gotoLogin(); throw new ApiClientError(401, 'Not authenticated'); }
if (!res.ok) throw new ApiClientError(res.status, await res.text());
}
// ── Standalone API helpers ──────────────────────────────────────────────

export const api = new ApiClient();

Expand All @@ -412,14 +398,6 @@ export interface AttachmentItem {
created_at: string;
}

export async function fetchAttachments(eventId: string): Promise<AttachmentItem[]> {
const res = await fetch(`${API_BASE}/events/${eventId}/attachments`);
if (res.status === 401) { gotoLogin(); throw new Error('Not authenticated'); }
if (!res.ok) return [];
const data = await res.json();
return data.items || [];
}

export function getAttachmentDownloadUrl(attachmentId: string): string {
return `${API_BASE}/attachments/${attachmentId}/download`;
}
19 changes: 19 additions & 0 deletions web/src/lib/components/EmptyState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import EmptyState from './EmptyState.svelte';

describe('EmptyState', () => {
it('renders title and description', () => {
render(EmptyState, {
props: { title: 'No issues found', description: 'Try adjusting your filters.' }
});
expect(screen.getByText('No issues found')).toBeInTheDocument();
expect(screen.getByText('Try adjusting your filters.')).toBeInTheDocument();
});

it('omits the description when not provided', () => {
render(EmptyState, { props: { title: 'Nothing here' } });
expect(screen.getByText('Nothing here')).toBeInTheDocument();
expect(screen.queryByText(/filters/i)).not.toBeInTheDocument();
});
});
50 changes: 50 additions & 0 deletions web/src/lib/components/Pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import Pagination from './Pagination.svelte';

describe('Pagination', () => {
it('renders nothing when there is only one page', () => {
const { container } = render(Pagination, {
props: { page: 1, totalPages: 1, total: 12, perPage: 20, onPageChange: vi.fn() }
});
// Svelte leaves an empty comment node for the falsy branch; no real elements.
expect(container.querySelector('*')).toBeNull();
expect(screen.queryByText(/Showing/)).not.toBeInTheDocument();
});

it('shows the active range and page buttons', () => {
render(Pagination, {
props: { page: 2, totalPages: 3, total: 50, perPage: 20, onPageChange: vi.fn() }
});
expect(screen.getByText('Showing 21–40 of 50')).toBeInTheDocument();
for (const n of [1, 2, 3]) {
expect(screen.getByRole('button', { name: String(n) })).toBeInTheDocument();
}
});

it('disables Prev on the first page and Next on the last', () => {
const { unmount } = render(Pagination, {
props: { page: 1, totalPages: 3, total: 50, perPage: 20, onPageChange: vi.fn() }
});
expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled();
unmount();

render(Pagination, {
props: { page: 3, totalPages: 3, total: 50, perPage: 20, onPageChange: vi.fn() }
});
expect(screen.getByRole('button', { name: 'Prev' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled();
});

it('reports page changes through onPageChange', async () => {
const onPageChange = vi.fn();
render(Pagination, {
props: { page: 2, totalPages: 3, total: 50, perPage: 20, onPageChange }
});
await fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onPageChange).toHaveBeenCalledWith(3);
await fireEvent.click(screen.getByRole('button', { name: '1' }));
expect(onPageChange).toHaveBeenCalledWith(1);
});
});
48 changes: 46 additions & 2 deletions web/src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { cn } from '$lib/utils';
import { describe, it, expect, vi } from 'vitest';
import { cn, rowKeyActivate, crashRateBarClass, crashRateColor } from './utils';

describe('cn utility', () => {
it('merges class names', () => {
Expand All @@ -14,3 +14,47 @@ describe('cn utility', () => {
expect(cn('px-2', 'px-4')).toBe('px-4');
});
});

function keyEvent(key: string): KeyboardEvent {
return new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
}

describe('rowKeyActivate', () => {
it('activates on Enter and prevents the default scroll', () => {
const activate = vi.fn();
const e = keyEvent('Enter');
rowKeyActivate(e, activate);
expect(activate).toHaveBeenCalledOnce();
expect(e.defaultPrevented).toBe(true);
});

it('activates on Space', () => {
const activate = vi.fn();
rowKeyActivate(keyEvent(' '), activate);
expect(activate).toHaveBeenCalledOnce();
});

it('ignores other keys', () => {
const activate = vi.fn();
rowKeyActivate(keyEvent('a'), activate);
rowKeyActivate(keyEvent('Escape'), activate);
expect(activate).not.toHaveBeenCalled();
});
});

describe('crashRateColor / crashRateBarClass', () => {
it('returns empty string for null rate', () => {
expect(crashRateColor(null)).toBe('');
expect(crashRateBarClass(null)).toBe('');
});

it('maps severity thresholds to semantic tokens', () => {
expect(crashRateColor(0.5)).toBe('text-success');
expect(crashRateColor(4.9)).toBe('text-warning');
expect(crashRateColor(10)).toBe('text-destructive');

expect(crashRateBarClass(0.5)).toBe('bg-success');
expect(crashRateBarClass(4.9)).toBe('bg-warning');
expect(crashRateBarClass(10)).toBe('bg-destructive');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { api, type StoredEvent, type Issue } from '$lib/api';
import { fetchAttachments, type AttachmentItem } from '$lib/api';
import { api, type StoredEvent, type Issue, type AttachmentItem } from '$lib/api';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
Expand Down Expand Up @@ -61,7 +60,7 @@
// Fetch attachments for this event
attachmentsLoading = true;
try {
attachments = await fetchAttachments(eventId);
attachments = await api.listAttachments(eventId);
} catch {
// Silently handle attachment fetch errors
attachments = [];
Expand Down
19 changes: 5 additions & 14 deletions web/src/routes/(dashboard)/rules/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
api,
type Project,
type AlertRule,
type CreateAlertRule,
listAlertRules,
createAlertRule,
deleteAlertRule,
toggleAlertRule
} from '$lib/api';
import { api, type Project, type AlertRule, type CreateAlertRule } from '$lib/api';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import { toast } from 'svelte-sonner';
Expand Down Expand Up @@ -41,7 +32,7 @@
loading = true;
error = '';
try {
rules = await listAlertRules(selectedProject);
rules = await api.listAlertRules(selectedProject);
} catch (e: any) {
error = e?.message || 'Failed to load rules';
} finally {
Expand Down Expand Up @@ -90,7 +81,7 @@
if (formWebhookUrl.trim()) actionConfig.url = formWebhookUrl.trim();

try {
await createAlertRule(selectedProject, {
await api.createAlertRule(selectedProject, {
name: formName.trim(),
conditions,
action_type: 'webhook',
Expand All @@ -109,7 +100,7 @@

async function handleToggle(rule: AlertRule) {
try {
await toggleAlertRule(rule.id, !rule.enabled);
await api.toggleAlertRule(rule.id, !rule.enabled);
toast.success(rule.enabled ? 'Rule disabled' : 'Rule enabled');
await loadRules();
} catch (e: any) {
Expand All @@ -122,7 +113,7 @@
const id = pendingDelete.id;
pendingDelete = null;
try {
await deleteAlertRule(id);
await api.deleteAlertRule(id);
toast.success('Rule deleted');
await loadRules();
} catch (e: any) {
Expand Down
4 changes: 4 additions & 0 deletions web/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { resolve } from 'node:path';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vitest/config';

export default defineConfig({
plugins: [svelte({ hot: false })],
resolve: {
// Force Svelte's client bundles under Vitest; jsdom is not a server.
conditions: ['browser'],
alias: {
$lib: resolve('./src/lib'),
$components: resolve('./src/lib/components'),
Expand Down
Loading