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.
This commit is contained in:
@@ -686,6 +686,22 @@ A separate API server (`server/` directory) receives bug reports:
|
|||||||
- **Rate limiting**: HWID-based, configurable (default 5 reports per 24h)
|
- **Rate limiting**: HWID-based, configurable (default 5 reports per 24h)
|
||||||
- **Endpoints**: `POST /api/bug-reports` (client), `GET/DELETE /api/admin/bug-reports` (admin)
|
- **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)
|
#### Configuration (config.ini)
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
|
|||||||
6
dashboard/.env.example
Normal file
6
dashboard/.env.example
Normal file
@@ -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
|
||||||
5
dashboard/.gitignore
vendored
Normal file
5
dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
bun.lock
|
||||||
9
dashboard/Dockerfile
Normal file
9
dashboard/Dockerfile
Normal file
@@ -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"]
|
||||||
13
dashboard/drizzle.config.ts
Normal file
13
dashboard/drizzle.config.ts
Normal file
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
34
dashboard/package.json
Normal file
34
dashboard/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
42
dashboard/src/app.css
Normal file
42
dashboard/src/app.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
12
dashboard/src/app.html
Normal file
12
dashboard/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
dashboard/src/lib/schema.ts
Normal file
54
dashboard/src/lib/schema.ts
Normal file
@@ -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'];
|
||||||
16
dashboard/src/lib/server/db.ts
Normal file
16
dashboard/src/lib/server/db.ts
Normal file
@@ -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' });
|
||||||
39
dashboard/src/lib/utils.ts
Normal file
39
dashboard/src/lib/utils.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
new: 'New',
|
||||||
|
in_review: 'In Review',
|
||||||
|
resolved: 'Resolved',
|
||||||
|
closed: 'Closed'
|
||||||
|
};
|
||||||
14
dashboard/src/routes/+error.svelte
Normal file
14
dashboard/src/routes/+error.svelte
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||||
|
<h1 class="text-6xl font-bold text-muted-foreground">{$page.status}</h1>
|
||||||
|
<p class="mt-4 text-lg text-muted-foreground">{$page.error?.message || 'Something went wrong'}</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mt-6 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Back to Reports
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
15
dashboard/src/routes/+layout.server.ts
Normal file
15
dashboard/src/routes/+layout.server.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
61
dashboard/src/routes/+layout.svelte
Normal file
61
dashboard/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { Bug, LayoutDashboard } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let { children, data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="flex w-56 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
|
||||||
|
<div class="flex items-center gap-2 border-b border-sidebar-border px-4 py-4">
|
||||||
|
<Bug class="h-6 w-6 text-primary" />
|
||||||
|
<span class="text-lg font-semibold">EMLy Dashboard</span>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-2 py-3">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors {$page.url
|
||||||
|
.pathname === '/'
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'}"
|
||||||
|
>
|
||||||
|
<LayoutDashboard class="h-4 w-4" />
|
||||||
|
Reports
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="border-t border-sidebar-border px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
EMLy Bug Reports
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<!-- Top bar -->
|
||||||
|
<header
|
||||||
|
class="flex h-14 items-center justify-between border-b border-border bg-card px-6"
|
||||||
|
>
|
||||||
|
<h1 class="text-lg font-semibold">
|
||||||
|
{#if $page.url.pathname === '/'}
|
||||||
|
Bug Reports
|
||||||
|
{:else if $page.url.pathname.startsWith('/reports/')}
|
||||||
|
Report Detail
|
||||||
|
{:else}
|
||||||
|
Dashboard
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
{#if data.newCount > 0}
|
||||||
|
<div class="flex items-center gap-2 rounded-md bg-blue-500/10 px-3 py-1.5 text-sm text-blue-400">
|
||||||
|
<span class="inline-block h-2 w-2 rounded-full bg-blue-400"></span>
|
||||||
|
{data.newCount} new {data.newCount === 1 ? 'report' : 'reports'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Page content -->
|
||||||
|
<main class="flex-1 overflow-auto p-6">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
77
dashboard/src/routes/+page.server.ts
Normal file
77
dashboard/src/routes/+page.server.ts
Normal file
@@ -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<number>`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 }
|
||||||
|
};
|
||||||
|
};
|
||||||
178
dashboard/src/routes/+page.svelte
Normal file
178
dashboard/src/routes/+page.svelte
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { statusColors, statusLabels, formatDate } from '$lib/utils';
|
||||||
|
import { Search, ChevronLeft, ChevronRight, Filter, Paperclip } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let searchInput = $state('');
|
||||||
|
let statusFilter = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
searchInput = data.filters.search;
|
||||||
|
statusFilter = data.filters.status;
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (searchInput) params.set('search', searchInput);
|
||||||
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
|
params.set('page', '1');
|
||||||
|
goto(`/?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(p: number) {
|
||||||
|
const params = new URLSearchParams($page.url.searchParams);
|
||||||
|
params.set('page', String(p));
|
||||||
|
goto(`/?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
searchInput = '';
|
||||||
|
statusFilter = '';
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||||
|
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search hostname, user, name, email..."
|
||||||
|
bind:value={searchInput}
|
||||||
|
onkeydown={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
bind:value={statusFilter}
|
||||||
|
onchange={applyFilters}
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="new">New</option>
|
||||||
|
<option value="in_review">In Review</option>
|
||||||
|
<option value="resolved">Resolved</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onclick={applyFilters}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<Filter class="h-4 w-4" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
{#if data.filters.search || data.filters.status}
|
||||||
|
<button
|
||||||
|
onclick={clearFilters}
|
||||||
|
class="rounded-md border border-input px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="overflow-hidden rounded-lg border border-border">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-border bg-muted/50">
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">ID</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Hostname</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">User</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Reporter</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Files</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.reports as report (report.id)}
|
||||||
|
<tr
|
||||||
|
class="border-b border-border transition-colors hover:bg-muted/30 cursor-pointer"
|
||||||
|
onclick={() => goto(`/reports/${report.id}`)}
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 font-mono text-muted-foreground">#{report.id}</td>
|
||||||
|
<td class="px-4 py-3">{report.hostname || '—'}</td>
|
||||||
|
<td class="px-4 py-3">{report.os_user || '—'}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div>{report.name}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{report.email}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full border px-2 py-0.5 text-xs font-medium {statusColors[report.status]}"
|
||||||
|
>
|
||||||
|
{statusLabels[report.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{#if report.file_count > 0}
|
||||||
|
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Paperclip class="h-3.5 w-3.5" />
|
||||||
|
{report.file_count}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground">{formatDate(report.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-4 py-12 text-center text-muted-foreground">
|
||||||
|
No reports found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{#if data.pagination.totalPages > 1}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
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
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={() => 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"
|
||||||
|
>
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{#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)}
|
||||||
|
<button
|
||||||
|
onclick={() => 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}
|
||||||
|
</button>
|
||||||
|
{:else if p === data.pagination.page - 2 || p === data.pagination.page + 2}
|
||||||
|
<span class="px-1 text-muted-foreground">...</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
onclick={() => 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"
|
||||||
|
>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
68
dashboard/src/routes/api/reports/[id]/download/+server.ts
Normal file
68
dashboard/src/routes/api/reports/[id]/download/+server.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
44
dashboard/src/routes/reports/[id]/+page.server.ts
Normal file
44
dashboard/src/routes/reports/[id]/+page.server.ts
Normal file
@@ -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()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
};
|
||||||
279
dashboard/src/routes/reports/[id]/+page.svelte
Normal file
279
dashboard/src/routes/reports/[id]/+page.svelte
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
|
import { statusColors, statusLabels, formatDate, formatBytes } from '$lib/utils';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
|
Monitor,
|
||||||
|
Settings,
|
||||||
|
Database,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Mail
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
let showDeleteDialog = $state(false);
|
||||||
|
let showSystemInfo = $state(false);
|
||||||
|
let statusUpdating = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
const roleIcons: Record<string, typeof FileText> = {
|
||||||
|
screenshot: Image,
|
||||||
|
mail_file: Mail,
|
||||||
|
localstorage: Database,
|
||||||
|
config: Settings,
|
||||||
|
system_info: Monitor
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
screenshot: 'Screenshot',
|
||||||
|
mail_file: 'Mail File',
|
||||||
|
localstorage: 'Local Storage',
|
||||||
|
config: 'Config',
|
||||||
|
system_info: 'System Info'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateStatus(newStatus: string) {
|
||||||
|
statusUpdating = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/reports/${data.report.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: newStatus })
|
||||||
|
});
|
||||||
|
if (res.ok) await invalidateAll();
|
||||||
|
} finally {
|
||||||
|
statusUpdating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReport() {
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/reports/${data.report.id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) goto('/');
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Back button -->
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
Back to Reports
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Header card -->
|
||||||
|
<div class="rounded-lg border border-border bg-card p-6">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-xl font-semibold">Report #{data.report.id}</h2>
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium {statusColors[data.report.status]}"
|
||||||
|
>
|
||||||
|
{statusLabels[data.report.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Submitted by {data.report.name} ({data.report.email})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Status selector -->
|
||||||
|
<select
|
||||||
|
value={data.report.status}
|
||||||
|
onchange={(e) => 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"
|
||||||
|
>
|
||||||
|
<option value="new">New</option>
|
||||||
|
<option value="in_review">In Review</option>
|
||||||
|
<option value="resolved">Resolved</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Download ZIP -->
|
||||||
|
<a
|
||||||
|
href="/api/reports/{data.report.id}/download"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<Download class="h-4 w-4" />
|
||||||
|
ZIP
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
onclick={() => (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"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata grid -->
|
||||||
|
<div class="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Hostname</p>
|
||||||
|
<p class="mt-1 text-sm">{data.report.hostname || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">OS User</p>
|
||||||
|
<p class="mt-1 text-sm">{data.report.os_user || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">HWID</p>
|
||||||
|
<p class="mt-1 font-mono text-xs break-all">{data.report.hwid || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">IP Address</p>
|
||||||
|
<p class="mt-1 text-sm">{data.report.submitter_ip || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Created</p>
|
||||||
|
<p class="mt-1 text-sm">{formatDate(data.report.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Updated</p>
|
||||||
|
<p class="mt-1 text-sm">{formatDate(data.report.updated_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="rounded-lg border border-border bg-card p-6">
|
||||||
|
<h3 class="text-sm font-medium text-muted-foreground">Description</h3>
|
||||||
|
<p class="mt-2 whitespace-pre-wrap text-sm">{data.report.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Info -->
|
||||||
|
{#if data.report.system_info}
|
||||||
|
<div class="rounded-lg border border-border bg-card">
|
||||||
|
<button
|
||||||
|
onclick={() => (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}
|
||||||
|
<ChevronDown class="h-4 w-4" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
System Information
|
||||||
|
</button>
|
||||||
|
{#if showSystemInfo}
|
||||||
|
<div class="border-t border-border px-6 py-4">
|
||||||
|
<pre class="overflow-auto rounded-md bg-muted/50 p-4 text-xs">{data.report.system_info}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Files -->
|
||||||
|
<div class="rounded-lg border border-border bg-card p-6">
|
||||||
|
<h3 class="mb-4 text-sm font-medium text-muted-foreground">
|
||||||
|
Attached Files ({data.files.length})
|
||||||
|
</h3>
|
||||||
|
{#if data.files.length > 0}
|
||||||
|
<!-- Screenshot previews -->
|
||||||
|
{@const screenshots = data.files.filter((f) => f.file_role === 'screenshot')}
|
||||||
|
{#if screenshots.length > 0}
|
||||||
|
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{#each screenshots as file}
|
||||||
|
<div class="overflow-hidden rounded-md border border-border">
|
||||||
|
<img
|
||||||
|
src="/api/reports/{data.report.id}/files/{file.id}"
|
||||||
|
alt={file.filename}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<div class="px-3 py-2 text-xs text-muted-foreground">{file.filename}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-md border border-border">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-border bg-muted/50">
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Role</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Filename</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Size</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-muted-foreground">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.files as file}
|
||||||
|
{@const Icon = roleIcons[file.file_role] || FileText}
|
||||||
|
<tr class="border-b border-border last:border-0">
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Icon class="h-3.5 w-3.5" />
|
||||||
|
{roleLabels[file.file_role] || file.file_role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 font-mono text-xs">{file.filename}</td>
|
||||||
|
<td class="px-4 py-2 text-muted-foreground">{formatBytes(file.file_size)}</td>
|
||||||
|
<td class="px-4 py-2 text-right">
|
||||||
|
<a
|
||||||
|
href="/api/reports/{data.report.id}/files/{file.id}"
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-input px-2 py-1 text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Download class="h-3 w-3" />
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No files attached.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete confirmation dialog -->
|
||||||
|
{#if showDeleteDialog}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && (showDeleteDialog = false)}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="absolute inset-0" onclick={() => (showDeleteDialog = false)}></div>
|
||||||
|
<div class="relative rounded-lg border border-border bg-card p-6 shadow-xl max-w-md w-full">
|
||||||
|
<h3 class="text-lg font-semibold">Delete Report</h3>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => (showDeleteDialog = false)}
|
||||||
|
class="rounded-md border border-input px-4 py-2 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={deleteReport}
|
||||||
|
disabled={deleting}
|
||||||
|
class="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/80 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
39
dashboard/src/routes/reports/[id]/+server.ts
Normal file
39
dashboard/src/routes/reports/[id]/+server.ts
Normal file
@@ -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 });
|
||||||
|
};
|
||||||
12
dashboard/svelte.config.js
Normal file
12
dashboard/svelte.config.js
Normal file
@@ -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;
|
||||||
14
dashboard/tsconfig.json
Normal file
14
dashboard/tsconfig.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
dashboard/vite.config.ts
Normal file
7
dashboard/vite.config.ts
Normal file
@@ -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()]
|
||||||
|
});
|
||||||
@@ -36,5 +36,19 @@ services:
|
|||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
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:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
|
|||||||
Reference in New Issue
Block a user