From c6c27f2f302267e3fecfff32ac2006a325f2a13e Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Sat, 14 Feb 2026 21:35:27 +0100 Subject: [PATCH] feat: initialize dashboard with bug reporting system - Add Tailwind CSS for styling and custom theme variables. - Create HTML structure for the dashboard with dark mode support. - Implement database schema for bug reports and associated files using Drizzle ORM. - Set up database connection with MySQL and environment variables. - Create utility functions for class names, byte formatting, and date formatting. - Develop error handling page for the dashboard. - Implement layout and routing for the dashboard, including pagination and filtering for bug reports. - Create API endpoints for downloading reports and files. - Add functionality for viewing, updating, and deleting bug reports. - Set up Docker configuration for the dashboard service. - Include TypeScript configuration and Vite setup for the project. --- DOCUMENTATION.md | 16 + dashboard/.env.example | 6 + dashboard/.gitignore | 5 + dashboard/Dockerfile | 9 + dashboard/drizzle.config.ts | 13 + dashboard/package.json | 34 +++ dashboard/src/app.css | 42 +++ dashboard/src/app.html | 12 + dashboard/src/lib/schema.ts | 54 ++++ dashboard/src/lib/server/db.ts | 16 + dashboard/src/lib/utils.ts | 39 +++ dashboard/src/routes/+error.svelte | 14 + dashboard/src/routes/+layout.server.ts | 15 + dashboard/src/routes/+layout.svelte | 61 ++++ dashboard/src/routes/+page.server.ts | 77 +++++ dashboard/src/routes/+page.svelte | 178 +++++++++++ .../api/reports/[id]/download/+server.ts | 68 +++++ .../reports/[id]/files/[fileId]/+server.ts | 28 ++ .../src/routes/reports/[id]/+page.server.ts | 44 +++ .../src/routes/reports/[id]/+page.svelte | 279 ++++++++++++++++++ dashboard/src/routes/reports/[id]/+server.ts | 39 +++ dashboard/svelte.config.js | 12 + dashboard/tsconfig.json | 14 + dashboard/vite.config.ts | 7 + server/docker-compose.yml | 14 + 25 files changed, 1096 insertions(+) create mode 100644 dashboard/.env.example create mode 100644 dashboard/.gitignore create mode 100644 dashboard/Dockerfile create mode 100644 dashboard/drizzle.config.ts create mode 100644 dashboard/package.json create mode 100644 dashboard/src/app.css create mode 100644 dashboard/src/app.html create mode 100644 dashboard/src/lib/schema.ts create mode 100644 dashboard/src/lib/server/db.ts create mode 100644 dashboard/src/lib/utils.ts create mode 100644 dashboard/src/routes/+error.svelte create mode 100644 dashboard/src/routes/+layout.server.ts create mode 100644 dashboard/src/routes/+layout.svelte create mode 100644 dashboard/src/routes/+page.server.ts create mode 100644 dashboard/src/routes/+page.svelte create mode 100644 dashboard/src/routes/api/reports/[id]/download/+server.ts create mode 100644 dashboard/src/routes/api/reports/[id]/files/[fileId]/+server.ts create mode 100644 dashboard/src/routes/reports/[id]/+page.server.ts create mode 100644 dashboard/src/routes/reports/[id]/+page.svelte create mode 100644 dashboard/src/routes/reports/[id]/+server.ts create mode 100644 dashboard/svelte.config.js create mode 100644 dashboard/tsconfig.json create mode 100644 dashboard/vite.config.ts 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 @@ + + +
+ + + + +
+ +
+

+ {#if $page.url.pathname === '/'} + Bug Reports + {:else if $page.url.pathname.startsWith('/reports/')} + Report Detail + {:else} + Dashboard + {/if} +

+ {#if data.newCount > 0} +
+ + {data.newCount} new {data.newCount === 1 ? 'report' : 'reports'} +
+ {/if} +
+ + +
+ {@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" + /> +
+ + + {#if data.filters.search || data.filters.status} + + {/if} +
+ + +
+ + + + + + + + + + + + + + {#each data.reports as report (report.id)} + goto(`/reports/${report.id}`)} + > + + + + + + + + + {:else} + + + + {/each} + +
IDHostnameUserReporterStatusFilesCreated
#{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)}
+ No reports found. +
+
+ + + {#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 +

+
+ + {#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)} + + {:else if p === data.pagination.page - 2 || p === data.pagination.page + 2} + ... + {/if} + {/each} + +
+
+ {/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}) +

+
+
+ + + + + + + ZIP + + + + +
+
+ + +
+
+

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} +
+ + {#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} +
{file.filename}
+
+ {/each} +
+ {/if} + +
+ + + + + + + + + + + {#each data.files as file} + {@const Icon = roleIcons[file.file_role] || FileText} + + + + + + + {/each} + +
RoleFilenameSizeAction
+ + + {roleLabels[file.file_role] || file.file_role} + + {file.filename}{formatBytes(file.file_size)} + + + Download + +
+
+ {: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. +

+
+ + +
+
+
+{/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: