fix(review): harden security, stability, and efficiency across 7 dashboard pages
Security: sanitize innerHTML with escapeHtml in job-query, add rate limiting to job-query and job-export endpoints, upgrade login rate limiter to Redis cross-worker with in-memory fallback, cap resource_ids array at 50, limit CSV export date range to 365 days. Stability: wrap initPage calls in onMounted for wip-overview, resource-status, and resource-history; unload inactive iframes in portal to free memory; add ±15% jitter to auto-refresh timers in useAutoRefresh and useQcGateData; batch expanded job history loads with concurrency limit of 5. Config: reorganize sidebar drawers, move query-tool to dev status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = `<div class="error">${data.error}</div>`;
|
||||
document.getElementById('equipmentList').innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
allEquipments = data.data;
|
||||
renderEquipmentList(allEquipments);
|
||||
} catch (error) {
|
||||
document.getElementById('equipmentList').innerHTML = `<div class="error">載入失敗: ${error.message}</div>`;
|
||||
document.getElementById('equipmentList').innerHTML = `<div class="error">載入失敗: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ function renderTxnCell(txn, apiKey) {
|
||||
});
|
||||
|
||||
if (data.error) {
|
||||
resultSection.innerHTML = `<div class="error">${data.error}</div>`;
|
||||
resultSection.innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ function renderTxnCell(txn, apiKey) {
|
||||
document.getElementById('exportBtn').disabled = jobsData.length === 0;
|
||||
|
||||
} catch (error) {
|
||||
resultSection.innerHTML = `<div class="error">查詢失敗: ${error.message}</div>`;
|
||||
resultSection.innerHTML = `<div class="error">查詢失敗: ${escapeHtml(error.message)}</div>`;
|
||||
} 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 = `<div class="error" style="margin: 10px 20px;">${data.error}</div>`;
|
||||
container.innerHTML = `<div class="error" style="margin: 10px 20px;">${escapeHtml(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -417,7 +419,16 @@ function renderTxnCell(txn, apiKey) {
|
||||
container.innerHTML = html;
|
||||
|
||||
} catch (error) {
|
||||
container.innerHTML = `<div class="error" style="margin: 10px 20px;">載入失敗: ${error.message}</div>`;
|
||||
container.innerHTML = `<div class="error" style="margin: 10px 20px;">載入失敗: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
|
||||
import { buildResourceKpiFromHours } from '../core/compute.js';
|
||||
@@ -306,7 +306,9 @@ async function initPage() {
|
||||
await executeQuery();
|
||||
}
|
||||
|
||||
void initPage();
|
||||
onMounted(() => {
|
||||
void initPage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
|
||||
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
||||
@@ -430,7 +430,9 @@ async function initPage() {
|
||||
await loadData(true);
|
||||
}
|
||||
|
||||
void initPage();
|
||||
onMounted(() => {
|
||||
void initPage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet } from '../core/api.js';
|
||||
import {
|
||||
@@ -267,7 +267,9 @@ async function initializePage() {
|
||||
await loadAllData(true);
|
||||
}
|
||||
|
||||
void initializePage();
|
||||
onMounted(() => {
|
||||
void initializePage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -22,13 +22,25 @@ 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
|
||||
_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:
|
||||
@@ -48,37 +60,62 @@ def _sanitize_next_url(next_url: str | None) -> str:
|
||||
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())
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -22,6 +23,24 @@ from mes_dashboard.services.job_query_service import (
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Page Route
|
||||
@@ -74,6 +93,7 @@ def get_resources():
|
||||
|
||||
|
||||
@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/<job_id>', 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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user