perf(dashboard): lazy-load charts and marked, preload in background on idle
- Drop UsageStats from shared barrel so recharts (~589KB) stays out of the initial bundle of every page; usage page imports it directly - Convert UsageChart/ProviderBarChart/TopModelsChart to next/dynamic - Preload chart chunks via requestIdleCallback in DashboardLayout so navigating to Usage is still instant - Lazy-import marked inside ChangelogModal (only when opened) - Route Sidebar/EndpointPageClient through settingsStore: coalesce concurrent in-flight GET, merge PATCH response over cache (PATCH omits GET-only hasPassword) to kill duplicate /api/settings calls - Delay /api/version npm check 2.5s after first render Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
CLIENT_PING_FAST_MS,
|
||||
} from "./endpointConstants";
|
||||
import { clientPingUrl, clientPingAny } from "./endpointPing";
|
||||
import useSettingsStore from "@/store/settingsStore";
|
||||
import EndpointRow from "./components/EndpointRow";
|
||||
import StatusAlert from "./components/StatusAlert";
|
||||
import Tooltip from "./components/Tooltip";
|
||||
@@ -194,16 +195,15 @@ export default function APIPageClient({ machineId }) {
|
||||
const loadSettings = async () => {
|
||||
setTunnelChecking(true);
|
||||
try {
|
||||
const [settingsRes, statusRes] = await Promise.all([
|
||||
fetch("/api/settings"),
|
||||
const [settingsData, statusRes] = await Promise.all([
|
||||
useSettingsStore.getState().fetchSettings(),
|
||||
fetch("/api/tunnel/status", { cache: "no-store" })
|
||||
]);
|
||||
if (settingsRes.ok) {
|
||||
const data = await settingsRes.json();
|
||||
setRequireApiKey(data.requireApiKey || false);
|
||||
setRequireLogin(data.requireLogin !== false);
|
||||
setHasPassword(data.hasPassword || false);
|
||||
setTunnelDashboardAccess(data.tunnelDashboardAccess || false);
|
||||
if (settingsData) {
|
||||
setRequireApiKey(settingsData.requireApiKey || false);
|
||||
setRequireLogin(settingsData.requireLogin !== false);
|
||||
setHasPassword(settingsData.hasPassword || false);
|
||||
setTunnelDashboardAccess(settingsData.tunnelDashboardAccess || false);
|
||||
}
|
||||
if (statusRes.ok) {
|
||||
const data = await statusRes.json();
|
||||
@@ -229,12 +229,8 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
const handleTunnelDashboardAccess = async (value) => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tunnelDashboardAccess: value }),
|
||||
});
|
||||
if (res.ok) setTunnelDashboardAccess(value);
|
||||
const updated = await useSettingsStore.getState().patchSettings({ tunnelDashboardAccess: value });
|
||||
if (updated) setTunnelDashboardAccess(value);
|
||||
} catch (error) {
|
||||
console.log("Error updating tunnelDashboardAccess:", error);
|
||||
}
|
||||
@@ -242,12 +238,8 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
const handleRequireApiKey = async (value) => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requireApiKey: value }),
|
||||
});
|
||||
if (res.ok) setRequireApiKey(value);
|
||||
const updated = await useSettingsStore.getState().patchSettings({ requireApiKey: value });
|
||||
if (updated) setRequireApiKey(value);
|
||||
} catch (error) {
|
||||
console.log("Error updating requireApiKey:", error);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
|
||||
import { RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
|
||||
import UsageStats from "@/shared/components/UsageStats";
|
||||
import RequestDetailsTab from "./components/RequestDetailsTab";
|
||||
|
||||
const PERIODS = [
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import PropTypes from "prop-types";
|
||||
import { marked } from "marked";
|
||||
import { GITHUB_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true });
|
||||
|
||||
export default function ChangelogModal({ isOpen, onClose }) {
|
||||
const [html, setHtml] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -18,12 +15,17 @@ export default function ChangelogModal({ isOpen, onClose }) {
|
||||
if (!isOpen || html) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
fetch(GITHUB_CONFIG.changelogUrl)
|
||||
.then((res) => {
|
||||
Promise.all([
|
||||
fetch(GITHUB_CONFIG.changelogUrl).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}),
|
||||
import("marked"),
|
||||
])
|
||||
.then(([md, { marked }]) => {
|
||||
marked.setOptions({ gfm: true, breaks: true });
|
||||
setHtml(marked.parse(md));
|
||||
})
|
||||
.then((md) => setHtml(marked.parse(md)))
|
||||
.catch((err) => setError(err.message || "Failed to load"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [isOpen, html]);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/shared/utils/cn";
|
||||
import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import useSettingsStore from "@/store/settingsStore";
|
||||
import Button from "./Button";
|
||||
import { ConfirmModal } from "./Modal";
|
||||
import NineRemotePromoModal from "./NineRemotePromoModal";
|
||||
@@ -54,18 +55,20 @@ export default function Sidebar({ onClose }) {
|
||||
const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then(res => res.json())
|
||||
.then(data => { if (data.enableTranslator) setEnableTranslator(true); })
|
||||
.catch(() => {});
|
||||
useSettingsStore.getState().fetchSettings().then((data) => {
|
||||
if (data?.enableTranslator) setEnableTranslator(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Lazy check for new npm version on mount
|
||||
// Lazy check for new npm version in background after initial render
|
||||
useEffect(() => {
|
||||
fetch("/api/version")
|
||||
.then(res => res.json())
|
||||
.then(data => { if (data.hasUpdate) setUpdateInfo(data); })
|
||||
.catch(() => {});
|
||||
const timer = setTimeout(() => {
|
||||
fetch("/api/version")
|
||||
.then(res => res.json())
|
||||
.then(data => { if (data.hasUpdate) setUpdateInfo(data); })
|
||||
.catch(() => {});
|
||||
}, 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const isActive = (href) => {
|
||||
|
||||
@@ -15,11 +15,11 @@ import Card from "./Card";
|
||||
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards";
|
||||
import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/components/UsageTable";
|
||||
import dynamic from "next/dynamic";
|
||||
// Lazy-load: keeps @xyflow/react out of the shared bundle until topology renders
|
||||
// Lazy-load: keeps @xyflow/react and recharts out of the initial bundle
|
||||
const ProviderTopology = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/ProviderTopology"), { ssr: false });
|
||||
import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart";
|
||||
import ProviderBarChart from "@/app/(dashboard)/dashboard/usage/components/ProviderBarChart";
|
||||
import TopModelsChart from "@/app/(dashboard)/dashboard/usage/components/TopModelsChart";
|
||||
const UsageChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/UsageChart"), { ssr: false });
|
||||
const ProviderBarChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/ProviderBarChart"), { ssr: false });
|
||||
const TopModelsChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/TopModelsChart"), { ssr: false });
|
||||
|
||||
function timeAgo(timestamp) {
|
||||
const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000);
|
||||
|
||||
@@ -18,7 +18,6 @@ export { default as ModelSelectModal } from "./ModelSelectModal";
|
||||
export { default as ManualConfigModal } from "./ManualConfigModal";
|
||||
export { default as ComboFormModal } from "./ComboFormModal";
|
||||
export { default as McpMarketplaceModal } from "./McpMarketplaceModal";
|
||||
export { default as UsageStats } from "./UsageStats";
|
||||
export { default as LanguageSwitcher } from "./LanguageSwitcher";
|
||||
export { default as NineRemoteButton } from "./NineRemoteButton";
|
||||
export { default as HeaderMenu } from "./HeaderMenu";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import Sidebar from "../Sidebar";
|
||||
@@ -37,6 +37,24 @@ export default function DashboardLayout({ children }) {
|
||||
const notifications = useNotificationStore((state) => state.notifications);
|
||||
const removeNotification = useNotificationStore((state) => state.removeNotification);
|
||||
|
||||
// Preload heavy usage charts in background when browser is idle
|
||||
useEffect(() => {
|
||||
const preload = () => {
|
||||
import("@/shared/components/UsageStats").catch(() => {});
|
||||
import("@/app/(dashboard)/dashboard/usage/components/UsageChart").catch(() => {});
|
||||
import("@/app/(dashboard)/dashboard/usage/components/ProviderBarChart").catch(() => {});
|
||||
import("@/app/(dashboard)/dashboard/usage/components/TopModelsChart").catch(() => {});
|
||||
};
|
||||
if (typeof window !== "undefined") {
|
||||
if ("requestIdleCallback" in window) {
|
||||
const id = window.requestIdleCallback(preload, { timeout: 4000 });
|
||||
return () => window.cancelIdleCallback(id);
|
||||
}
|
||||
const timer = setTimeout(preload, 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-bg">
|
||||
<div className="fixed top-4 right-4 z-[80] flex w-[min(92vw,380px)] flex-col gap-2">
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { create } from "zustand";
|
||||
import { CLIENT_STORE_TTL_MS } from "@/shared/constants/config";
|
||||
|
||||
let inFlightSettingsPromise = null;
|
||||
|
||||
const useSettingsStore = create((set, get) => ({
|
||||
settings: null,
|
||||
loading: false,
|
||||
@@ -11,23 +13,30 @@ const useSettingsStore = create((set, get) => ({
|
||||
|
||||
invalidate: () => set({ lastFetched: 0 }),
|
||||
|
||||
// Skips network when cache is fresh; pass {force:true} to override
|
||||
// Skips network when cache is fresh; coalesce concurrent in-flight requests
|
||||
fetchSettings: async ({ force = false } = {}) => {
|
||||
const { lastFetched, settings } = get();
|
||||
if (!force && settings && Date.now() - lastFetched < CLIENT_STORE_TTL_MS) return settings;
|
||||
if (inFlightSettingsPromise) return inFlightSettingsPromise;
|
||||
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
set({ settings: data, loading: false, lastFetched: Date.now() });
|
||||
return data;
|
||||
inFlightSettingsPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
set({ settings: data, loading: false, lastFetched: Date.now() });
|
||||
return data;
|
||||
}
|
||||
set({ error: data.error, loading: false });
|
||||
} catch (e) {
|
||||
set({ error: "Failed to fetch settings", loading: false });
|
||||
} finally {
|
||||
inFlightSettingsPromise = null;
|
||||
}
|
||||
set({ error: data.error, loading: false });
|
||||
} catch (e) {
|
||||
set({ error: "Failed to fetch settings", loading: false });
|
||||
}
|
||||
return null;
|
||||
return null;
|
||||
})();
|
||||
return inFlightSettingsPromise;
|
||||
},
|
||||
|
||||
// PATCH server + merge into local cache (no extra fetch needed)
|
||||
@@ -40,7 +49,8 @@ const useSettingsStore = create((set, get) => ({
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const updated = await res.json();
|
||||
set({ settings: updated, lastFetched: Date.now() });
|
||||
// Merge, not replace: PATCH response omits GET-only fields (e.g. hasPassword)
|
||||
set({ settings: { ...get().settings, ...updated }, lastFetched: Date.now() });
|
||||
return updated;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user