mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Dashboard charts refinement
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user