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
This commit is contained in:
2026-04-10 19:06:43 +03:00
parent b5166d9768
commit aedc91e321
3 changed files with 74 additions and 38 deletions
+15 -6
View File
@@ -20,18 +20,26 @@
checkedAt: string; checkedAt: string;
} }
interface Props { interface PreloadedHistory {
app: AppWithStatus; history: StatusPoint[];
uptimePercent: number;
} }
let { app }: Props = $props(); interface Props {
app: AppWithStatus;
preloadedHistory?: PreloadedHistory | null;
}
let historyData: StatusPoint[] = $state([]); let { app, preloadedHistory = null }: Props = $props();
let uptimePercent: number | null = $state(null);
let historyLoading = $state(true); 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'); const currentStatus = $derived(app.statuses?.[0]?.status ?? 'unknown');
// Only fetch client-side if no preloaded data was provided
if (!preloadedHistory) {
onMount(async () => { onMount(async () => {
try { try {
const res = await fetch(`/api/apps/${app.id}/history`); const res = await fetch(`/api/apps/${app.id}/history`);
@@ -48,6 +56,7 @@
historyLoading = false; historyLoading = false;
} }
}); });
}
const iconDisplay = $derived.by(() => { const iconDisplay = $derived.by(() => {
if (!app.icon) return null; if (!app.icon) return null;
+15 -1
View File
@@ -18,7 +18,21 @@ export const load: PageServerLoad = async (event) => {
superValidate(zod(createAppSchema)) 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<string, { history: { status: string; responseTime: number | null; checkedAt: string }[]; uptimePercent: number }> = {};
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 = { export const actions: Actions = {
+20 -7
View File
@@ -69,9 +69,10 @@
{/if} {/if}
{#if data.apps.length === 0} {#if data.apps.length === 0}
<div class="flex flex-col items-center justify-center rounded-xl border border-border bg-card/50 py-16 text-muted-foreground"> <div class="flex flex-col items-center rounded-xl border border-dashed border-border bg-card/30 px-6 py-16 text-center">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<svg <svg
class="mb-3 h-12 w-12 text-muted-foreground/40" class="h-8 w-8 text-primary"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
@@ -81,16 +82,28 @@
stroke-linejoin="round" stroke-linejoin="round"
> >
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" /> <line x1="2" y1="12" x2="22" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" /> <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg> </svg>
<p class="text-lg">{$t('app.no_apps')}</p> </div>
<p class="mt-1 text-sm">{$t('app.no_apps_hint')}</p> <p class="text-lg font-medium text-foreground">{$t('app.no_apps')}</p>
<p class="mt-1 max-w-sm text-sm text-muted-foreground">{$t('app.no_apps_hint')}</p>
<button
type="button"
onclick={() => (showForm = true)}
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{$t('app.add')}
</button>
</div> </div>
{:else} {:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each data.apps as app (app.id)} {#each data.apps as app (app.id)}
<AppCard {app} /> <AppCard {app} preloadedHistory={data.appHistories[app.id] ?? null} />
{/each} {/each}
</div> </div>
{/if} {/if}