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.
This commit is contained in:
Flavio Fois
2026-02-14 23:01:08 +01:00
parent c2052595cb
commit 492db8fcf8
28 changed files with 25 additions and 1 deletions

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { statusColors, statusLabels, formatDate } from '$lib/utils';
import { Search, ChevronLeft, ChevronRight, Filter, Paperclip, RefreshCcw } 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('/');
}
async function refreshReports() {
try {
await invalidateAll();
} catch (err) {
console.error('Failed to refresh reports:', err);
}
}
</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>
<button
onclick={refreshReports}
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"
>
<RefreshCcw 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>