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 @@
diff --git a/frontend/src/resource-status/App.vue b/frontend/src/resource-status/App.vue
index e614d16..99c31b2 100644
--- a/frontend/src/resource-status/App.vue
+++ b/frontend/src/resource-status/App.vue
@@ -1,5 +1,5 @@
diff --git a/frontend/src/wip-overview/App.vue b/frontend/src/wip-overview/App.vue
index d80104b..cf95c07 100644
--- a/frontend/src/wip-overview/App.vue
+++ b/frontend/src/wip-overview/App.vue
@@ -1,5 +1,5 @@
diff --git a/frontend/src/wip-shared/composables/useAutoRefresh.js b/frontend/src/wip-shared/composables/useAutoRefresh.js
index be53e74..ad1c667 100644
--- a/frontend/src/wip-shared/composables/useAutoRefresh.js
+++ b/frontend/src/wip-shared/composables/useAutoRefresh.js
@@ -1,6 +1,12 @@
import { onBeforeUnmount, onMounted } from 'vue';
const DEFAULT_REFRESH_INTERVAL_MS = 10 * 60 * 1000;
+const JITTER_FACTOR = 0.15; // ±15% random jitter to prevent synchronized requests
+
+function jitteredInterval(baseMs) {
+ const jitter = baseMs * JITTER_FACTOR * (2 * Math.random() - 1);
+ return Math.max(1000, Math.round(baseMs + jitter));
+}
export function useAutoRefresh({
onRefresh,
@@ -14,18 +20,23 @@ export function useAutoRefresh({
function stopAutoRefresh() {
if (refreshTimer) {
- clearInterval(refreshTimer);
+ clearTimeout(refreshTimer);
refreshTimer = null;
}
}
- function startAutoRefresh() {
+ function scheduleNextRefresh() {
stopAutoRefresh();
- refreshTimer = setInterval(() => {
+ refreshTimer = setTimeout(() => {
if (!document.hidden) {
void onRefresh?.();
}
- }, intervalMs);
+ scheduleNextRefresh();
+ }, jitteredInterval(intervalMs));
+ }
+
+ function startAutoRefresh() {
+ scheduleNextRefresh();
}
function resetAutoRefresh() {
diff --git a/src/mes_dashboard/routes/auth_routes.py b/src/mes_dashboard/routes/auth_routes.py
index 5fe883e..527d9d3 100644
--- a/src/mes_dashboard/routes/auth_routes.py
+++ b/src/mes_dashboard/routes/auth_routes.py
@@ -3,17 +3,17 @@
from __future__ import annotations
-import logging
-import time
-from collections import defaultdict
-from datetime import datetime
-from threading import Lock
-from urllib.parse import urlparse
+import logging
+import time
+from collections import defaultdict
+from datetime import datetime
+from threading import Lock
+from urllib.parse import urlparse
-from flask import Blueprint, flash, redirect, render_template, request, session, url_for
-
-from mes_dashboard.core.csrf import rotate_csrf_token
-from mes_dashboard.services.auth_service import authenticate, is_admin
+from flask import Blueprint, flash, redirect, render_template, request, session, url_for
+
+from mes_dashboard.core.csrf import rotate_csrf_token
+from mes_dashboard.services.auth_service import authenticate, is_admin
logger = logging.getLogger('mes_dashboard.auth_routes')
auth_bp = Blueprint("auth", __name__, url_prefix="/admin")
@@ -22,63 +22,100 @@ auth_bp = Blueprint("auth", __name__, url_prefix="/admin")
# ============================================================
# Rate Limiting for Login Endpoint
# ============================================================
-# Simple in-memory rate limiter to prevent brute force attacks
+# Redis-backed rate limiter (cross-worker) with in-memory fallback.
# Configuration: max 5 attempts per IP per 5 minutes
_rate_limit_lock = Lock()
_login_attempts: dict = defaultdict(list) # IP -> list of timestamps
-RATE_LIMIT_MAX_ATTEMPTS = 5
-RATE_LIMIT_WINDOW_SECONDS = 300 # 5 minutes
-
-
-def _sanitize_next_url(next_url: str | None) -> str:
- """Return a safe post-login redirect URL limited to local paths."""
- fallback = url_for("portal_index")
- if not next_url:
- return fallback
-
- parsed = urlparse(next_url)
- if parsed.scheme or parsed.netloc:
- logger.warning("Blocked external next redirect: %s", next_url)
- return fallback
-
- if not next_url.startswith("/") or next_url.startswith("//"):
- return fallback
-
- return next_url
+_last_cleanup = time.time()
+RATE_LIMIT_MAX_ATTEMPTS = 5
+RATE_LIMIT_WINDOW_SECONDS = 300 # 5 minutes
+_CLEANUP_INTERVAL = 600 # Sweep stale entries every 10 minutes
+_REDIS_LOGIN_KEY_PREFIX = "mes:login_attempts:"
+
+
+def _get_redis():
+ """Get Redis client if available."""
+ try:
+ from mes_dashboard.core.redis_client import get_redis_client
+ return get_redis_client()
+ except Exception:
+ return None
+
+
+def _sanitize_next_url(next_url: str | None) -> str:
+ """Return a safe post-login redirect URL limited to local paths."""
+ fallback = url_for("portal_index")
+ if not next_url:
+ return fallback
+
+ parsed = urlparse(next_url)
+ if parsed.scheme or parsed.netloc:
+ logger.warning("Blocked external next redirect: %s", next_url)
+ return fallback
+
+ if not next_url.startswith("/") or next_url.startswith("//"):
+ return fallback
+
+ return next_url
+
+
+def _cleanup_stale_entries() -> None:
+ """Remove stale IP entries from the in-memory rate limiter."""
+ global _last_cleanup
+ now = time.time()
+ if now - _last_cleanup < _CLEANUP_INTERVAL:
+ return
+ _last_cleanup = now
+ window_start = now - RATE_LIMIT_WINDOW_SECONDS
+ stale_ips = [
+ ip for ip, timestamps in _login_attempts.items()
+ if not timestamps or timestamps[-1] <= window_start
+ ]
+ for ip in stale_ips:
+ del _login_attempts[ip]
def _is_rate_limited(ip: str) -> bool:
"""Check if an IP address is rate limited.
- Args:
- ip: Client IP address.
-
- Returns:
- True if rate limited, False otherwise.
+ Uses Redis when available for cross-worker consistency,
+ falls back to in-memory dict otherwise.
"""
+ redis_client = _get_redis()
+ if redis_client:
+ try:
+ key = f"{_REDIS_LOGIN_KEY_PREFIX}{ip}"
+ count = redis_client.get(key)
+ return int(count or 0) >= RATE_LIMIT_MAX_ATTEMPTS
+ except Exception:
+ pass # Fall through to in-memory
+
current_time = time.time()
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
with _rate_limit_lock:
- # Clean up old attempts
+ _cleanup_stale_entries()
_login_attempts[ip] = [
ts for ts in _login_attempts[ip] if ts > window_start
]
-
- # Check if limit exceeded
- if len(_login_attempts[ip]) >= RATE_LIMIT_MAX_ATTEMPTS:
- return True
-
- return False
+ return len(_login_attempts[ip]) >= RATE_LIMIT_MAX_ATTEMPTS
def _record_login_attempt(ip: str) -> None:
- """Record a login attempt for rate limiting.
+ """Record a login attempt for rate limiting."""
+ redis_client = _get_redis()
+ if redis_client:
+ try:
+ key = f"{_REDIS_LOGIN_KEY_PREFIX}{ip}"
+ pipe = redis_client.pipeline()
+ pipe.incr(key)
+ pipe.expire(key, RATE_LIMIT_WINDOW_SECONDS)
+ pipe.execute()
+ return
+ except Exception:
+ pass # Fall through to in-memory
- Args:
- ip: Client IP address.
- """
with _rate_limit_lock:
_login_attempts[ip].append(time.time())
@@ -108,27 +145,27 @@ def login():
user = authenticate(username, password)
if user is None:
error = "帳號或密碼錯誤"
- elif not is_admin(user):
- error = "您不是管理員,無法登入後台"
- else:
- # Login successful
- session.clear()
- session["admin"] = {
- "username": user.get("username"),
- "displayName": user.get("displayName"),
- "mail": user.get("mail"),
- "department": user.get("department"),
- "login_time": datetime.now().isoformat(),
- }
- rotate_csrf_token()
- next_url = _sanitize_next_url(request.args.get("next"))
- return redirect(next_url)
+ elif not is_admin(user):
+ error = "您不是管理員,無法登入後台"
+ else:
+ # Login successful
+ session.clear()
+ session["admin"] = {
+ "username": user.get("username"),
+ "displayName": user.get("displayName"),
+ "mail": user.get("mail"),
+ "department": user.get("department"),
+ "login_time": datetime.now().isoformat(),
+ }
+ rotate_csrf_token()
+ next_url = _sanitize_next_url(request.args.get("next"))
+ return redirect(next_url)
return render_template("login.html", error=error)
@auth_bp.route("/logout")
-def logout():
- """Admin logout."""
- session.clear()
- return redirect(url_for("portal_index"))
+def logout():
+ """Admin logout."""
+ session.clear()
+ return redirect(url_for("portal_index"))
diff --git a/src/mes_dashboard/routes/job_query_routes.py b/src/mes_dashboard/routes/job_query_routes.py
index 313cf2d..fd300c4 100644
--- a/src/mes_dashboard/routes/job_query_routes.py
+++ b/src/mes_dashboard/routes/job_query_routes.py
@@ -1,16 +1,17 @@
# -*- coding: utf-8 -*-
-"""Job Query API routes.
+"""Job Query API routes.
Contains Flask Blueprint for maintenance job query endpoints:
- Job list query by resources
- Job transaction history detail
- CSV export with full history
-"""
-
-import logging
-
-from flask import Blueprint, jsonify, request, Response, render_template
+"""
+import logging
+
+from flask import Blueprint, jsonify, request, Response, render_template
+
+from mes_dashboard.core.rate_limit import configured_rate_limit
from mes_dashboard.services.job_query_service import (
get_jobs_by_resources,
get_job_txn_history,
@@ -18,9 +19,27 @@ from mes_dashboard.services.job_query_service import (
validate_date_range,
)
-# Create Blueprint
-job_query_bp = Blueprint('job_query', __name__)
-logger = logging.getLogger('mes_dashboard.job_query_routes')
+# Create Blueprint
+job_query_bp = Blueprint('job_query', __name__)
+logger = logging.getLogger('mes_dashboard.job_query_routes')
+
+MAX_RESOURCE_IDS = 50
+
+_JOB_QUERY_RATE_LIMIT = configured_rate_limit(
+ bucket="job-query",
+ max_attempts_env="JOB_QUERY_RATE_LIMIT_MAX_REQUESTS",
+ window_seconds_env="JOB_QUERY_RATE_LIMIT_WINDOW_SECONDS",
+ default_max_attempts=60,
+ default_window_seconds=60,
+)
+
+_JOB_EXPORT_RATE_LIMIT = configured_rate_limit(
+ bucket="job-export",
+ max_attempts_env="JOB_EXPORT_RATE_LIMIT_MAX_REQUESTS",
+ window_seconds_env="JOB_EXPORT_RATE_LIMIT_WINDOW_SECONDS",
+ default_max_attempts=10,
+ default_window_seconds=60,
+)
# ============================================================
@@ -68,12 +87,13 @@ def get_resources():
'total': len(data)
})
- except Exception as exc:
- logger.exception("Failed to load job-query resources: %s", exc)
- return jsonify({'error': '服務暫時無法使用'}), 500
+ except Exception as exc:
+ logger.exception("Failed to load job-query resources: %s", exc)
+ return jsonify({'error': '服務暫時無法使用'}), 500
@job_query_bp.route('/api/job-query/jobs', methods=['POST'])
+@_JOB_QUERY_RATE_LIMIT
def query_jobs():
"""Query jobs for selected resources.
@@ -95,6 +115,8 @@ def query_jobs():
# Validation
if not resource_ids:
return jsonify({'error': '請選擇至少一台設備'}), 400
+ if len(resource_ids) > MAX_RESOURCE_IDS:
+ return jsonify({'error': f'設備數量不可超過 {MAX_RESOURCE_IDS} 台'}), 400
if not start_date or not end_date:
return jsonify({'error': '請指定日期範圍'}), 400
@@ -111,6 +133,7 @@ def query_jobs():
@job_query_bp.route('/api/job-query/txn/', methods=['GET'])
+@_JOB_QUERY_RATE_LIMIT
def query_job_txn_history(job_id: str):
"""Query transaction history for a single job.
@@ -131,6 +154,7 @@ def query_job_txn_history(job_id: str):
@job_query_bp.route('/api/job-query/export', methods=['POST'])
+@_JOB_EXPORT_RATE_LIMIT
def export_jobs():
"""Export jobs with full transaction history as CSV.
@@ -152,6 +176,8 @@ def export_jobs():
# Validation
if not resource_ids:
return jsonify({'error': '請選擇至少一台設備'}), 400
+ if len(resource_ids) > MAX_RESOURCE_IDS:
+ return jsonify({'error': f'設備數量不可超過 {MAX_RESOURCE_IDS} 台'}), 400
if not start_date or not end_date:
return jsonify({'error': '請指定日期範圍'}), 400
diff --git a/src/mes_dashboard/routes/resource_history_routes.py b/src/mes_dashboard/routes/resource_history_routes.py
index d7659bc..7e01b08 100644
--- a/src/mes_dashboard/routes/resource_history_routes.py
+++ b/src/mes_dashboard/routes/resource_history_routes.py
@@ -4,6 +4,8 @@
Contains Flask Blueprint for historical equipment performance analysis endpoints.
"""
+from datetime import datetime
+
from flask import Blueprint, jsonify, request, redirect, Response
from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key
@@ -218,6 +220,21 @@ def api_resource_history_export():
'error': '必須提供 start_date 和 end_date 參數'
}), 400
+ # Validate export date range (max 365 days)
+ try:
+ sd = datetime.strptime(start_date, '%Y-%m-%d')
+ ed = datetime.strptime(end_date, '%Y-%m-%d')
+ if (ed - sd).days > 365:
+ return jsonify({
+ 'success': False,
+ 'error': 'CSV 匯出範圍不可超過一年 (365 天)'
+ }), 400
+ except ValueError:
+ return jsonify({
+ 'success': False,
+ 'error': '日期格式錯誤,請使用 YYYY-MM-DD'
+ }), 400
+
# Generate filename
filename = f"resource_history_{start_date}_to_{end_date}.csv"
diff --git a/src/mes_dashboard/templates/portal.html b/src/mes_dashboard/templates/portal.html
index 24e99ce..983ee96 100644
--- a/src/mes_dashboard/templates/portal.html
+++ b/src/mes_dashboard/templates/portal.html
@@ -423,7 +423,17 @@
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 => {