diff --git a/data/page_status.json b/data/page_status.json index b81fdb8..6d112a2 100644 --- a/data/page_status.json +++ b/data/page_status.json @@ -23,7 +23,7 @@ "route": "/hold-history", "name": "Hold 歷史績效", "status": "dev", - "drawer_id": "reports", + "drawer_id": "drawer-2", "order": 3 }, { @@ -40,7 +40,7 @@ "route": "/resource-history", "name": "設備歷史績效", "status": "released", - "drawer_id": "reports", + "drawer_id": "drawer-2", "order": 5 }, { @@ -75,14 +75,14 @@ "route": "/job-query", "name": "設備維修查詢", "status": "released", - "drawer_id": "queries", + "drawer_id": "drawer", "order": 3 }, { "route": "/query-tool", "name": "批次追蹤工具", - "status": "released", - "drawer_id": "queries", + "status": "dev", + "drawer_id": "dev-tools", "order": 4 }, { @@ -128,12 +128,6 @@ "order": 1, "admin_only": false }, - { - "id": "queries", - "name": "查詢類", - "order": 3, - "admin_only": false - }, { "id": "dev-tools", "name": "開發工具", @@ -143,6 +137,12 @@ { "id": "drawer", "name": "查詢工具", + "order": 3, + "admin_only": false + }, + { + "id": "drawer-2", + "name": "歷史報表", "order": 2, "admin_only": false } diff --git a/frontend/src/job-query/main.js b/frontend/src/job-query/main.js index 943c612..869fab8 100644 --- a/frontend/src/job-query/main.js +++ b/frontend/src/job-query/main.js @@ -63,14 +63,14 @@ function renderTxnCell(txn, apiKey) { try { const data = await MesApi.get('/api/job-query/resources'); if (data.error) { - document.getElementById('equipmentList').innerHTML = `
${data.error}
`; + document.getElementById('equipmentList').innerHTML = `
${escapeHtml(data.error)}
`; return; } allEquipments = data.data; renderEquipmentList(allEquipments); } catch (error) { - document.getElementById('equipmentList').innerHTML = `
載入失敗: ${error.message}
`; + document.getElementById('equipmentList').innerHTML = `
載入失敗: ${escapeHtml(error.message)}
`; } } @@ -264,7 +264,7 @@ function renderTxnCell(txn, apiKey) { }); if (data.error) { - resultSection.innerHTML = `
${data.error}
`; + resultSection.innerHTML = `
${escapeHtml(data.error)}
`; return; } @@ -275,7 +275,7 @@ function renderTxnCell(txn, apiKey) { document.getElementById('exportBtn').disabled = jobsData.length === 0; } catch (error) { - resultSection.innerHTML = `
查詢失敗: ${error.message}
`; + resultSection.innerHTML = `
查詢失敗: ${escapeHtml(error.message)}
`; } finally { document.getElementById('queryBtn').disabled = false; } @@ -346,11 +346,13 @@ function renderTxnCell(txn, apiKey) { resultSection.innerHTML = html; - // Load expanded histories + // Load expanded histories in batches to avoid thundering herd + const pendingLoads = []; expandedJobs.forEach(jobId => { const idx = jobsData.findIndex(j => j.JOBID === jobId); - if (idx >= 0) loadJobHistory(jobId, idx); + if (idx >= 0) pendingLoads.push({ jobId, idx }); }); + void loadHistoriesBatched(pendingLoads); } // Toggle job history @@ -382,7 +384,7 @@ function renderTxnCell(txn, apiKey) { const data = await MesApi.get(`/api/job-query/txn/${jobId}`); if (data.error) { - container.innerHTML = `
${data.error}
`; + container.innerHTML = `
${escapeHtml(data.error)}
`; return; } @@ -417,7 +419,16 @@ function renderTxnCell(txn, apiKey) { container.innerHTML = html; } catch (error) { - container.innerHTML = `
載入失敗: ${error.message}
`; + container.innerHTML = `
載入失敗: ${escapeHtml(error.message)}
`; + } + } + + // Load multiple job histories with concurrency limit + const BATCH_CONCURRENCY = 5; + async function loadHistoriesBatched(items) { + for (let i = 0; i < items.length; i += BATCH_CONCURRENCY) { + const batch = items.slice(i, i + BATCH_CONCURRENCY); + await Promise.all(batch.map(({ jobId, idx }) => loadJobHistory(jobId, idx))); } } diff --git a/frontend/src/portal/main.js b/frontend/src/portal/main.js index d9d2f9c..86072db 100644 --- a/frontend/src/portal/main.js +++ b/frontend/src/portal/main.js @@ -29,7 +29,17 @@ import './portal.css'; function activateTab(targetId, toolSrc) { sidebarItems.forEach((item) => item.classList.remove('active')); - frames.forEach((frame) => frame.classList.remove('active')); + + // Unload inactive iframes to free memory and stop their timers + frames.forEach((frame) => { + if (frame.classList.contains('active') && frame.id !== targetId) { + if (frame.src) { + frame.dataset.src = frame.src; + } + frame.removeAttribute('src'); + } + frame.classList.remove('active'); + }); const activeItems = document.querySelectorAll(`.sidebar-item[data-target="${targetId}"]`); activeItems.forEach((item) => { diff --git a/frontend/src/qc-gate/composables/useQcGateData.js b/frontend/src/qc-gate/composables/useQcGateData.js index 8a0c7a3..b3eaff1 100644 --- a/frontend/src/qc-gate/composables/useQcGateData.js +++ b/frontend/src/qc-gate/composables/useQcGateData.js @@ -3,6 +3,12 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { apiGet } from '../../core/api.js'; const REFRESH_INTERVAL_MS = 10 * 60 * 1000; +const JITTER_FACTOR = 0.15; + +function jitteredInterval(baseMs) { + const jitter = baseMs * JITTER_FACTOR * (2 * Math.random() - 1); + return Math.max(1000, Math.round(baseMs + jitter)); +} const API_TIMEOUT_MS = 60000; const BUCKET_KEYS = ['lt_6h', '6h_12h', '12h_24h', 'gt_24h']; @@ -106,18 +112,23 @@ export function useQcGateData() { const stopAutoRefresh = () => { if (refreshTimer) { - clearInterval(refreshTimer); + clearTimeout(refreshTimer); refreshTimer = null; } }; - const startAutoRefresh = () => { + const scheduleNextRefresh = () => { stopAutoRefresh(); - refreshTimer = setInterval(() => { + refreshTimer = setTimeout(() => { if (!document.hidden) { void fetchData({ background: true }); } - }, REFRESH_INTERVAL_MS); + scheduleNextRefresh(); + }, jitteredInterval(REFRESH_INTERVAL_MS)); + }; + + const startAutoRefresh = () => { + scheduleNextRefresh(); }; const resetAutoRefresh = () => { diff --git a/frontend/src/resource-history/App.vue b/frontend/src/resource-history/App.vue index 658b684..65fb6d4 100644 --- a/frontend/src/resource-history/App.vue +++ b/frontend/src/resource-history/App.vue @@ -1,5 +1,5 @@