From 6d97d4e17262bf2f3d5eb5003a95551589543d9e Mon Sep 17 00:00:00 2001 From: aaaamoooo Date: Wed, 21 Oct 2026 10:50:56 +0000 Subject: [PATCH] perf(web): page-data cache + progressive rendering (item 22, frontend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page switches no longer refetch the base datasets every time: servers/customers/nodes/protocols are reused across pages for 30s, and every successful mutation (all write paths funnel through api()) invalidates the cache. Rendering is progressive — the page paints immediately with what is already in state and re-renders as each dataset lands, instead of waiting for a Promise.all of 2-5 requests. Overview/system/jobs still fetch fresh on every load. --- public/app.js | 63 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/public/app.js b/public/app.js index 7bca144..36cb83e 100644 --- a/public/app.js +++ b/public/app.js @@ -128,6 +128,9 @@ async function api(path, options = {}) { if (response.status === 401 && path !== '/api/auth/login') showLogin(); throw new Error((data && data.message) || `请求失败 (${response.status})`); } + // Any successful mutation invalidates the page-data cache (item 22): + // every write path in this file funnels through api(). + if (!['GET', 'HEAD'].includes(options.method || 'GET')) invalidateDataCache(); return data; } @@ -240,29 +243,59 @@ function pagerHTML(key, total) { } /* ---------- 数据加载与路由 ---------- */ +/* 基础数据集页间缓存(第 22 条):servers/customers/nodes/protocols 在 + 30 秒内切页直接复用、不重复请求;任何写操作经 api() 统一失效。 + 渲染不再等全部请求落地:每份数据到达即更新 state 并重渲染当前页。 */ +const DATA_CACHE_TTL_MS = 30000; +const dataCacheAt = { servers: 0, customers: 0, nodes: 0, protocols: 0 }; +function invalidateDataCache() { for (const key of Object.keys(dataCacheAt)) dataCacheAt[key] = 0; } +function cacheFresh(key) { return dataCacheAt[key] && Date.now() - dataCacheAt[key] < DATA_CACHE_TTL_MS; } + async function load(page = state.page) { + const isCurrent = () => state.page === page && !modalOpen(); + const apply = (fn) => { fn(); if (isCurrent()) render(); }; + const jobs = []; + const errors = []; + // Cached base dataset: fetch only when stale. A partial failure keeps + // whatever data is already in state (the page renders progressively); + // only a total failure surfaces a toast, as the old all-or-nothing + // load did. + const base = (key, path, fn) => { + if (cacheFresh(key)) return; + jobs.push(api(path).then((data) => { dataCacheAt[key] = Date.now(); apply(() => fn(data)); }).catch((error) => errors.push(error))); + }; + const fresh = (path, fn) => { jobs.push(api(path).then((data) => apply(() => fn(data))).catch((error) => errors.push(error))); }; + const setServers = (d) => { state.servers = d.servers; }; + const setCustomers = (d) => { state.customers = d.customers; state.customerSummary = d.summary || null; }; + const setNodes = (d) => { state.nodes = d.nodes; state.nodeClients = d.nodeClients; }; + const setOverview = (d) => { state.overview = d; state.version = d.version || state.version; $('#version-label').textContent = `v${state.version}`; }; try { if (page === 'overview') { - const [overview, system, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/system'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]); - state.overview = overview; state.system = system; state.version = overview.version || state.version; - state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; - $('#version-label').textContent = `v${state.version}`; + fresh('/api/overview', setOverview); + fresh('/api/system', (d) => { state.system = d; }); + base('servers', '/api/servers', setServers); + base('customers', '/api/customers', setCustomers); + base('nodes', '/api/nodes', setNodes); } else if (page === 'servers') { - const [servers, routes] = await Promise.all([api('/api/servers'), api('/api/nodes')]); - state.servers = servers.servers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; + base('servers', '/api/servers', setServers); + base('nodes', '/api/nodes', setNodes); } else if (page === 'customers') { - const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]); - state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; + base('customers', '/api/customers', setCustomers); + base('nodes', '/api/nodes', setNodes); } else if (page === 'chains') { - const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]); - state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; - state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || []; + base('servers', '/api/servers', setServers); + base('customers', '/api/customers', setCustomers); + base('nodes', '/api/nodes', setNodes); + base('protocols', '/api/protocols', (d) => { state.protocols = d.profiles; state.realityPresets = d.realityPresets || []; }); } else if (page === 'operations') { - const [jobs, overview] = await Promise.all([api('/api/jobs'), api('/api/overview')]); - state.jobs = jobs.jobs; state.overview = overview; state.version = overview.version || state.version; - $('#version-label').textContent = `v${state.version}`; + fresh('/api/jobs', (d) => { state.jobs = d.jobs; }); + fresh('/api/overview', setOverview); } - render(); + // Render immediately with whatever is already in state (cached or + // previously loaded), then re-render as the rest lands. + if (isCurrent()) render(); + await Promise.allSettled(jobs); + if (errors.length && errors.length === jobs.length) throw errors[0]; } catch (error) { toast(error.message, true); } }