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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, getContext } from 'svelte';
|
||||||
import AppHealthBadge from '$lib/components/app/AppHealthBadge.svelte';
|
import AppHealthBadge from '$lib/components/app/AppHealthBadge.svelte';
|
||||||
import AnimatedStatusRing from '$lib/components/app/AnimatedStatusRing.svelte';
|
import AnimatedStatusRing from '$lib/components/app/AnimatedStatusRing.svelte';
|
||||||
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
|
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
|
||||||
@@ -48,9 +48,13 @@
|
|||||||
|
|
||||||
const cardStyleClass = $derived(`card-${theme.cardStyle}`);
|
const cardStyleClass = $derived(`card-${theme.cardStyle}`);
|
||||||
|
|
||||||
let historyData: StatusPoint[] = $state([]);
|
// Use pre-loaded history from context (set by board page) to avoid N+1 fetches
|
||||||
let uptimePercent: number | null = $state(null);
|
const appHistories = getContext<Record<string, { history: StatusPoint[]; uptimePercent: number }> | undefined>('appHistories');
|
||||||
let historyLoading = $state(true);
|
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 linksExpanded = $state(false);
|
||||||
let showContextMenu = $state(false);
|
let showContextMenu = $state(false);
|
||||||
let contextMenuPos = $state({ x: 0, y: 0 });
|
let contextMenuPos = $state({ x: 0, y: 0 });
|
||||||
@@ -74,7 +78,9 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fallback: fetch history only if not pre-loaded via context
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
if (preloaded) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/apps/${app.id}/history`);
|
const res = await fetch(`/api/apps/${app.id}/history`);
|
||||||
if (res.ok) {
|
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() {
|
export async function getHealthcheckTargets() {
|
||||||
return prisma.app.findMany({
|
return prisma.app.findMany({
|
||||||
where: { healthcheckEnabled: true },
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Board not found';
|
const message = err instanceof Error ? err.message : 'Board not found';
|
||||||
if (message.includes('not found')) {
|
if (message.includes('not found')) {
|
||||||
|
|||||||
@@ -9,11 +9,15 @@
|
|||||||
import CustomCssInjector from '$lib/components/layout/CustomCssInjector.svelte';
|
import CustomCssInjector from '$lib/components/layout/CustomCssInjector.svelte';
|
||||||
import WallpaperBackground from '$lib/components/background/WallpaperBackground.svelte';
|
import WallpaperBackground from '$lib/components/background/WallpaperBackground.svelte';
|
||||||
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
|
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const boardCardSize = $derived((data.board.cardSize as 'compact' | 'medium' | 'large') ?? 'medium');
|
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 showShareDialog = $state(false);
|
||||||
let guestToggleError = $state('');
|
let guestToggleError = $state('');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user