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:
decolua
2026-09-26 11:34:08 +07:00
parent 30464bc227
commit ddfdb0df04
8 changed files with 80 additions and 55 deletions

View File

@@ -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);
}

View File

@@ -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 = [

View File

@@ -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]);

View File

@@ -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) => {

View File

@@ -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);

View File

@@ -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";

View File

@@ -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">

View File

@@ -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;