Dashboard charts refinement

This commit is contained in:
Wayne
2025-07-24 19:26:07 +03:00
parent c3bbc84b01
commit 7646f39721
11 changed files with 228 additions and 90 deletions

View File

@@ -21,6 +21,11 @@ class DashboardController {
const syncs = await dashboardService.getRecentSyncs();
res.json(syncs);
}
public async getIndexedInsights(req: Request, res: Response) {
const insights = await dashboardService.getIndexedInsights();
res.json(insights);
}
}
export const dashboardController = new DashboardController();

View File

@@ -12,6 +12,7 @@ export const createDashboardRouter = (authService: IAuthService): Router => {
router.get('/ingestion-history', dashboardController.getIngestionHistory);
router.get('/ingestion-sources', dashboardController.getIngestionSources);
router.get('/recent-syncs', dashboardController.getRecentSyncs);
router.get('/indexed-insights', dashboardController.getIndexedInsights);
return router;
};

View File

@@ -1,13 +1,17 @@
import { and, count, eq, gte, sql } from 'drizzle-orm';
import type { IndexedInsights } from '@open-archiver/types';
import { archivedEmails, ingestionSources } from '../database/schema';
import { DatabaseService } from './DatabaseService';
import { SearchService } from './SearchService';
class DashboardService {
#db;
#searchService;
constructor(databaseService: DatabaseService) {
constructor(databaseService: DatabaseService, searchService: SearchService) {
this.#db = databaseService.db;
this.#searchService = searchService;
}
public async getStats() {
@@ -73,6 +77,13 @@ class DashboardService {
// This is a placeholder as we don't have a sync job table yet.
return Promise.resolve([]);
}
public async getIndexedInsights(): Promise<IndexedInsights> {
const topSenders = await this.#searchService.getTopSenders(10);
return {
topSenders
};
}
}
export const dashboardService = new DashboardService(new DatabaseService());
export const dashboardService = new DashboardService(new DatabaseService(), new SearchService());

View File

@@ -1,6 +1,6 @@
import { Index, MeiliSearch, SearchParams } from 'meilisearch';
import { config } from '../config';
import type { SearchQuery, SearchResult, EmailDocument } from '@open-archiver/types';
import type { SearchQuery, SearchResult, EmailDocument, TopSender } from '@open-archiver/types';
export class SearchService {
private client: MeiliSearch;
@@ -73,6 +73,26 @@ export class SearchService {
};
}
public async getTopSenders(limit = 10): Promise<TopSender[]> {
const index = await this.getIndex<EmailDocument>('emails');
const searchResults = await index.search('', {
facets: ['from'],
limit: 0
});
if (!searchResults.facetDistribution?.from) {
return [];
}
// Sort and take top N
const sortedSenders = Object.entries(searchResults.facetDistribution.from)
.sort(([, countA], [, countB]) => countB - countA)
.slice(0, limit)
.map(([sender, count]) => ({ sender, count }));
return sortedSenders;
}
public async configureEmailIndex() {
const index = await this.getIndex('emails');
await index.updateSettings({

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import * as Chart from '$lib/components/ui/chart/index.js';
import { AreaChart } from 'layerchart';
import { curveCatmullRom } from 'd3-shape';
import type { ChartConfig } from '$lib/components/ui/chart';
export let data: { date: Date; count: number }[];
const chartConfig = {
count: {
label: 'Emails Ingested',
color: 'var(--chart-1)'
}
} satisfies ChartConfig;
</script>
<Chart.Container config={chartConfig} class="min-h-[300px] w-full">
<AreaChart
{data}
x="date"
y="count"
yDomain={[0, Math.max(...data.map((d) => d.count)) * 1.1]}
axis
legend={false}
series={[
{
key: 'count',
...chartConfig.count
}
]}
cRange={[
'var(--color-chart-1)',
'var(--color-chart-2)',
'var(--color-chart-3)',
'var(--color-chart-4)',
'var(--color-chart-5)'
]}
labels={{}}
props={{
xAxis: {
format: (d) =>
new Date(d).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})
},
area: { curve: curveCatmullRom }
}}
>
{#snippet tooltip()}
<Chart.Tooltip />
{/snippet}
</AreaChart>
</Chart.Container>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import * as Chart from '$lib/components/ui/chart/index.js';
import { PieChart } from 'layerchart';
import type { IngestionSourceStats } from '@open-archiver/types';
import type { ChartConfig } from '$lib/components/ui/chart';
export let data: IngestionSourceStats[];
const chartConfig = {
storageUsed: {
label: 'Storage Used'
}
} satisfies ChartConfig;
</script>
<Chart.Container config={chartConfig} class="h-full min-h-[300px] w-full">
<PieChart
{data}
key="name"
value="storageUsed"
label="name"
legend={{}}
cRange={[
'var(--color-chart-1)',
'var(--color-chart-2)',
'var(--color-chart-3)',
'var(--color-chart-4)',
'var(--color-chart-5)'
]}
>
{#snippet tooltip()}
<Chart.Tooltip></Chart.Tooltip>
{/snippet}
</PieChart>
</Chart.Container>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import * as Chart from '$lib/components/ui/chart/index.js';
import { BarChart } from 'layerchart';
import type { TopSender } from '@open-archiver/types';
import type { ChartConfig } from '$lib/components/ui/chart';
export let data: TopSender[];
const chartConfig = {
count: {
label: 'Emails'
}
} satisfies ChartConfig;
</script>
<Chart.Container config={chartConfig} class="min-h-[300px] w-full">
<BarChart
{data}
x="count"
y="sender"
orientation="horizontal"
xDomain={[0, Math.max(...data.map((d) => d.count)) * 1.1]}
axis={'x'}
legend={false}
series={[
{
key: 'count',
...chartConfig.count
}
]}
cRange={[
'var(--color-chart-1)',
'var(--color-chart-2)',
'var(--color-chart-3)',
'var(--color-chart-4)',
'var(--color-chart-5)'
]}
labels={{}}
>
{#snippet tooltip()}
<Chart.Tooltip />
{/snippet}
</BarChart>
</Chart.Container>

View File

@@ -4,7 +4,8 @@ import type {
DashboardStats,
IngestionHistory,
IngestionSourceStats,
RecentSync
RecentSync,
IndexedInsights
} from '@open-archiver/types';
export const load: PageServerLoad = async (event) => {
@@ -52,17 +53,31 @@ export const load: PageServerLoad = async (event) => {
}
};
const [stats, ingestionHistory, ingestionSources, recentSyncs] = await Promise.all([
fetchStats(),
fetchIngestionHistory(),
fetchIngestionSources(),
fetchRecentSyncs()
]);
const fetchIndexedInsights = async (): Promise<IndexedInsights | null> => {
try {
const response = await api('/dashboard/indexed-insights', event);
if (!response.ok) throw new Error('Failed to fetch indexed insights');
return await response.json();
} catch (error) {
console.error('Indexed Insights Error:', error);
return null;
}
};
const [stats, ingestionHistory, ingestionSources, recentSyncs, indexedInsights] =
await Promise.all([
fetchStats(),
fetchIngestionHistory(),
fetchIngestionSources(),
fetchRecentSyncs(),
fetchIndexedInsights()
]);
return {
stats,
ingestionHistory,
ingestionSources,
recentSyncs
recentSyncs,
indexedInsights
};
};

View File

@@ -1,14 +1,13 @@
<script lang="ts">
import type { PageData } from './$types';
import * as Card from '$lib/components/ui/card/index.js';
import * as Chart from '$lib/components/ui/chart/index.js';
import { LineChart, PieChart, AreaChart } from 'layerchart';
import { formatBytes } from '$lib/utils';
import { curveCatmullRom } from 'd3-shape';
import type { ChartConfig } from '$lib/components/ui/chart';
import EmptyState from '$lib/components/custom/EmptyState.svelte';
import { goto } from '$app/navigation';
import { Archive, CircleAlert, HardDrive } from 'lucide-svelte';
import TopSendersChart from '$lib/components/custom/charts/TopSendersChart.svelte';
import IngestionHistoryChart from '$lib/components/custom/charts/IngestionHistoryChart.svelte';
import StorageBySourceChart from '$lib/components/custom/charts/StorageBySourceChart.svelte';
let { data }: { data: PageData } = $props();
const transformedHistory = $derived(
@@ -17,19 +16,6 @@
date: new Date(item.date)
})) ?? []
);
const emailIngestedChartConfig = {
count: {
label: 'Emails Ingested',
color: 'var(--chart-1)'
}
} satisfies ChartConfig;
const StorageUsedChartConfig = {
storageUsed: {
label: 'Storage Used'
}
} satisfies ChartConfig;
</script>
<svelte:head>
@@ -102,44 +88,7 @@
</Card.Header>
<Card.Content class=" pl-4">
{#if transformedHistory.length > 0}
<Chart.Container config={emailIngestedChartConfig} class="min-h-[300px] w-full">
<AreaChart
data={transformedHistory}
x="date"
y="count"
yDomain={[0, Math.max(...transformedHistory.map((d) => d.count)) * 1.1]}
axis
legend={false}
series={[
{
key: 'count',
...emailIngestedChartConfig.count
}
]}
cRange={[
'var(--color-chart-1)',
'var(--color-chart-2)',
'var(--color-chart-3)',
'var(--color-chart-4)',
'var(--color-chart-5)'
]}
labels={{}}
props={{
xAxis: {
format: (d) =>
new Date(d).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})
},
area: { curve: curveCatmullRom }
}}
>
{#snippet tooltip()}
<Chart.Tooltip />
{/snippet}
</AreaChart>
</Chart.Container>
<IngestionHistoryChart data={transformedHistory} />
{:else}
<p>No ingestion history available.</p>
{/if}
@@ -153,29 +102,7 @@
</Card.Header>
<Card.Content class="h-full">
{#if data.ingestionSources && data.ingestionSources.length > 0}
<Chart.Container
config={StorageUsedChartConfig}
class="h-full min-h-[300px] w-full"
>
<PieChart
data={data.ingestionSources}
key="name"
value="storageUsed"
label="name"
legend={{}}
cRange={[
'var(--color-chart-1)',
'var(--color-chart-2)',
'var(--color-chart-3)',
'var(--color-chart-4)',
'var(--color-chart-5)'
]}
>
{#snippet tooltip()}
<Chart.Tooltip></Chart.Tooltip>
{/snippet}
</PieChart>
</Chart.Container>
<StorageBySourceChart data={data.ingestionSources} />
{:else}
<p>No ingestion sources available.</p>
{/if}
@@ -183,6 +110,23 @@
</Card.Root>
</div>
</div>
<div>
<h1 class="text-xl font-semibold leading-6">Indexed insights</h1>
</div>
<div class="grid grid-cols-1">
<Card.Root>
<Card.Header>
<Card.Title>Top 10 Senders</Card.Title>
</Card.Header>
<Card.Content>
{#if data.indexedInsights && data.indexedInsights.topSenders.length > 0}
<TopSendersChart data={data.indexedInsights.topSenders} />
{:else}
<p>No indexed insights available.</p>
{/if}
</Card.Content>
</Card.Root>
</div>
</div>
{/if}
</div>

View File

@@ -27,3 +27,12 @@ export interface RecentSync {
emailsProcessed: number;
status: string;
}
export interface TopSender {
sender: string;
count: number;
}
export interface IndexedInsights {
topSenders: TopSender[];
}

File diff suppressed because one or more lines are too long