Dashboard revamp

This commit is contained in:
Wayne
2025-07-24 14:43:24 +03:00
parent 69846c10c0
commit bef92cb7d4
4 changed files with 267 additions and 320 deletions

View File

@@ -1,121 +1,121 @@
@import "tailwindcss";
@import 'tailwindcss';
@import "tw-animate-css";
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -5,9 +5,21 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
export type WithoutChild<T> = T extends { child?: any; } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildren<T> = T extends { children?: any; } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null; };

View File

@@ -52,10 +52,17 @@ export const load: PageServerLoad = async (event) => {
}
};
const [stats, ingestionHistory, ingestionSources, recentSyncs] = await Promise.all([
fetchStats(),
fetchIngestionHistory(),
fetchIngestionSources(),
fetchRecentSyncs()
]);
return {
stats: fetchStats(),
ingestionHistory: fetchIngestionHistory(),
ingestionSources: fetchIngestionSources(),
recentSyncs: fetchRecentSyncs()
stats,
ingestionHistory,
ingestionSources,
recentSyncs
};
};

View File

@@ -1,226 +1,154 @@
<script lang="ts">
import type { PageData } from './$types';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import * as Card from '$lib/components/ui/card/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Chart from '$lib/components/ui/chart/index.js';
import { BarChart, PieChart, LineChart } from 'layerchart';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import { BarChart } from 'layerchart';
import { formatBytes } from '$lib/utils';
let { data }: { data: PageData } = $props();
export let data: PageData;
const { stats, ingestionHistory, ingestionSources, recentSyncs } = data;
const chartConfig = {
storageUsed: {
label: 'Storage Used'
},
count: {
label: 'Emails'
label: 'Emails Ingested',
color: '#2563eb'
}
} satisfies Chart.ChartConfig;
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
</script>
<svelte:head>
<title>Dashboard - OpenArchiver</title>
<meta name="description" content="System health and activity overview." />
<meta name="description" content="Overview of your email archive." />
</svelte:head>
<div class="container mx-auto">
<h1 class="mb-6 text-3xl font-bold">Dashboard</h1>
<div class="flex-1 space-y-4 p-8 pt-6">
<div class="flex items-center justify-between space-y-2">
<h2 class="text-3xl font-bold tracking-tight">Dashboard</h2>
</div>
<!-- Ingestion Overview -->
<section class="mb-8">
<h2 class="mb-4 text-2xl font-semibold">Ingestion Overview</h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{#await stats}
<p>Loading stats...</p>
{:then statsData}
{#if statsData}
<Card>
<CardHeader>
<CardTitle>Total Emails Archived</CardTitle>
</CardHeader>
<CardContent>
<p class="text-4xl font-bold">{statsData.totalEmailsArchived.toLocaleString()}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Total Storage Used</CardTitle>
</CardHeader>
<CardContent>
<p class="text-4xl font-bold">{formatBytes(statsData.totalStorageUsed)}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Failed Ingestions (7 days)</CardTitle>
</CardHeader>
<CardContent>
<p
class="text-4xl font-bold"
class:text-red-500={statsData.failedIngestionsLast7Days > 0}
>
{statsData.failedIngestionsLast7Days}
</p>
</CardContent>
</Card>
{/if}
{/await}
{#if data.stats}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">Total Emails Archived</Card.Title>
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{data.stats.totalEmailsArchived}</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">Total Storage Used</Card.Title>
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{formatBytes(data.stats.totalStorageUsed)}</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">Failed Ingestions (Last 7 Days)</Card.Title>
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{data.stats.failedIngestionsLast7Days}</div>
</Card.Content>
</Card.Root>
</div>
</section>
{/if}
<!-- Charts -->
<section class="mb-8 grid gap-8 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Emails Archived (Last 30 Days)</CardTitle>
</CardHeader>
<CardContent>
{#await ingestionHistory}
<p>Loading chart...</p>
{:then historyData}
{#if historyData}
<div class="h-64">
<Chart.Container config={chartConfig}>
<LineChart data={historyData.history} x="date" y="count" />
</Chart.Container>
</div>
{/if}
{/await}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Storage Usage by Source</CardTitle>
</CardHeader>
<CardContent>
{#await ingestionSources}
<p>Loading chart...</p>
{:then sourceData}
{#if sourceData}
<div class="h-64">
<Chart.Container config={chartConfig}>
<PieChart
data={sourceData}
key="id"
label="name"
value="storageUsed"
innerRadius={0.5}
/>
</Chart.Container>
</div>
{/if}
{/await}
</CardContent>
</Card>
</section>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card.Root class="col-span-4">
<Card.Header>
<Card.Title>Ingestion History</Card.Title>
</Card.Header>
<Card.Content class="pl-2">
{#if data.ingestionHistory && data.ingestionHistory.history.length > 0}
<Chart.Container config={chartConfig} class="min-h-[200px] w-full">
<BarChart
data={data.ingestionHistory.history}
x="date"
y="count"
axis="x"
seriesLayout="group"
props={{
xAxis: {
format: (d) =>
new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
}}
>
{#snippet tooltip()}
<Chart.Tooltip />
{/snippet}
</BarChart>
</Chart.Container>
{:else}
<p>No ingestion history available.</p>
{/if}
</Card.Content>
</Card.Root>
<!-- Ingestion Source Status -->
<section class="mb-8">
<h2 class="mb-4 text-2xl font-semibold">Ingestion Source Status</h2>
<Card>
<CardContent>
{#await ingestionSources}
<p>Loading sources...</p>
{:then sourceData}
{#if sourceData}
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Status</TableHead>
<TableHead class="text-right">Storage Used</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each sourceData as source}
<TableRow>
<TableCell>{source.name}</TableCell>
<TableCell>{source.provider}</TableCell>
<TableCell>
<Badge
class={source.status === 'Active'
? 'bg-green-500'
: source.status === 'Error'
? 'bg-red-500'
: 'bg-gray-500'}
>
{source.status}
</Badge>
</TableCell>
<TableCell class="text-right">{formatBytes(source.storageUsed)}</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{/if}
{/await}
</CardContent>
</Card>
</section>
<Card.Root class="col-span-3">
<Card.Header>
<Card.Title>Recent Syncs</Card.Title>
<Card.Description>Most recent sync activities.</Card.Description>
</Card.Header>
<Card.Content>
{#if data.recentSyncs && data.recentSyncs.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Source</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Processed</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.recentSyncs as sync}
<Table.Row>
<Table.Cell class="font-medium">{sync.sourceName}</Table.Cell>
<Table.Cell>{sync.status}</Table.Cell>
<Table.Cell>{sync.emailsProcessed}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<p>No recent syncs.</p>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Recent Sync Jobs -->
<section>
<h2 class="mb-4 text-2xl font-semibold">Recent Sync Jobs</h2>
<Card>
<CardContent>
{#await recentSyncs}
<p>Loading syncs...</p>
{:then syncData}
{#if syncData && syncData.length > 0}
<Table>
<TableHeader>
<TableRow>
<TableHead>Source</TableHead>
<TableHead>Start Time</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Emails Processed</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each syncData as sync}
<TableRow>
<TableCell>{sync.sourceName}</TableCell>
<TableCell>{new Date(sync.startTime).toLocaleString()}</TableCell>
<TableCell>{sync.duration}s</TableCell>
<TableCell>{sync.emailsProcessed}</TableCell>
<TableCell>
<Badge class={sync.status === 'Completed' ? 'bg-green-500' : 'bg-red-500'}>
{sync.status}
</Badge>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{:else}
<p class="p-4 text-center">No recent sync jobs found.</p>
{/if}
{/await}
</CardContent>
</Card>
</section>
<Card.Root>
<Card.Header>
<Card.Title>Ingestion Sources</Card.Title>
<Card.Description>Overview of your ingestion sources.</Card.Description>
</Card.Header>
<Card.Content>
{#if data.ingestionSources && data.ingestionSources.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Provider</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head class="text-right">Storage Used</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.ingestionSources as source}
<Table.Row>
<Table.Cell class="font-medium">{source.name}</Table.Cell>
<Table.Cell>{source.provider}</Table.Cell>
<Table.Cell>{source.status}</Table.Cell>
<Table.Cell class="text-right">{formatBytes(source.storageUsed)}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<p>No ingestion sources found.</p>
{/if}
</Card.Content>
</Card.Root>
</div>