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,
|
CLIENT_PING_FAST_MS,
|
||||||
} from "./endpointConstants";
|
} from "./endpointConstants";
|
||||||
import { clientPingUrl, clientPingAny } from "./endpointPing";
|
import { clientPingUrl, clientPingAny } from "./endpointPing";
|
||||||
|
import useSettingsStore from "@/store/settingsStore";
|
||||||
import EndpointRow from "./components/EndpointRow";
|
import EndpointRow from "./components/EndpointRow";
|
||||||
import StatusAlert from "./components/StatusAlert";
|
import StatusAlert from "./components/StatusAlert";
|
||||||
import Tooltip from "./components/Tooltip";
|
import Tooltip from "./components/Tooltip";
|
||||||
@@ -194,16 +195,15 @@ export default function APIPageClient({ machineId }) {
|
|||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
setTunnelChecking(true);
|
setTunnelChecking(true);
|
||||||
try {
|
try {
|
||||||
const [settingsRes, statusRes] = await Promise.all([
|
const [settingsData, statusRes] = await Promise.all([
|
||||||
fetch("/api/settings"),
|
useSettingsStore.getState().fetchSettings(),
|
||||||
fetch("/api/tunnel/status", { cache: "no-store" })
|
fetch("/api/tunnel/status", { cache: "no-store" })
|
||||||
]);
|
]);
|
||||||
if (settingsRes.ok) {
|
if (settingsData) {
|
||||||
const data = await settingsRes.json();
|
setRequireApiKey(settingsData.requireApiKey || false);
|
||||||
setRequireApiKey(data.requireApiKey || false);
|
setRequireLogin(settingsData.requireLogin !== false);
|
||||||
setRequireLogin(data.requireLogin !== false);
|
setHasPassword(settingsData.hasPassword || false);
|
||||||
setHasPassword(data.hasPassword || false);
|
setTunnelDashboardAccess(settingsData.tunnelDashboardAccess || false);
|
||||||
setTunnelDashboardAccess(data.tunnelDashboardAccess || false);
|
|
||||||
}
|
}
|
||||||
if (statusRes.ok) {
|
if (statusRes.ok) {
|
||||||
const data = await statusRes.json();
|
const data = await statusRes.json();
|
||||||
@@ -229,12 +229,8 @@ export default function APIPageClient({ machineId }) {
|
|||||||
|
|
||||||
const handleTunnelDashboardAccess = async (value) => {
|
const handleTunnelDashboardAccess = async (value) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings", {
|
const updated = await useSettingsStore.getState().patchSettings({ tunnelDashboardAccess: value });
|
||||||
method: "PATCH",
|
if (updated) setTunnelDashboardAccess(value);
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ tunnelDashboardAccess: value }),
|
|
||||||
});
|
|
||||||
if (res.ok) setTunnelDashboardAccess(value);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error updating tunnelDashboardAccess:", error);
|
console.log("Error updating tunnelDashboardAccess:", error);
|
||||||
}
|
}
|
||||||
@@ -242,12 +238,8 @@ export default function APIPageClient({ machineId }) {
|
|||||||
|
|
||||||
const handleRequireApiKey = async (value) => {
|
const handleRequireApiKey = async (value) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings", {
|
const updated = await useSettingsStore.getState().patchSettings({ requireApiKey: value });
|
||||||
method: "PATCH",
|
if (updated) setRequireApiKey(value);
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ requireApiKey: value }),
|
|
||||||
});
|
|
||||||
if (res.ok) setRequireApiKey(value);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error updating requireApiKey:", error);
|
console.log("Error updating requireApiKey:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { Suspense, useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
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";
|
import RequestDetailsTab from "./components/RequestDetailsTab";
|
||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
|
|||||||
@@ -3,11 +3,8 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { marked } from "marked";
|
|
||||||
import { GITHUB_CONFIG } from "@/shared/constants/config";
|
import { GITHUB_CONFIG } from "@/shared/constants/config";
|
||||||
|
|
||||||
marked.setOptions({ gfm: true, breaks: true });
|
|
||||||
|
|
||||||
export default function ChangelogModal({ isOpen, onClose }) {
|
export default function ChangelogModal({ isOpen, onClose }) {
|
||||||
const [html, setHtml] = useState("");
|
const [html, setHtml] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -18,12 +15,17 @@ export default function ChangelogModal({ isOpen, onClose }) {
|
|||||||
if (!isOpen || html) return;
|
if (!isOpen || html) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
fetch(GITHUB_CONFIG.changelogUrl)
|
Promise.all([
|
||||||
.then((res) => {
|
fetch(GITHUB_CONFIG.changelogUrl).then((res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
return res.text();
|
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"))
|
.catch((err) => setError(err.message || "Failed to load"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [isOpen, html]);
|
}, [isOpen, html]);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { cn } from "@/shared/utils/cn";
|
|||||||
import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config";
|
import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config";
|
||||||
import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
|
import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
|
import useSettingsStore from "@/store/settingsStore";
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
import { ConfirmModal } from "./Modal";
|
import { ConfirmModal } from "./Modal";
|
||||||
import NineRemotePromoModal from "./NineRemotePromoModal";
|
import NineRemotePromoModal from "./NineRemotePromoModal";
|
||||||
@@ -54,18 +55,20 @@ export default function Sidebar({ onClose }) {
|
|||||||
const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest;
|
const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/settings")
|
useSettingsStore.getState().fetchSettings().then((data) => {
|
||||||
.then(res => res.json())
|
if (data?.enableTranslator) setEnableTranslator(true);
|
||||||
.then(data => { if (data.enableTranslator) setEnableTranslator(true); })
|
});
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Lazy check for new npm version on mount
|
// Lazy check for new npm version in background after initial render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/version")
|
const timer = setTimeout(() => {
|
||||||
.then(res => res.json())
|
fetch("/api/version")
|
||||||
.then(data => { if (data.hasUpdate) setUpdateInfo(data); })
|
.then(res => res.json())
|
||||||
.catch(() => {});
|
.then(data => { if (data.hasUpdate) setUpdateInfo(data); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, 2500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isActive = (href) => {
|
const isActive = (href) => {
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import Card from "./Card";
|
|||||||
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards";
|
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards";
|
||||||
import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/components/UsageTable";
|
import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/components/UsageTable";
|
||||||
import dynamic from "next/dynamic";
|
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 });
|
const ProviderTopology = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/ProviderTopology"), { ssr: false });
|
||||||
import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart";
|
const UsageChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/UsageChart"), { ssr: false });
|
||||||
import ProviderBarChart from "@/app/(dashboard)/dashboard/usage/components/ProviderBarChart";
|
const ProviderBarChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/ProviderBarChart"), { ssr: false });
|
||||||
import TopModelsChart from "@/app/(dashboard)/dashboard/usage/components/TopModelsChart";
|
const TopModelsChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/TopModelsChart"), { ssr: false });
|
||||||
|
|
||||||
function timeAgo(timestamp) {
|
function timeAgo(timestamp) {
|
||||||
const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000);
|
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 ManualConfigModal } from "./ManualConfigModal";
|
||||||
export { default as ComboFormModal } from "./ComboFormModal";
|
export { default as ComboFormModal } from "./ComboFormModal";
|
||||||
export { default as McpMarketplaceModal } from "./McpMarketplaceModal";
|
export { default as McpMarketplaceModal } from "./McpMarketplaceModal";
|
||||||
export { default as UsageStats } from "./UsageStats";
|
|
||||||
export { default as LanguageSwitcher } from "./LanguageSwitcher";
|
export { default as LanguageSwitcher } from "./LanguageSwitcher";
|
||||||
export { default as NineRemoteButton } from "./NineRemoteButton";
|
export { default as NineRemoteButton } from "./NineRemoteButton";
|
||||||
export { default as HeaderMenu } from "./HeaderMenu";
|
export { default as HeaderMenu } from "./HeaderMenu";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useNotificationStore } from "@/store/notificationStore";
|
import { useNotificationStore } from "@/store/notificationStore";
|
||||||
import Sidebar from "../Sidebar";
|
import Sidebar from "../Sidebar";
|
||||||
@@ -37,6 +37,24 @@ export default function DashboardLayout({ children }) {
|
|||||||
const notifications = useNotificationStore((state) => state.notifications);
|
const notifications = useNotificationStore((state) => state.notifications);
|
||||||
const removeNotification = useNotificationStore((state) => state.removeNotification);
|
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 (
|
return (
|
||||||
<div className="flex h-screen w-full overflow-hidden bg-bg">
|
<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">
|
<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 { create } from "zustand";
|
||||||
import { CLIENT_STORE_TTL_MS } from "@/shared/constants/config";
|
import { CLIENT_STORE_TTL_MS } from "@/shared/constants/config";
|
||||||
|
|
||||||
|
let inFlightSettingsPromise = null;
|
||||||
|
|
||||||
const useSettingsStore = create((set, get) => ({
|
const useSettingsStore = create((set, get) => ({
|
||||||
settings: null,
|
settings: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -11,23 +13,30 @@ const useSettingsStore = create((set, get) => ({
|
|||||||
|
|
||||||
invalidate: () => set({ lastFetched: 0 }),
|
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 } = {}) => {
|
fetchSettings: async ({ force = false } = {}) => {
|
||||||
const { lastFetched, settings } = get();
|
const { lastFetched, settings } = get();
|
||||||
if (!force && settings && Date.now() - lastFetched < CLIENT_STORE_TTL_MS) return settings;
|
if (!force && settings && Date.now() - lastFetched < CLIENT_STORE_TTL_MS) return settings;
|
||||||
|
if (inFlightSettingsPromise) return inFlightSettingsPromise;
|
||||||
|
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
inFlightSettingsPromise = (async () => {
|
||||||
const res = await fetch("/api/settings");
|
try {
|
||||||
const data = await res.json();
|
const res = await fetch("/api/settings");
|
||||||
if (res.ok) {
|
const data = await res.json();
|
||||||
set({ settings: data, loading: false, lastFetched: Date.now() });
|
if (res.ok) {
|
||||||
return data;
|
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 });
|
return null;
|
||||||
} catch (e) {
|
})();
|
||||||
set({ error: "Failed to fetch settings", loading: false });
|
return inFlightSettingsPromise;
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// PATCH server + merge into local cache (no extra fetch needed)
|
// PATCH server + merge into local cache (no extra fetch needed)
|
||||||
@@ -40,7 +49,8 @@ const useSettingsStore = create((set, get) => ({
|
|||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const updated = await res.json();
|
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;
|
return updated;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user