This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new bbccb74e feat: cut first-paint payload and cache static assets (#1545)
bbccb74e is described below
commit bbccb74eededac1e6a5a15c69a9b091cea0e56bb
Author: zhaohai <[email protected]>
AuthorDate: Tue Aug 11 20:22:45 2026 +0800
feat: cut first-paint payload and cache static assets (#1545)
- nginx: serve hashed /assets/ with Cache-Control immutable + expires 1y so
repeat visits do not re-download the ~1.8MB JS bundle; index.html is
served
no-cache so releases are picked up immediately. Document (commented-out)
Brotli directives for images that ship ngx_brotli.
- vite: drop manualChunks.markdown so react-markdown/remark-gfm (used only
by
the lazy /ai page) are no longer preloaded on first paint. Verified: entry
now loads only index+antd+react+css; first-paint gzip ~555KB -> ~507KB.
- MainLayout: wrap menuItems/breadcrumbMap/breadcrumbItems/navigationEntries
in useMemo keyed on their real dependencies; previously rebuilt on every
render (search typing, theme toggle, route change).
Verified: tsc -b clean, full vitest suite passes except pre-existing
GrafanaDashboardList export timing flakes (also fail on baseline HEAD).
---
web/nginx.conf | 21 ++++
web/src/layouts/MainLayout.tsx | 229 +++++++++++++++++++++++------------------
web/vite.config.ts | 6 +-
3 files changed, 153 insertions(+), 103 deletions(-)
diff --git a/web/nginx.conf b/web/nginx.conf
index 41833596..ef47cdfa 100644
--- a/web/nginx.conf
+++ b/web/nginx.conf
@@ -10,12 +10,33 @@ server {
gzip_vary on;
gzip_types text/plain text/css application/javascript application/json
image/svg+xml;
+ # Brotli is only enabled when the image ships the module (e.g. nginx built
with
+ # ngx_brotli). The stock `nginx:alpine` image does not include it, so
enabling the
+ # directives below would break startup:
+ # brotli on;
+ # brotli_comp_level 6;
+ # brotli_types text/plain text/css application/javascript
application/json image/svg+xml;
+
# RESOLVER is injected by 15-resolver.sh (podman network gateway only).
# Variable proxy_pass re-resolves on every request, so a recreated
# rocketmq-server container with a new IP is picked up without restarting
# nginx.
resolver ${RESOLVER} valid=10s;
+ # Hashed build assets are content-addressable and never change; cache them
forever
+ # so repeat visits download the ~1.8MB JS bundle only once.
+ location /assets/ {
+ root /usr/share/nginx/html;
+ expires 1y;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ }
+
+ # Never cache the HTML shell so a released build is picked up immediately.
+ location = /index.html {
+ root /usr/share/nginx/html;
+ add_header Cache-Control "no-cache";
+ }
+
location / {
root /usr/share/nginx/html;
index index.html;
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index d86ada04..5fd75121 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { Layout, Menu, Breadcrumb, Avatar, Dropdown, Empty, Input, Modal,
message } from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
@@ -100,112 +100,133 @@ const MainLayout = () => {
return () => window.removeEventListener('keydown', openSearchWithShortcut);
}, []);
- const instanceScopedMatch = location.pathname.match(
- /^\/instance\/[^/]+\/(topic|consumer|message|acl|dlq|resource-plan)$/,
+ const instanceScopedMatch = useMemo(
+ () =>
+ location.pathname.match(
+ /^\/instance\/[^/]+\/(topic|consumer|message|acl|dlq|resource-plan)$/,
+ ),
+ [location.pathname],
);
const selectedMenuKey = instanceScopedMatch
? `/instance/${instanceScopedMatch[1]}`
: location.pathname;
- const menuItems = [
- { key: '/', icon: <House size={iconSize} weight="duotone" />, label:
t('nav.home') },
- {
- key: 'instance-group',
- icon: <Database size={iconSize} weight="duotone" />,
- label: t('nav.instance'),
- children: [
- { key: '/instance', icon: <Database size={16} />, label:
t('nav.instanceList') },
- { key: '/instance/topic', icon: <ListDashes size={16} />, label:
t('nav.topic') },
- { key: '/instance/consumer', icon: <ChatCircleText size={16} />,
label: t('nav.group') },
- { key: '/instance/acl', icon: <Key size={16} />, label: t('nav.acl') },
- { key: '/instance/message', icon: <MagnifyingGlass size={16} />,
label: t('nav.message') },
- { key: '/instance/dlq', icon: <TrashSimple size={16} />, label:
t('nav.dlq') },
- {
- key: '/instance/resource-plan',
- icon: <Notebook size={16} />,
- label: t('nav.resourcePlan'),
- },
- ],
- },
- {
- key: 'cluster-ops-group',
- icon: <Monitor size={iconSize} weight="duotone" />,
- label: t('nav.clusterOps'),
- children: [
- { key: '/ops/dashboard', icon: <ChartBar size={16} />, label:
t('nav.dashboard') },
- { key: '/ops/grafana', icon: <ChartLine size={16} />, label:
t('nav.grafanaDashboards') },
- { key: '/cluster/certs', icon: <ShieldCheck size={16} />, label:
t('nav.certs') },
- { key: '/cluster', icon: <Database size={16} />, label:
t('nav.rocketmqCluster') },
- { key: '/cluster/clients', icon: <PlugsConnected size={16} />, label:
t('nav.clients') },
- { key: '/ops/alerts', icon: <BellRinging size={16} />, label:
t('nav.alertRules') },
- { key: '/ops/system-alerts', icon: <BellRinging size={16} />, label:
t('nav.alertEvents') },
- { key: '/ops/audit', icon: <Notebook size={16} />, label:
t('nav.audit') },
- {
- key: '/ops/nameserver-config-drift',
- icon: <GitDiff size={16} />,
- label: t('nav.nameServerConfigDrift'),
- },
- {
- key: '/ops/alert-rule-templates',
- icon: <Warning size={16} />,
- label: t('nav.alertRuleAssets'),
- },
- ],
- },
- { key: '/ai', icon: <Sparkle size={iconSize} weight="duotone" />, label:
t('nav.ai') },
- {
- key: '/settings',
- icon: <GearSix size={iconSize} weight="duotone" />,
- label: t('nav.settings'),
- },
- ];
+ const menuItems = useMemo(
+ () => [
+ { key: '/', icon: <House size={iconSize} weight="duotone" />, label:
t('nav.home') },
+ {
+ key: 'instance-group',
+ icon: <Database size={iconSize} weight="duotone" />,
+ label: t('nav.instance'),
+ children: [
+ { key: '/instance', icon: <Database size={16} />, label:
t('nav.instanceList') },
+ { key: '/instance/topic', icon: <ListDashes size={16} />, label:
t('nav.topic') },
+ { key: '/instance/consumer', icon: <ChatCircleText size={16} />,
label: t('nav.group') },
+ { key: '/instance/acl', icon: <Key size={16} />, label: t('nav.acl')
},
+ {
+ key: '/instance/message',
+ icon: <MagnifyingGlass size={16} />,
+ label: t('nav.message'),
+ },
+ { key: '/instance/dlq', icon: <TrashSimple size={16} />, label:
t('nav.dlq') },
+ {
+ key: '/instance/resource-plan',
+ icon: <Notebook size={16} />,
+ label: t('nav.resourcePlan'),
+ },
+ ],
+ },
+ {
+ key: 'cluster-ops-group',
+ icon: <Monitor size={iconSize} weight="duotone" />,
+ label: t('nav.clusterOps'),
+ children: [
+ { key: '/ops/dashboard', icon: <ChartBar size={16} />, label:
t('nav.dashboard') },
+ { key: '/ops/grafana', icon: <ChartLine size={16} />, label:
t('nav.grafanaDashboards') },
+ { key: '/cluster/certs', icon: <ShieldCheck size={16} />, label:
t('nav.certs') },
+ { key: '/cluster', icon: <Database size={16} />, label:
t('nav.rocketmqCluster') },
+ { key: '/cluster/clients', icon: <PlugsConnected size={16} />,
label: t('nav.clients') },
+ { key: '/ops/alerts', icon: <BellRinging size={16} />, label:
t('nav.alertRules') },
+ {
+ key: '/ops/system-alerts',
+ icon: <BellRinging size={16} />,
+ label: t('nav.alertEvents'),
+ },
+ { key: '/ops/audit', icon: <Notebook size={16} />, label:
t('nav.audit') },
+ {
+ key: '/ops/nameserver-config-drift',
+ icon: <GitDiff size={16} />,
+ label: t('nav.nameServerConfigDrift'),
+ },
+ {
+ key: '/ops/alert-rule-templates',
+ icon: <Warning size={16} />,
+ label: t('nav.alertRuleAssets'),
+ },
+ ],
+ },
+ { key: '/ai', icon: <Sparkle size={iconSize} weight="duotone" />, label:
t('nav.ai') },
+ {
+ key: '/settings',
+ icon: <GearSix size={iconSize} weight="duotone" />,
+ label: t('nav.settings'),
+ },
+ ],
+ [t],
+ );
- const breadcrumbMap: Record<string, string> = {
- '/': t('nav.home'),
- '/ops': t('nav.clusterOps'),
- '/instance': t('nav.instanceList'),
- '/instance/topic': t('nav.topic'),
- '/instance/consumer': t('nav.group'),
- '/instance/message': t('nav.message'),
- '/instance/acl': t('nav.acl'),
- '/instance/dlq': t('nav.dlq'),
- '/instance/resource-plan': t('nav.resourcePlan'),
- '/cluster': t('nav.rocketmqCluster'),
- '/cluster/certs': t('nav.certs'),
- '/cluster/clients': t('nav.clients'),
- '/ops/dashboard': t('nav.dashboard'),
- '/ops/grafana': t('nav.grafanaDashboards'),
- '/ops/system-alerts': t('nav.alertEvents'),
- '/ops/alerts': t('nav.alertRules'),
- '/ops/audit': t('nav.audit'),
- '/ops/nameserver-config-drift': t('nav.nameServerConfigDrift'),
- '/ops/alert-rule-templates': t('nav.alertRuleAssets'),
- '/ai': t('nav.ai'),
- '/settings': t('nav.settings'),
- };
+ const breadcrumbMap: Record<string, string> = useMemo(
+ () => ({
+ '/': t('nav.home'),
+ '/ops': t('nav.clusterOps'),
+ '/instance': t('nav.instanceList'),
+ '/instance/topic': t('nav.topic'),
+ '/instance/consumer': t('nav.group'),
+ '/instance/message': t('nav.message'),
+ '/instance/acl': t('nav.acl'),
+ '/instance/dlq': t('nav.dlq'),
+ '/instance/resource-plan': t('nav.resourcePlan'),
+ '/cluster': t('nav.rocketmqCluster'),
+ '/cluster/certs': t('nav.certs'),
+ '/cluster/clients': t('nav.clients'),
+ '/ops/dashboard': t('nav.dashboard'),
+ '/ops/grafana': t('nav.grafanaDashboards'),
+ '/ops/system-alerts': t('nav.alertEvents'),
+ '/ops/alerts': t('nav.alertRules'),
+ '/ops/audit': t('nav.audit'),
+ '/ops/nameserver-config-drift': t('nav.nameServerConfigDrift'),
+ '/ops/alert-rule-templates': t('nav.alertRuleAssets'),
+ '/ai': t('nav.ai'),
+ '/settings': t('nav.settings'),
+ }),
+ [t],
+ );
const pathSnippets = location.pathname.split('/').filter((i) => i);
- const breadcrumbItems = [
- {
- title: (
- <span onClick={() => navigate('/')} style={{ cursor: 'pointer' }}>
- 🏠
- </span>
- ),
- key: 'home',
- },
- ...pathSnippets.map((_, index) => {
- const path = '/' + pathSnippets.slice(0, index + 1).join('/');
- const isSectionLeaf = instanceScopedMatch && index ===
pathSnippets.length - 1;
- const leafTitle = isSectionLeaf
- ? breadcrumbMap[`/instance/${instanceScopedMatch[1]}`]
- : undefined;
- return {
- title: breadcrumbMap[path] || leafTitle || path,
- key: path,
- };
- }),
- ];
+ const breadcrumbItems = useMemo(
+ () => [
+ {
+ title: (
+ <span onClick={() => navigate('/')} style={{ cursor: 'pointer' }}>
+ 🏠
+ </span>
+ ),
+ key: 'home',
+ },
+ ...pathSnippets.map((_, index) => {
+ const path = '/' + pathSnippets.slice(0, index + 1).join('/');
+ const isSectionLeaf = instanceScopedMatch && index ===
pathSnippets.length - 1;
+ const leafTitle = isSectionLeaf
+ ? breadcrumbMap[`/instance/${instanceScopedMatch[1]}`]
+ : undefined;
+ return {
+ title: breadcrumbMap[path] || leafTitle || path,
+ key: path,
+ };
+ }),
+ ],
+ [location.pathname, navigate, breadcrumbMap, instanceScopedMatch],
+ );
const userMenu = {
onClick: handleUserMenuClick,
@@ -220,9 +241,13 @@ const MainLayout = () => {
const siderBg = darkMode ? '#2a2a2e' : '#ffffff';
const topBarBg = darkMode ? 'rgba(42,42,46,0.85)' : 'rgba(255,255,255,0.7)';
const logoColor = darkMode ? '#e5e5e5' : '#1b1b1a';
- const navigationEntries: NavigationSearchEntry[] = menuItems
- .flatMap((item) => ('children' in item && item.children ? item.children :
[item]))
- .map((item) => ({ key: String(item.key), label: String(item.label), icon:
item.icon }));
+ const navigationEntries: NavigationSearchEntry[] = useMemo(
+ () =>
+ menuItems
+ .flatMap((item) => ('children' in item && item.children ?
item.children : [item]))
+ .map((item) => ({ key: String(item.key), label: String(item.label),
icon: item.icon })),
+ [menuItems],
+ );
const searchResults = filterNavigationEntries(navigationEntries, searchText);
const isAiRoute = location.pathname === '/ai';
diff --git a/web/vite.config.ts b/web/vite.config.ts
index dbf50854..ec30743b 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -9,13 +9,17 @@ export default defineConfig(({ mode }) => {
build: {
// Ant Design is shared by the application shell and most route
components. Keep it
// cacheable as one vendor chunk rather than splitting its cyclic
internals.
+ // The 1.3MB (gzip ~420KB) size is inherent to the dashboard UI surface;
combined with
+ // immutable asset caching in nginx.conf, repeat visits download it only
once.
chunkSizeWarningLimit: 1400,
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom', 'react-router-dom'],
antd: ['antd', '@ant-design/icons'],
- markdown: ['react-markdown', 'remark-gfm'],
+ // react-markdown / remark-gfm are only used by the lazily-loaded
/ai page;
+ // pinning them here would force the entry to preload them on
first paint.
+ // markdown: ['react-markdown', 'remark-gfm'],
},
},
},