Files
EMLy/server/dashboard/src/routes/reports/[id]/+page.svelte
Flavio Fois 492db8fcf8 feat: initialize dashboard with bug reporting functionality
- Add HTML structure for the dashboard application.
- Create database schema for bug reports and associated files.
- Implement database connection using Drizzle ORM with MySQL.
- Add utility functions for class names, byte formatting, and date formatting.
- Create error handling page for the application.
- Implement layout and main page structure with navigation and report listing.
- Add server-side logic for loading reports with pagination and filtering.
- Create report detail page with metadata, description, and file attachments.
- Implement API endpoints for downloading reports and files, refreshing report counts, and managing report statuses.
- Set up SvelteKit configuration and TypeScript support.
- Configure Vite for SvelteKit and Tailwind CSS integration.
- Update Docker Compose configuration for the dashboard service.
- Create systemd service for managing the dashboard server.
2026-02-14 23:01:08 +01:00

280 lines
8.8 KiB
Svelte

<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}