diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md
index edc3e56..113e845 100644
--- a/DOCUMENTATION.md
+++ b/DOCUMENTATION.md
@@ -686,6 +686,22 @@ A separate API server (`server/` directory) receives bug reports:
- **Rate limiting**: HWID-based, configurable (default 5 reports per 24h)
- **Endpoints**: `POST /api/bug-reports` (client), `GET/DELETE /api/admin/bug-reports` (admin)
+#### Bug Report Dashboard
+
+A web dashboard (`dashboard/` directory) for browsing, triaging, and downloading bug reports:
+- **Stack**: SvelteKit (Svelte 5) + TailwindCSS v4 + Drizzle ORM + Bun.js
+- **Deployment**: Docker service in `server/docker-compose.yml`, port 3001
+- **Database**: Connects directly to the same MySQL database via Drizzle ORM (read/write)
+- **Features**:
+ - Paginated reports list with status filter and search (hostname, user, name, email)
+ - Report detail view with metadata, description, system info (collapsible JSON), and file list
+ - Status management (new → in_review → resolved → closed)
+ - Inline screenshot preview for attached screenshots
+ - Individual file download and bulk ZIP download (all files + report metadata)
+ - Report deletion with confirmation dialog
+ - Dark mode UI matching EMLy's aesthetic
+- **Development**: `cd dashboard && bun install && bun dev` (localhost:3001)
+
#### Configuration (config.ini)
```ini
diff --git a/dashboard/.env.example b/dashboard/.env.example
new file mode 100644
index 0000000..7e790cc
--- /dev/null
+++ b/dashboard/.env.example
@@ -0,0 +1,6 @@
+# MySQL Connection
+MYSQL_HOST=localhost
+MYSQL_PORT=3306
+MYSQL_USER=emly
+MYSQL_PASSWORD=change_me_in_production
+MYSQL_DATABASE=emly_bugreports
diff --git a/dashboard/.gitignore b/dashboard/.gitignore
new file mode 100644
index 0000000..41068e2
--- /dev/null
+++ b/dashboard/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+.svelte-kit
+build
+.env
+bun.lock
diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile
new file mode 100644
index 0000000..1e67551
--- /dev/null
+++ b/dashboard/Dockerfile
@@ -0,0 +1,9 @@
+FROM oven/bun:alpine
+WORKDIR /app
+COPY package.json bun.lock* ./
+RUN bun install --frozen-lockfile || bun install
+COPY . .
+RUN bun run build
+ENV NODE_ENV=production
+EXPOSE 3000
+CMD ["bun", "build/index.js"]
diff --git a/dashboard/drizzle.config.ts b/dashboard/drizzle.config.ts
new file mode 100644
index 0000000..2c0ea05
--- /dev/null
+++ b/dashboard/drizzle.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'drizzle-kit';
+
+export default defineConfig({
+ schema: './src/lib/schema.ts',
+ dialect: 'mysql',
+ dbCredentials: {
+ host: process.env.MYSQL_HOST || 'localhost',
+ port: Number(process.env.MYSQL_PORT) || 3306,
+ user: process.env.MYSQL_USER || 'emly',
+ password: process.env.MYSQL_PASSWORD || '',
+ database: process.env.MYSQL_DATABASE || 'emly_bugreports'
+ }
+});
diff --git a/dashboard/package.json b/dashboard/package.json
new file mode 100644
index 0000000..bfe1dc1
--- /dev/null
+++ b/dashboard/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "emly-dashboard",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev --port 3001",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-node": "^5.2.0",
+ "@sveltejs/kit": "^2.21.0",
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/node": "^25.2.3",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.0.0",
+ "vite": "^6.0.0"
+ },
+ "dependencies": {
+ "drizzle-orm": "^0.38.0",
+ "mysql2": "^3.11.0",
+ "bits-ui": "^1.0.0",
+ "clsx": "^2.1.0",
+ "tailwind-merge": "^3.0.0",
+ "tailwind-variants": "^0.3.0",
+ "jszip": "^3.10.0",
+ "lucide-svelte": "^0.469.0"
+ },
+ "type": "module"
+}
diff --git a/dashboard/src/app.css b/dashboard/src/app.css
new file mode 100644
index 0000000..2ac6589
--- /dev/null
+++ b/dashboard/src/app.css
@@ -0,0 +1,42 @@
+@import "tailwindcss";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme {
+ --color-background: hsl(222.2 84% 4.9%);
+ --color-foreground: hsl(210 40% 98%);
+ --color-card: hsl(222.2 84% 4.9%);
+ --color-card-foreground: hsl(210 40% 98%);
+ --color-popover: hsl(222.2 84% 4.9%);
+ --color-popover-foreground: hsl(210 40% 98%);
+ --color-primary: hsl(217.2 91.2% 59.8%);
+ --color-primary-foreground: hsl(222.2 47.4% 11.2%);
+ --color-secondary: hsl(217.2 32.6% 17.5%);
+ --color-secondary-foreground: hsl(210 40% 98%);
+ --color-muted: hsl(217.2 32.6% 17.5%);
+ --color-muted-foreground: hsl(215 20.2% 65.1%);
+ --color-accent: hsl(217.2 32.6% 17.5%);
+ --color-accent-foreground: hsl(210 40% 98%);
+ --color-destructive: hsl(0 62.8% 30.6%);
+ --color-destructive-foreground: hsl(210 40% 98%);
+ --color-border: hsl(217.2 32.6% 17.5%);
+ --color-input: hsl(217.2 32.6% 17.5%);
+ --color-ring: hsl(224.3 76.3% 48%);
+ --radius: 0.5rem;
+
+ --color-sidebar: hsl(222.2 84% 3.5%);
+ --color-sidebar-foreground: hsl(210 40% 98%);
+ --color-sidebar-border: hsl(217.2 32.6% 12%);
+
+ --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+}
+
+* {
+ border-color: var(--color-border);
+}
+
+body {
+ background-color: var(--color-background);
+ color: var(--color-foreground);
+ font-family: var(--font-sans);
+}
diff --git a/dashboard/src/app.html b/dashboard/src/app.html
new file mode 100644
index 0000000..a2e03b2
--- /dev/null
+++ b/dashboard/src/app.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/dashboard/src/lib/schema.ts b/dashboard/src/lib/schema.ts
new file mode 100644
index 0000000..c6557b5
--- /dev/null
+++ b/dashboard/src/lib/schema.ts
@@ -0,0 +1,54 @@
+import {
+ mysqlTable,
+ int,
+ varchar,
+ text,
+ json,
+ mysqlEnum,
+ timestamp,
+ customType
+} from 'drizzle-orm/mysql-core';
+
+const longblob = customType<{ data: Buffer }>({
+ dataType() {
+ return 'longblob';
+ }
+});
+
+export const bugReports = mysqlTable('bug_reports', {
+ id: int('id').autoincrement().primaryKey(),
+ name: varchar('name', { length: 255 }).notNull(),
+ email: varchar('email', { length: 255 }).notNull(),
+ description: text('description').notNull(),
+ hwid: varchar('hwid', { length: 255 }).notNull().default(''),
+ hostname: varchar('hostname', { length: 255 }).notNull().default(''),
+ os_user: varchar('os_user', { length: 255 }).notNull().default(''),
+ submitter_ip: varchar('submitter_ip', { length: 45 }).notNull().default(''),
+ system_info: json('system_info'),
+ status: mysqlEnum('status', ['new', 'in_review', 'resolved', 'closed']).notNull().default('new'),
+ created_at: timestamp('created_at').notNull().defaultNow(),
+ updated_at: timestamp('updated_at').notNull().defaultNow().onUpdateNow()
+});
+
+export const bugReportFiles = mysqlTable('bug_report_files', {
+ id: int('id').autoincrement().primaryKey(),
+ report_id: int('report_id')
+ .notNull()
+ .references(() => bugReports.id, { onDelete: 'cascade' }),
+ file_role: mysqlEnum('file_role', [
+ 'screenshot',
+ 'mail_file',
+ 'localstorage',
+ 'config',
+ 'system_info'
+ ]).notNull(),
+ filename: varchar('filename', { length: 255 }).notNull(),
+ mime_type: varchar('mime_type', { length: 127 }).notNull().default('application/octet-stream'),
+ file_size: int('file_size').notNull().default(0),
+ data: longblob('data').notNull(),
+ created_at: timestamp('created_at').notNull().defaultNow()
+});
+
+export type BugReport = typeof bugReports.$inferSelect;
+export type BugReportFile = typeof bugReportFiles.$inferSelect;
+export type BugReportStatus = BugReport['status'];
diff --git a/dashboard/src/lib/server/db.ts b/dashboard/src/lib/server/db.ts
new file mode 100644
index 0000000..d93f0ea
--- /dev/null
+++ b/dashboard/src/lib/server/db.ts
@@ -0,0 +1,16 @@
+import { drizzle } from 'drizzle-orm/mysql2';
+import mysql from 'mysql2/promise';
+import * as schema from '$lib/schema';
+import { env } from '$env/dynamic/private';
+
+const pool = mysql.createPool({
+ host: env.MYSQL_HOST || 'localhost',
+ port: Number(env.MYSQL_PORT) || 3306,
+ user: env.MYSQL_USER || 'emly',
+ password: env.MYSQL_PASSWORD,
+ database: env.MYSQL_DATABASE || 'emly_bugreports',
+ connectionLimit: 10,
+ idleTimeout: 60000
+});
+
+export const db = drizzle(pool, { schema, mode: 'default' });
diff --git a/dashboard/src/lib/utils.ts b/dashboard/src/lib/utils.ts
new file mode 100644
index 0000000..10803a7
--- /dev/null
+++ b/dashboard/src/lib/utils.ts
@@ -0,0 +1,39 @@
+import { type ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function formatBytes(bytes: number): string {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
+}
+
+export function formatDate(date: Date | string): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
+ return d.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+}
+
+export const statusColors: Record = {
+ new: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
+ in_review: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
+ resolved: 'bg-green-500/20 text-green-400 border-green-500/30',
+ closed: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30'
+};
+
+export const statusLabels: Record = {
+ new: 'New',
+ in_review: 'In Review',
+ resolved: 'Resolved',
+ closed: 'Closed'
+};
diff --git a/dashboard/src/routes/+error.svelte b/dashboard/src/routes/+error.svelte
new file mode 100644
index 0000000..024d2f9
--- /dev/null
+++ b/dashboard/src/routes/+error.svelte
@@ -0,0 +1,14 @@
+
+
+
+
{$page.status}
+
{$page.error?.message || 'Something went wrong'}
+
+ Back to Reports
+
+
diff --git a/dashboard/src/routes/+layout.server.ts b/dashboard/src/routes/+layout.server.ts
new file mode 100644
index 0000000..c3760d9
--- /dev/null
+++ b/dashboard/src/routes/+layout.server.ts
@@ -0,0 +1,15 @@
+import type { LayoutServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports } from '$lib/schema';
+import { eq, count } from 'drizzle-orm';
+
+export const load: LayoutServerLoad = async () => {
+ const [result] = await db
+ .select({ count: count() })
+ .from(bugReports)
+ .where(eq(bugReports.status, 'new'));
+
+ return {
+ newCount: result.count
+ };
+};
diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte
new file mode 100644
index 0000000..a97862d
--- /dev/null
+++ b/dashboard/src/routes/+layout.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
+
diff --git a/dashboard/src/routes/+page.server.ts b/dashboard/src/routes/+page.server.ts
new file mode 100644
index 0000000..5cb069d
--- /dev/null
+++ b/dashboard/src/routes/+page.server.ts
@@ -0,0 +1,77 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports, bugReportFiles } from '$lib/schema';
+import { eq, like, or, count, sql, desc } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ url }) => {
+ const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
+ const pageSize = Math.min(50, Math.max(10, Number(url.searchParams.get('pageSize')) || 20));
+ const status = url.searchParams.get('status') || '';
+ const search = url.searchParams.get('search') || '';
+
+ const conditions = [];
+ if (status && ['new', 'in_review', 'resolved', 'closed'].includes(status)) {
+ conditions.push(eq(bugReports.status, status as 'new' | 'in_review' | 'resolved' | 'closed'));
+ }
+ if (search) {
+ const pattern = `%${search}%`;
+ conditions.push(
+ or(
+ like(bugReports.hostname, pattern),
+ like(bugReports.os_user, pattern),
+ like(bugReports.name, pattern),
+ like(bugReports.email, pattern)
+ )!
+ );
+ }
+
+ const where = conditions.length > 0
+ ? conditions.length === 1
+ ? conditions[0]
+ : sql`${conditions[0]} AND ${conditions[1]}`
+ : undefined;
+
+ const [totalResult] = await db
+ .select({ count: count() })
+ .from(bugReports)
+ .where(where);
+
+ const total = totalResult.count;
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+
+ const fileCountSubquery = db
+ .select({
+ report_id: bugReportFiles.report_id,
+ file_count: count().as('file_count')
+ })
+ .from(bugReportFiles)
+ .groupBy(bugReportFiles.report_id)
+ .as('fc');
+
+ const reports = await db
+ .select({
+ id: bugReports.id,
+ name: bugReports.name,
+ email: bugReports.email,
+ hostname: bugReports.hostname,
+ os_user: bugReports.os_user,
+ status: bugReports.status,
+ created_at: bugReports.created_at,
+ file_count: sql`COALESCE(${fileCountSubquery.file_count}, 0)`.as('file_count')
+ })
+ .from(bugReports)
+ .leftJoin(fileCountSubquery, eq(bugReports.id, fileCountSubquery.report_id))
+ .where(where)
+ .orderBy(desc(bugReports.created_at))
+ .limit(pageSize)
+ .offset((page - 1) * pageSize);
+
+ return {
+ reports: reports.map((r) => ({
+ ...r,
+ created_at: r.created_at.toISOString()
+ })),
+ pagination: { page, pageSize, total, totalPages },
+ filters: { status, search }
+ };
+};
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
new file mode 100644
index 0000000..03f58a4
--- /dev/null
+++ b/dashboard/src/routes/+page.svelte
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+ e.key === 'Enter' && applyFilters()}
+ class="w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+
+
+ All statuses
+ New
+ In Review
+ Resolved
+ Closed
+
+
+
+ Filter
+
+ {#if data.filters.search || data.filters.status}
+
+ Clear
+
+ {/if}
+
+
+
+
+
+
+
+ ID
+ Hostname
+ User
+ Reporter
+ Status
+ Files
+ Created
+
+
+
+ {#each data.reports as report (report.id)}
+ goto(`/reports/${report.id}`)}
+ >
+ #{report.id}
+ {report.hostname || '—'}
+ {report.os_user || '—'}
+
+ {report.name}
+ {report.email}
+
+
+
+ {statusLabels[report.status]}
+
+
+
+ {#if report.file_count > 0}
+
+
+ {report.file_count}
+
+ {:else}
+ —
+ {/if}
+
+ {formatDate(report.created_at)}
+
+ {:else}
+
+
+ No reports found.
+
+
+ {/each}
+
+
+
+
+
+ {#if data.pagination.totalPages > 1}
+
+
+ Showing {(data.pagination.page - 1) * data.pagination.pageSize + 1} to {Math.min(
+ data.pagination.page * data.pagination.pageSize,
+ data.pagination.total
+ )} of {data.pagination.total} reports
+
+
+ goToPage(data.pagination.page - 1)}
+ disabled={data.pagination.page <= 1}
+ class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
+ >
+
+
+ {#each Array.from({ length: data.pagination.totalPages }, (_, i) => i + 1) as p}
+ {#if p === 1 || p === data.pagination.totalPages || (p >= data.pagination.page - 1 && p <= data.pagination.page + 1)}
+ goToPage(p)}
+ class="inline-flex h-9 w-9 items-center justify-center rounded-md text-sm {p ===
+ data.pagination.page
+ ? 'bg-primary text-primary-foreground'
+ : 'border border-input hover:bg-accent'}"
+ >
+ {p}
+
+ {:else if p === data.pagination.page - 2 || p === data.pagination.page + 2}
+ ...
+ {/if}
+ {/each}
+ goToPage(data.pagination.page + 1)}
+ disabled={data.pagination.page >= data.pagination.totalPages}
+ class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
+ >
+
+
+
+
+ {/if}
+
diff --git a/dashboard/src/routes/api/reports/[id]/download/+server.ts b/dashboard/src/routes/api/reports/[id]/download/+server.ts
new file mode 100644
index 0000000..51595c2
--- /dev/null
+++ b/dashboard/src/routes/api/reports/[id]/download/+server.ts
@@ -0,0 +1,68 @@
+import { error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports, bugReportFiles } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+import JSZip from 'jszip';
+
+export const GET: RequestHandler = async ({ params }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const [report] = await db
+ .select()
+ .from(bugReports)
+ .where(eq(bugReports.id, id))
+ .limit(1);
+
+ if (!report) throw error(404, 'Report not found');
+
+ const files = await db
+ .select()
+ .from(bugReportFiles)
+ .where(eq(bugReportFiles.report_id, id));
+
+ const zip = new JSZip();
+
+ // Add report metadata as text file
+ const reportText = [
+ `Bug Report #${report.id}`,
+ `========================`,
+ ``,
+ `Name: ${report.name}`,
+ `Email: ${report.email}`,
+ `Hostname: ${report.hostname}`,
+ `OS User: ${report.os_user}`,
+ `HWID: ${report.hwid}`,
+ `IP: ${report.submitter_ip}`,
+ `Status: ${report.status}`,
+ `Created: ${report.created_at.toISOString()}`,
+ `Updated: ${report.updated_at.toISOString()}`,
+ ``,
+ `Description:`,
+ `------------`,
+ report.description,
+ ``,
+ ...(report.system_info
+ ? [`System Info:`, `------------`, JSON.stringify(report.system_info, null, 2)]
+ : [])
+ ].join('\n');
+
+ zip.file('report.txt', reportText);
+
+ // Add all files
+ for (const file of files) {
+ const folder = file.file_role;
+ zip.file(`${folder}/${file.filename}`, file.data);
+ }
+
+ const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });
+
+ return new Response(new Uint8Array(zipBuffer), {
+ headers: {
+ 'Content-Type': 'application/zip',
+ 'Content-Disposition': `attachment; filename="report-${id}.zip"`,
+ 'Content-Length': String(zipBuffer.length)
+ }
+ });
+};
diff --git a/dashboard/src/routes/api/reports/[id]/files/[fileId]/+server.ts b/dashboard/src/routes/api/reports/[id]/files/[fileId]/+server.ts
new file mode 100644
index 0000000..980bdfd
--- /dev/null
+++ b/dashboard/src/routes/api/reports/[id]/files/[fileId]/+server.ts
@@ -0,0 +1,28 @@
+import { error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { bugReportFiles } from '$lib/schema';
+import { eq, and } from 'drizzle-orm';
+
+export const GET: RequestHandler = async ({ params }) => {
+ const reportId = Number(params.id);
+ const fileId = Number(params.fileId);
+
+ if (isNaN(reportId) || isNaN(fileId)) throw error(400, 'Invalid ID');
+
+ const [file] = await db
+ .select()
+ .from(bugReportFiles)
+ .where(and(eq(bugReportFiles.id, fileId), eq(bugReportFiles.report_id, reportId)))
+ .limit(1);
+
+ if (!file) throw error(404, 'File not found');
+
+ return new Response(new Uint8Array(file.data), {
+ headers: {
+ 'Content-Type': file.mime_type,
+ 'Content-Disposition': `inline; filename="${file.filename}"`,
+ 'Content-Length': String(file.file_size)
+ }
+ });
+};
diff --git a/dashboard/src/routes/reports/[id]/+page.server.ts b/dashboard/src/routes/reports/[id]/+page.server.ts
new file mode 100644
index 0000000..83dea88
--- /dev/null
+++ b/dashboard/src/routes/reports/[id]/+page.server.ts
@@ -0,0 +1,44 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports, bugReportFiles } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+import { error } from '@sveltejs/kit';
+
+export const load: PageServerLoad = async ({ params }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const [report] = await db
+ .select()
+ .from(bugReports)
+ .where(eq(bugReports.id, id))
+ .limit(1);
+
+ if (!report) throw error(404, 'Report not found');
+
+ const files = await db
+ .select({
+ id: bugReportFiles.id,
+ report_id: bugReportFiles.report_id,
+ file_role: bugReportFiles.file_role,
+ filename: bugReportFiles.filename,
+ mime_type: bugReportFiles.mime_type,
+ file_size: bugReportFiles.file_size,
+ created_at: bugReportFiles.created_at
+ })
+ .from(bugReportFiles)
+ .where(eq(bugReportFiles.report_id, id));
+
+ return {
+ report: {
+ ...report,
+ system_info: report.system_info ? JSON.stringify(report.system_info, null, 2) : null,
+ created_at: report.created_at.toISOString(),
+ updated_at: report.updated_at.toISOString()
+ },
+ files: files.map((f) => ({
+ ...f,
+ created_at: f.created_at.toISOString()
+ }))
+ };
+};
diff --git a/dashboard/src/routes/reports/[id]/+page.svelte b/dashboard/src/routes/reports/[id]/+page.svelte
new file mode 100644
index 0000000..eb43c0a
--- /dev/null
+++ b/dashboard/src/routes/reports/[id]/+page.svelte
@@ -0,0 +1,279 @@
+
+
+
+
+
+
+ Back to Reports
+
+
+
+
+
+
+
+
Report #{data.report.id}
+
+ {statusLabels[data.report.status]}
+
+
+
+ Submitted by {data.report.name} ({data.report.email})
+
+
+
+
+
updateStatus(e.currentTarget.value)}
+ disabled={statusUpdating}
+ class="rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
+ >
+ New
+ In Review
+ Resolved
+ Closed
+
+
+
+
+
+ ZIP
+
+
+
+
(showDeleteDialog = true)}
+ class="inline-flex items-center gap-1.5 rounded-md border border-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground bg-destructive hover:bg-destructive/80"
+ >
+
+ Delete
+
+
+
+
+
+
+
+
Hostname
+
{data.report.hostname || '—'}
+
+
+
OS User
+
{data.report.os_user || '—'}
+
+
+
HWID
+
{data.report.hwid || '—'}
+
+
+
IP Address
+
{data.report.submitter_ip || '—'}
+
+
+
Created
+
{formatDate(data.report.created_at)}
+
+
+
Updated
+
{formatDate(data.report.updated_at)}
+
+
+
+
+
+
+
Description
+
{data.report.description}
+
+
+
+ {#if data.report.system_info}
+
+
(showSystemInfo = !showSystemInfo)}
+ class="flex w-full items-center gap-2 px-6 py-4 text-left text-sm font-medium text-muted-foreground hover:text-foreground"
+ >
+ {#if showSystemInfo}
+
+ {:else}
+
+ {/if}
+ System Information
+
+ {#if showSystemInfo}
+
+
{data.report.system_info}
+
+ {/if}
+
+ {/if}
+
+
+
+
+ Attached Files ({data.files.length})
+
+ {#if data.files.length > 0}
+
+ {@const screenshots = data.files.filter((f) => f.file_role === 'screenshot')}
+ {#if screenshots.length > 0}
+
+ {#each screenshots as file}
+
+
+
{file.filename}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+ Role
+ Filename
+ Size
+ Action
+
+
+
+ {#each data.files as file}
+ {@const Icon = roleIcons[file.file_role] || FileText}
+
+
+
+
+ {roleLabels[file.file_role] || file.file_role}
+
+
+ {file.filename}
+ {formatBytes(file.file_size)}
+
+
+
+ Download
+
+
+
+ {/each}
+
+
+
+ {:else}
+
No files attached.
+ {/if}
+
+
+
+
+{#if showDeleteDialog}
+
+ e.key === 'Escape' && (showDeleteDialog = false)}
+ >
+
+
(showDeleteDialog = false)}>
+
+
Delete Report
+
+ Are you sure you want to delete report #{data.report.id}? This will permanently remove the
+ report and all attached files. This action cannot be undone.
+
+
+ (showDeleteDialog = false)}
+ class="rounded-md border border-input px-4 py-2 text-sm hover:bg-accent"
+ >
+ Cancel
+
+
+ {deleting ? 'Deleting...' : 'Delete'}
+
+
+
+
+{/if}
diff --git a/dashboard/src/routes/reports/[id]/+server.ts b/dashboard/src/routes/reports/[id]/+server.ts
new file mode 100644
index 0000000..1b67138
--- /dev/null
+++ b/dashboard/src/routes/reports/[id]/+server.ts
@@ -0,0 +1,39 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+
+export const PATCH: RequestHandler = async ({ params, request }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const body = await request.json();
+ const { status } = body;
+
+ if (!['new', 'in_review', 'resolved', 'closed'].includes(status)) {
+ throw error(400, 'Invalid status');
+ }
+
+ const [result] = await db
+ .update(bugReports)
+ .set({ status })
+ .where(eq(bugReports.id, id));
+
+ if (result.affectedRows === 0) throw error(404, 'Report not found');
+
+ return json({ success: true });
+};
+
+export const DELETE: RequestHandler = async ({ params }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const [result] = await db
+ .delete(bugReports)
+ .where(eq(bugReports.id, id));
+
+ if (result.affectedRows === 0) throw error(404, 'Report not found');
+
+ return json({ success: true });
+};
diff --git a/dashboard/svelte.config.js b/dashboard/svelte.config.js
new file mode 100644
index 0000000..b4b7de8
--- /dev/null
+++ b/dashboard/svelte.config.js
@@ -0,0 +1,12 @@
+import adapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter()
+ }
+};
+
+export default config;
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
new file mode 100644
index 0000000..a8f10c8
--- /dev/null
+++ b/dashboard/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
new file mode 100644
index 0000000..bf699a8
--- /dev/null
+++ b/dashboard/vite.config.ts
@@ -0,0 +1,7 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import tailwindcss from '@tailwindcss/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()]
+});
diff --git a/server/docker-compose.yml b/server/docker-compose.yml
index ff3f75c..8514d85 100644
--- a/server/docker-compose.yml
+++ b/server/docker-compose.yml
@@ -36,5 +36,19 @@ services:
mysql:
condition: service_healthy
+ dashboard:
+ build: ../dashboard
+ ports:
+ - "${DASHBOARD_PORT:-3001}:3000"
+ environment:
+ MYSQL_HOST: mysql
+ MYSQL_PORT: 3306
+ MYSQL_USER: ${MYSQL_USER:-emly}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD}
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
+ depends_on:
+ mysql:
+ condition: service_healthy
+
volumes:
mysql_data: