Dashboard service init

This commit is contained in:
Wayne
2025-07-23 14:57:39 +03:00
parent 7bd1b2d77a
commit b19ec38505
16 changed files with 788 additions and 9 deletions

View File

@@ -0,0 +1,26 @@
import { Request, Response } from 'express';
import { dashboardService } from '../../services/DashboardService';
class DashboardController {
public async getStats(req: Request, res: Response) {
const stats = await dashboardService.getStats();
res.json(stats);
}
public async getIngestionHistory(req: Request, res: Response) {
const history = await dashboardService.getIngestionHistory();
res.json(history);
}
public async getIngestionSources(req: Request, res: Response) {
const sources = await dashboardService.getIngestionSources();
res.json(sources);
}
public async getRecentSyncs(req: Request, res: Response) {
const syncs = await dashboardService.getRecentSyncs();
res.json(syncs);
}
}
export const dashboardController = new DashboardController();

View File

@@ -0,0 +1,17 @@
import { Router } from 'express';
import { dashboardController } from '../controllers/dashboard.controller';
import { requireAuth } from '../middleware/requireAuth';
import { IAuthService } from '../../services/AuthService';
export const createDashboardRouter = (authService: IAuthService): Router => {
const router = Router();
router.use(requireAuth(authService));
router.get('/stats', dashboardController.getStats);
router.get('/ingestion-history', dashboardController.getIngestionHistory);
router.get('/ingestion-sources', dashboardController.getIngestionSources);
router.get('/recent-syncs', dashboardController.getRecentSyncs);
return router;
};

View File

@@ -11,6 +11,7 @@ import { createIngestionRouter } from './api/routes/ingestion.routes';
import { createArchivedEmailRouter } from './api/routes/archived-email.routes';
import { createStorageRouter } from './api/routes/storage.routes';
import { createSearchRouter } from './api/routes/search.routes';
import { createDashboardRouter } from './api/routes/dashboard.routes';
import testRouter from './api/routes/test.routes';
import { AuthService } from './services/AuthService';
import { AdminUserService } from './services/UserService';
@@ -58,11 +59,13 @@ const ingestionRouter = createIngestionRouter(ingestionController, authService);
const archivedEmailRouter = createArchivedEmailRouter(archivedEmailController, authService);
const storageRouter = createStorageRouter(storageController, authService);
const searchRouter = createSearchRouter(searchController, authService);
const dashboardRouter = createDashboardRouter(authService);
app.use('/v1/auth', authRouter);
app.use('/v1/ingestion-sources', ingestionRouter);
app.use('/v1/archived-emails', archivedEmailRouter);
app.use('/v1/storage', storageRouter);
app.use('/v1/search', searchRouter);
app.use('/v1/dashboard', dashboardRouter);
app.use('/v1/test', testRouter);
// Example of a protected route

View File

@@ -0,0 +1,78 @@
import { and, count, eq, gte, sql } from 'drizzle-orm';
import { archivedEmails, ingestionSources } from '../database/schema';
import { DatabaseService } from './DatabaseService';
class DashboardService {
#db;
constructor(databaseService: DatabaseService) {
this.#db = databaseService.db;
}
public async getStats() {
const totalEmailsArchived = await this.#db.select({ count: count() }).from(archivedEmails);
const totalStorageUsed = await this.#db
.select({ sum: sql<number>`sum(${archivedEmails.sizeBytes})` })
.from(archivedEmails);
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const failedIngestionsLast7Days = await this.#db
.select({ count: count() })
.from(ingestionSources)
.where(
and(
eq(ingestionSources.status, 'error'),
gte(ingestionSources.updatedAt, sevenDaysAgo)
)
);
return {
totalEmailsArchived: totalEmailsArchived[0].count,
totalStorageUsed: totalStorageUsed[0].sum || 0,
failedIngestionsLast7Days: failedIngestionsLast7Days[0].count
};
}
public async getIngestionHistory() {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const history = await this.#db
.select({
date: sql<string>`date_trunc('day', ${archivedEmails.archivedAt})`,
count: count()
})
.from(archivedEmails)
.where(gte(archivedEmails.archivedAt, thirtyDaysAgo))
.groupBy(sql`date_trunc('day', ${archivedEmails.archivedAt})`)
.orderBy(sql`date_trunc('day', ${archivedEmails.archivedAt})`);
return { history };
}
public async getIngestionSources() {
const sources = await this.#db
.select({
id: ingestionSources.id,
name: ingestionSources.name,
provider: ingestionSources.provider,
status: ingestionSources.status,
storageUsed: sql<number>`sum(${archivedEmails.sizeBytes})`.mapWith(Number)
})
.from(ingestionSources)
.leftJoin(archivedEmails, eq(ingestionSources.id, archivedEmails.ingestionSourceId))
.groupBy(ingestionSources.id);
return sources;
}
public async getRecentSyncs() {
// This is a placeholder as we don't have a sync job table yet.
return Promise.resolve([]);
}
}
export const dashboardService = new DashboardService(new DatabaseService());

