mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Dashboard service init
This commit is contained in:
26
packages/backend/src/api/controllers/dashboard.controller.ts
Normal file
26
packages/backend/src/api/controllers/dashboard.controller.ts
Normal 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();
|
||||
17
packages/backend/src/api/routes/dashboard.routes.ts
Normal file
17
packages/backend/src/api/routes/dashboard.routes.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
78
packages/backend/src/services/DashboardService.ts
Normal file
78
packages/backend/src/services/DashboardService.ts
Normal 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());
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
66
packages/frontend/src/lib/components/ui/chart/chart-utils.ts
Normal file
66
packages/frontend/src/lib/components/ui/chart/chart-utils.ts
Normal 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);
|
||||
}
|
||||
6
packages/frontend/src/lib/components/ui/chart/index.ts
Normal file
6
packages/frontend/src/lib/components/ui/chart/index.ts
Normal 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 };
|
||||
61
packages/frontend/src/routes/dashboard/+page.server.ts
Normal file
61
packages/frontend/src/routes/dashboard/+page.server.ts
Normal 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()
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -16,5 +16,8 @@ export default defineConfig({
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ['layerchart']
|
||||
}
|
||||
});
|
||||
|
||||
29
packages/types/src/dashboard.types.ts
Normal file
29
packages/types/src/dashboard.types.ts
Normal 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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user