From aedc91e32180c71f4186f0bc98aa0d1fb91c9740 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 10 Apr 2026 19:06:43 +0300 Subject: [PATCH] perf: batch-load app status history server-side to eliminate N+1 requests - Load all app sparkline history in a single server query - Pass preloadedHistory to AppCard to skip client-side fetch - Polish empty state with icon, hint text, and add button --- src/lib/components/app/AppCard.svelte | 49 ++++++++++++++++----------- src/routes/apps/+page.server.ts | 16 ++++++++- src/routes/apps/+page.svelte | 47 +++++++++++++++---------- 3 files changed, 74 insertions(+), 38 deletions(-) diff --git a/src/lib/components/app/AppCard.svelte b/src/lib/components/app/AppCard.svelte index 44dbd4a..d63f965 100644 --- a/src/lib/components/app/AppCard.svelte +++ b/src/lib/components/app/AppCard.svelte @@ -20,34 +20,43 @@ checkedAt: string; } - interface Props { - app: AppWithStatus; + interface PreloadedHistory { + history: StatusPoint[]; + uptimePercent: number; } - let { app }: Props = $props(); + interface Props { + app: AppWithStatus; + preloadedHistory?: PreloadedHistory | null; + } - let historyData: StatusPoint[] = $state([]); - let uptimePercent: number | null = $state(null); - let historyLoading = $state(true); + let { app, preloadedHistory = null }: Props = $props(); + + let historyData: StatusPoint[] = $state(preloadedHistory?.history ?? []); + let uptimePercent: number | null = $state(preloadedHistory?.uptimePercent ?? null); + let historyLoading = $state(!preloadedHistory); const currentStatus = $derived(app.statuses?.[0]?.status ?? 'unknown'); - onMount(async () => { - try { - const res = await fetch(`/api/apps/${app.id}/history`); - if (res.ok) { - const json = await res.json(); - if (json.success && json.data) { - historyData = json.data.history ?? []; - uptimePercent = json.data.uptimePercent ?? null; + // Only fetch client-side if no preloaded data was provided + if (!preloadedHistory) { + onMount(async () => { + try { + const res = await fetch(`/api/apps/${app.id}/history`); + if (res.ok) { + const json = await res.json(); + if (json.success && json.data) { + historyData = json.data.history ?? []; + uptimePercent = json.data.uptimePercent ?? null; + } } + } catch { + // Silently fail — sparkline is non-critical + } finally { + historyLoading = false; } - } catch { - // Silently fail — sparkline is non-critical - } finally { - historyLoading = false; - } - }); + }); + } const iconDisplay = $derived.by(() => { if (!app.icon) return null; diff --git a/src/routes/apps/+page.server.ts b/src/routes/apps/+page.server.ts index ea98e41..143dd50 100644 --- a/src/routes/apps/+page.server.ts +++ b/src/routes/apps/+page.server.ts @@ -18,7 +18,21 @@ export const load: PageServerLoad = async (event) => { superValidate(zod(createAppSchema)) ]); - return { apps, categories, form }; + // Batch-load sparkline history for all apps (single query instead of N+1) + const appIds = apps.map((a) => a.id); + const historyMap = appIds.length > 0 + ? await appService.getBatchStatusHistory(appIds) + : new Map(); + + const appHistories: Record = {}; + for (const [appId, data] of historyMap) { + appHistories[appId] = { + history: data.history.map((h: { status: string; responseTime: number | null; checkedAt: Date }) => ({ ...h, checkedAt: h.checkedAt.toISOString() })), + uptimePercent: data.uptimePercent + }; + } + + return { apps, categories, form, appHistories }; }; export const actions: Actions = { diff --git a/src/routes/apps/+page.svelte b/src/routes/apps/+page.svelte index 5491f1b..0d7332c 100644 --- a/src/routes/apps/+page.svelte +++ b/src/routes/apps/+page.svelte @@ -69,28 +69,41 @@ {/if} {#if data.apps.length === 0} -
- +
+ + + + + +
+

{$t('app.no_apps')}

+

{$t('app.no_apps_hint')}

+
{:else}
{#each data.apps as app (app.id)} - + {/each}
{/if}