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:
@@ -20,34 +20,43 @@
|
|||||||
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');
|
||||||
|
|
||||||
onMount(async () => {
|
// Only fetch client-side if no preloaded data was provided
|
||||||
try {
|
if (!preloadedHistory) {
|
||||||
const res = await fetch(`/api/apps/${app.id}/history`);
|
onMount(async () => {
|
||||||
if (res.ok) {
|
try {
|
||||||
const json = await res.json();
|
const res = await fetch(`/api/apps/${app.id}/history`);
|
||||||
if (json.success && json.data) {
|
if (res.ok) {
|
||||||
historyData = json.data.history ?? [];
|
const json = await res.json();
|
||||||
uptimePercent = json.data.uptimePercent ?? null;
|
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(() => {
|
const iconDisplay = $derived.by(() => {
|
||||||
if (!app.icon) return null;
|
if (!app.icon) return null;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -69,28 +69,41 @@
|
|||||||
{/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">
|
||||||
<svg
|
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||||
class="mb-3 h-12 w-12 text-muted-foreground/40"
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
class="h-8 w-8 text-primary"
|
||||||
viewBox="0 0 24 24"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
stroke-width="1.5"
|
stroke="currentColor"
|
||||||
stroke-linecap="round"
|
stroke-width="1.5"
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="2" y1="12" x2="22" y2="12" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<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="8" x2="12" y2="12" />
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-lg">{$t('app.no_apps')}</p>
|
{$t('app.add')}
|
||||||
<p class="mt-1 text-sm">{$t('app.no_apps_hint')}</p>
|
</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}
|
||||||
|
|||||||
Reference in New Issue
Block a user