perf: batch-load app history to eliminate N+1 fetches on board load

Previously each AppWidget fetched /api/apps/{id}/history individually
on mount, causing N sequential HTTP requests. Now the board page
server load fetches all app histories in a single Prisma query via
getBatchStatusHistory() and passes them to AppWidget via Svelte
context. AppWidget uses the pre-loaded data immediately with a
fallback fetch for non-board contexts.
This commit is contained in:
2026-03-25 15:36:06 +03:00
parent 6eb6bba289
commit 92eeeadec0
4 changed files with 66 additions and 5 deletions
+10 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, getContext } from 'svelte';
import AppHealthBadge from '$lib/components/app/AppHealthBadge.svelte';
import AnimatedStatusRing from '$lib/components/app/AnimatedStatusRing.svelte';
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
@@ -48,9 +48,13 @@
const cardStyleClass = $derived(`card-${theme.cardStyle}`);
let historyData: StatusPoint[] = $state([]);
let uptimePercent: number | null = $state(null);
let historyLoading = $state(true);
// Use pre-loaded history from context (set by board page) to avoid N+1 fetches
const appHistories = getContext<Record<string, { history: StatusPoint[]; uptimePercent: number }> | undefined>('appHistories');
const preloaded = appHistories?.[app.id];
let historyData: StatusPoint[] = $state(preloaded?.history ?? []);
let uptimePercent: number | null = $state(preloaded?.uptimePercent ?? null);
let historyLoading = $state(!preloaded);
let linksExpanded = $state(false);
let showContextMenu = $state(false);
let contextMenuPos = $state({ x: 0, y: 0 });
@@ -74,7 +78,9 @@
}
});
// Fallback: fetch history only if not pre-loaded via context
onMount(async () => {
if (preloaded) return;
try {
const res = await fetch(`/api/apps/${app.id}/history`);
if (res.ok) {
+32
View File
@@ -127,6 +127,38 @@ export async function getStatusHistory(appId: string, limit: number = 50) {
});
}
/**
* Batch-fetch status history for multiple apps in a single query.
* Returns a map of appId -> { history, uptimePercent }.
*/
export async function getBatchStatusHistory(
appIds: readonly string[],
limitPerApp: number = 288
): Promise<ReadonlyMap<string, { readonly history: readonly { status: string; responseTime: number | null; checkedAt: Date }[]; readonly uptimePercent: number }>> {
if (appIds.length === 0) return new Map();
const allStatuses = await prisma.appStatus.findMany({
where: { appId: { in: [...appIds] } },
orderBy: { checkedAt: 'desc' },
select: { appId: true, status: true, responseTime: true, checkedAt: true }
});
const result = new Map<string, { history: { status: string; responseTime: number | null; checkedAt: Date }[]; uptimePercent: number }>();
for (const appId of appIds) {
const statuses = allStatuses.filter((s) => s.appId === appId).slice(0, limitPerApp).reverse();
const totalChecks = statuses.length;
const onlineChecks = statuses.filter((s) => s.status === 'online').length;
const uptimePercent = totalChecks > 0 ? Math.round((onlineChecks / totalChecks) * 1000) / 10 : 0;
result.set(appId, {
history: statuses.map((s) => ({ status: s.status, responseTime: s.responseTime, checkedAt: s.checkedAt })),
uptimePercent
});
}
return result;
}
export async function getHealthcheckTargets() {
return prisma.app.findMany({
where: { healthcheckEnabled: true },
+20 -1
View File
@@ -75,7 +75,26 @@ export const load: PageServerLoad = async ({ params, locals }) => {
}));
}
return { board, canEdit, allApps, users, groups };
// Batch-load sparkline history for all apps on this board (single query)
const appIdsOnBoard = board.sections
.flatMap((s: { widgets: { appId: string | null }[] }) => s.widgets)
.map((w: { appId: string | null }) => w.appId)
.filter((id: string | null): id is string => id !== null);
const historyMap = appIdsOnBoard.length > 0
? await appService.getBatchStatusHistory(appIdsOnBoard)
: new Map();
// Serialize the Map to a plain object for the client
const appHistories: Record<string, { history: { status: string; responseTime: number | null; checkedAt: string }[]; uptimePercent: number }> = {};
for (const [appId, data] of historyMap) {
appHistories[appId] = {
history: data.history.map((h) => ({ ...h, checkedAt: h.checkedAt.toISOString() })),
uptimePercent: data.uptimePercent
};
}
return { board, canEdit, allApps, users, groups, appHistories };
} catch (err) {
const message = err instanceof Error ? err.message : 'Board not found';
if (message.includes('not found')) {
+4
View File
@@ -9,11 +9,15 @@
import CustomCssInjector from '$lib/components/layout/CustomCssInjector.svelte';
import WallpaperBackground from '$lib/components/background/WallpaperBackground.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
import { setContext } from 'svelte';
let { data }: { data: PageData } = $props();
const boardCardSize = $derived((data.board.cardSize as 'compact' | 'medium' | 'large') ?? 'medium');
// Provide pre-loaded app histories via context to avoid N+1 fetches in AppWidget
setContext('appHistories', data.appHistories ?? {});
let showShareDialog = $state(false);
let guestToggleError = $state('');