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:
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user