View File

@@ -30,6 +30,7 @@
"bits-ui": "^2.8.10",
"clsx": "^2.1.1",
"dotenv": "^17.2.0",
"layerchart": "2.0.0-next.27",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import ChartStyle from "./chart-style.svelte";
import { setChartContext, type ChartConfig } from "./chart-utils.js";
const uid = $props.id();
let {
ref = $bindable(null),
id = uid,
class: className,
children,
config,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
config: ChartConfig;
} = $props();
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
setChartContext({
get config() {
return config;
},
});
</script>
<div
bind:this={ref}
data-chart={chartId}
data-slot="chart"
class={cn(
"flex aspect-video justify-center overflow-visible text-xs",
// Overrides
//
// Stroke around dots/marks when hovering
"[&_.stroke-white]:stroke-transparent",
// override the default stroke color of lines
"[&_.lc-line]:stroke-border/50",
// by default, layerchart shows a line intersecting the point when hovering, this hides that
"[&_.lc-highlight-line]:stroke-0",
// by default, when you hover a point on a stacked series chart, it will drop the opacity
// of the other series, this overrides that
"[&_.lc-area-path]:opacity-100 [&_.lc-highlight-line]:opacity-100 [&_.lc-highlight-point]:opacity-100 [&_.lc-spline-path]:opacity-100 [&_.lc-text-svg]:overflow-visible [&_.lc-text]:text-xs",
// We don't want the little tick lines between the axis labels and the chart, so we remove
// the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every
// chart.
"[&_.lc-axis-tick]:stroke-0",
// We don't want to display the rule on the x/y axis, as there is already going to be
// a grid line there and rule ends up overlapping the marks because it is rendered after
// the marks
"[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 [&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0",
"[&_.lc-grid-x-radial-line]:stroke-border [&_.lc-grid-x-radial-circle]:stroke-border",
"[&_.lc-grid-y-radial-line]:stroke-border [&_.lc-grid-y-radial-circle]:stroke-border",
// Legend adjustments
"[&_.lc-legend-swatch-button]:items-center [&_.lc-legend-swatch-button]:gap-1.5",
"[&_.lc-legend-swatch-group]:items-center [&_.lc-legend-swatch-group]:gap-4",
"[&_.lc-legend-swatch]:size-2.5 [&_.lc-legend-swatch]:rounded-[2px]",
// Labels
"[&_.lc-labels-text:not([fill])]:fill-foreground [&_text]:stroke-transparent",
// Tick labels on th x/y axes
"[&_.lc-axis-tick-label]:fill-muted-foreground [&_.lc-axis-tick-label]:font-normal",
"[&_.lc-tooltip-rects-g]:fill-transparent",
"[&_.lc-layout-svg-g]:fill-transparent",
"[&_.lc-root-container]:w-full",
className
)}
{...restProps}
>
<ChartStyle id={chartId} {config} />
{@render children?.()}
</div>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { THEMES, type ChartConfig } from "./chart-utils.js";
let { id, config }: { id: string; config: ChartConfig } = $props();
const colorConfig = $derived(
config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null
);
const themeContents = $derived.by(() => {
if (!colorConfig || !colorConfig.length) return;
const themeContents = [];
for (let [_theme, prefix] of Object.entries(THEMES)) {
let content = `${prefix} [data-chart=${id}] {\n`;
const color = colorConfig.map(([key, itemConfig]) => {
const theme = _theme as keyof typeof itemConfig.theme;
const color = itemConfig.theme?.[theme] || itemConfig.color;
return color ? `\t--color-${key}: ${color};` : null;
});
content += color.join("\n") + "\n}";
themeContents.push(content);
}
return themeContents.join("\n");
});
</script>
{#if themeContents}
{#key id}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${themeContents}</style>`}
{/key}
{/if}

View File

@@ -0,0 +1,159 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { getPayloadConfigFromPayload, useChart, type TooltipPayload } from "./chart-utils.js";
import { getTooltipContext, Tooltip as TooltipPrimitive } from "layerchart";
import type { Snippet } from "svelte";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function defaultFormatter(value: any, _payload: TooltipPayload[]) {
return `${value}`;
}
let {
ref = $bindable(null),
class: className,
hideLabel = false,
indicator = "dot",
hideIndicator = false,
labelKey,
label,
labelFormatter = defaultFormatter,
labelClassName,
formatter,
nameKey,
color,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & {
hideLabel?: boolean;
label?: string;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
hideIndicator?: boolean;
labelClassName?: string;
labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any
((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null;
formatter?: Snippet<
[
{
value: unknown;
name: string;
item: TooltipPayload;
index: number;
payload: TooltipPayload[];
},
]
>;
} = $props();
const chart = useChart();
const tooltipCtx = getTooltipContext();
const formattedLabel = $derived.by(() => {
if (hideLabel || !tooltipCtx.payload?.length) return null;
const [item] = tooltipCtx.payload;
const key = labelKey ?? item?.label ?? item?.name ?? "value";
const itemConfig = getPayloadConfigFromPayload(chart.config, item, key);
const value =
!labelKey && typeof label === "string"
? (chart.config[label as keyof typeof chart.config]?.label ?? label)
: (itemConfig?.label ?? item.label);
if (value === undefined) return null;
if (!labelFormatter) return value;
return labelFormatter(value, tooltipCtx.payload);
});
const nestLabel = $derived(tooltipCtx.payload.length === 1 && indicator !== "dot");
</script>
{#snippet TooltipLabel()}
{#if formattedLabel}
<div class={cn("font-medium", labelClassName)}>
{#if typeof formattedLabel === "function"}
{@render formattedLabel()}
{:else}
{formattedLabel}
{/if}
</div>
{/if}
{/snippet}
<TooltipPrimitive.Root variant="none">
<div
class={cn(
"border-border/50 bg-background grid min-w-[9rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
{...restProps}
>
{#if !nestLabel}
{@render TooltipLabel()}
{/if}
<div class="grid gap-1.5">
{#each tooltipCtx.payload as item, i (item.key + i)}
{@const key = `${nameKey || item.key || item.name || "value"}`}
{@const itemConfig = getPayloadConfigFromPayload(chart.config, item, key)}
{@const indicatorColor = color || item.payload?.color || item.color}
<div
class={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5",
indicator === "dot" && "items-center"
)}
>
{#if formatter && item.value !== undefined && item.name}
{@render formatter({
value: item.value,
name: item.name,
item,
index: i,
payload: tooltipCtx.payload,
})}
{:else}
{#if itemConfig?.icon}
<itemConfig.icon />
{:else if !hideIndicator}
<div
style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};"
class={cn(
"border-(--color-border) bg-(--color-bg) shrink-0 rounded-[2px]",
{
"size-2.5": indicator === "dot",
"h-full w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
></div>
{/if}
<div
class={cn(
"flex flex-1 shrink-0 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div class="grid gap-1.5">
{#if nestLabel}
{@render TooltipLabel()}
{/if}
<span class="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{#if item.value !== undefined}
<span class="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
</TooltipPrimitive.Root>

View File

@@ -0,0 +1,66 @@
import type { Tooltip } from "layerchart";
import { getContext, setContext, type Component, type ComponentProps, type Snippet } from "svelte";
export const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: string;
icon?: Component;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
export type TooltipPayload = ExtractSnippetParams<
ComponentProps<typeof Tooltip.Root>["children"]
>["payload"][number];
// Helper to extract item config from a payload.
export function getPayloadConfigFromPayload(
config: ChartConfig,
payload: TooltipPayload,
key: string
) {
if (typeof payload !== "object" || payload === null) return undefined;
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (payload.key === key) {
configLabelKey = payload.key;
} else if (payload.name === key) {
configLabelKey = payload.name;
} else if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload !== undefined &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
type ChartContextValue = {
config: ChartConfig;
};
const chartContextKey = Symbol("chart-context");
export function setChartContext(value: ChartContextValue) {
return setContext(chartContextKey, value);
}
export function useChart() {
return getContext<ChartContextValue>(chartContextKey);
}

View File

@@ -0,0 +1,6 @@
import ChartContainer from "./chart-container.svelte";
import ChartTooltip from "./chart-tooltip.svelte";
export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils.js";
export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip };

View File

@@ -0,0 +1,61 @@
import type { PageServerLoad } from './$types';
import { api } from '$lib/server/api';
import type {
DashboardStats,
IngestionHistory,
IngestionSourceStats,
RecentSync
} from '@open-archiver/types';
export const load: PageServerLoad = async (event) => {
const fetchStats = async (): Promise<DashboardStats | null> => {
try {
const response = await api('/dashboard/stats', event);
if (!response.ok) throw new Error('Failed to fetch stats');
return await response.json();
} catch (error) {
console.error('Dashboard Stats Error:', error);
return null;
}
};
const fetchIngestionHistory = async (): Promise<IngestionHistory | null> => {
try {
const response = await api('/dashboard/ingestion-history', event);
if (!response.ok) throw new Error('Failed to fetch ingestion history');
return await response.json();
} catch (error) {
console.error('Ingestion History Error:', error);
return null;
}
};
const fetchIngestionSources = async (): Promise<IngestionSourceStats[] | null> => {
try {
const response = await api('/dashboard/ingestion-sources', event);
if (!response.ok) throw new Error('Failed to fetch ingestion sources');
return await response.json();
} catch (error) {
console.error('Ingestion Sources Error:', error);
return null;
}
};
const fetchRecentSyncs = async (): Promise<RecentSync[] | null> => {
try {
const response = await api('/dashboard/recent-syncs', event);
if (!response.ok) throw new Error('Failed to fetch recent syncs');
return await response.json();
} catch (error) {
console.error('Recent Syncs Error:', error);
return null;
}
};
return {
stats: fetchStats(),
ingestionHistory: fetchIngestionHistory(),
ingestionSources: fetchIngestionSources(),
recentSyncs: fetchRecentSyncs()
};
};

View File

@@ -1,15 +1,228 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.store';
import type { PageData } from './$types';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
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';
export let data: PageData;
const { stats, ingestionHistory, ingestionSources, recentSyncs } = data;
const chartConfig = {
storageUsed: {
label: 'Storage Used'
},
count: {
label: 'Emails'
}
} 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 - Open Archiver</title>
<title>Dashboard - OpenArchiver</title>
<meta name="description" content="System health and activity overview." />
</svelte:head>
<div class="">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Dashboard</h1>
</div>
<p class="mt-4">Welcome, {$authStore.user?.email}!</p>
<p>You are logged in.</p>
<div class="container mx-auto">
<h1 class="mb-6 text-3xl font-bold">Dashboard</h1>
<!-- 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}
</div>
</section>
<!-- 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>
<!-- 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>
<!-- 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>
</div>

View File

@@ -16,5 +16,8 @@ export default defineConfig({
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
ssr: {
noExternal: ['layerchart']
}
});

View File

@@ -0,0 +1,29 @@
export interface DashboardStats {
totalEmailsArchived: number;
totalStorageUsed: number;
failedIngestionsLast7Days: number;
}
export interface IngestionHistory {
history: {
date: string;
count: number;
}[];
}
export interface IngestionSourceStats {
id: string;
name: string;
provider: string;
status: string;
storageUsed: number;
}
export interface RecentSync {
id: string;
sourceName: string;
startTime: string;
duration: number;
emailsProcessed: number;
status: string;
}

View File

@@ -5,3 +5,4 @@ export * from './storage.types';
export * from './email.types';
export * from './archived-emails.types';
export * from './search.types';
export * from './dashboard.types';

File diff suppressed because one or more lines are too long