chore: reinitialize project with vite architecture

This commit is contained in:
beabigegg
2026-02-08 08:30:48 +08:00
commit b56e80381b
264 changed files with 75752 additions and 0 deletions

181
.env.example Normal file
View File

@@ -0,0 +1,181 @@
# ============================================================
# MES Dashboard Environment Configuration
# ============================================================
# Copy this file to .env and fill in your actual values:
# cp .env.example .env
# nano .env
# ============================================================
# ============================================================
# Database Configuration (REQUIRED)
# ============================================================
# Oracle Database connection settings
DB_HOST=your_database_host
DB_PORT=1521
DB_SERVICE=your_service_name
DB_USER=your_username
DB_PASSWORD=your_password
# Database Pool Settings (optional, has defaults)
# Adjust based on expected load
DB_POOL_SIZE=5 # Default: 5 (dev: 2, prod: 10)
DB_MAX_OVERFLOW=10 # Default: 10 (dev: 3, prod: 20)
DB_POOL_TIMEOUT=30 # Seconds to wait when pool is exhausted
DB_POOL_RECYCLE=1800 # Recycle connection after N seconds
DB_TCP_CONNECT_TIMEOUT=10
DB_CONNECT_RETRY_COUNT=1
DB_CONNECT_RETRY_DELAY=1.0
DB_CALL_TIMEOUT_MS=55000 # Must stay below worker timeout
# ============================================================
# Flask Configuration
# ============================================================
# Environment mode: development | production | testing
FLASK_ENV=development
# Debug mode: 0 for production, 1 for development
FLASK_DEBUG=0
# Session Security (REQUIRED for production!)
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=your-secret-key-change-in-production
# Session timeout in seconds (default: 28800 = 8 hours)
SESSION_LIFETIME=28800
# ============================================================
# Authentication Configuration
# ============================================================
# LDAP API endpoint for user authentication
LDAP_API_URL=https://your-ldap-api-endpoint.example.com
# Admin email addresses (comma-separated for multiple)
ADMIN_EMAILS=admin@example.com
# Local Authentication (for development/testing)
# When enabled, uses local credentials instead of LDAP
# Set LOCAL_AUTH_ENABLED=true to bypass LDAP authentication
LOCAL_AUTH_ENABLED=false
LOCAL_AUTH_USERNAME=
LOCAL_AUTH_PASSWORD=
# ============================================================
# Gunicorn Configuration
# ============================================================
# Server bind address and port
GUNICORN_BIND=0.0.0.0:8080
# Number of worker processes (recommend: 2 * CPU cores + 1)
GUNICORN_WORKERS=2
# Threads per worker
GUNICORN_THREADS=4
# ============================================================
# Redis Configuration (for WIP cache)
# ============================================================
# Redis connection URL
REDIS_URL=redis://localhost:6379/0
# Enable/disable Redis cache (set to false to fallback to Oracle)
REDIS_ENABLED=true
# Redis key prefix (to separate from other applications)
REDIS_KEY_PREFIX=mes_wip
# Cache check interval in seconds (default: 600 = 10 minutes)
CACHE_CHECK_INTERVAL=600
# ============================================================
# Resource Cache Configuration
# ============================================================
# Enable/disable Resource cache (DW_MES_RESOURCE)
# When disabled, queries will fallback to Oracle directly
RESOURCE_CACHE_ENABLED=true
# Resource cache sync interval in seconds (default: 14400 = 4 hours)
# The cache will check for updates at this interval using MAX(LASTCHANGEDATE)
RESOURCE_SYNC_INTERVAL=14400
# ============================================================
# Circuit Breaker Configuration
# ============================================================
# Enable/disable circuit breaker for database protection
CIRCUIT_BREAKER_ENABLED=true
# Minimum failures before circuit can open
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
# Failure rate threshold (0.0 - 1.0)
CIRCUIT_BREAKER_FAILURE_RATE=0.5
# Seconds to wait in OPEN state before trying HALF_OPEN
CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
# Sliding window size for counting successes/failures
CIRCUIT_BREAKER_WINDOW_SIZE=10
# ============================================================
# Performance Metrics Configuration
# ============================================================
# Slow query threshold in seconds (default: 5.0)
# Note: Real-time Oracle views may take 2-5s per query, set threshold accordingly
SLOW_QUERY_THRESHOLD=5.0
# ============================================================
# SQLite Log Store Configuration
# ============================================================
# Enable/disable SQLite log store for admin dashboard
LOG_STORE_ENABLED=true
# SQLite database path
LOG_SQLITE_PATH=logs/admin_logs.sqlite
# Log retention period in days (default: 7)
LOG_SQLITE_RETENTION_DAYS=7
# Maximum log rows (default: 100000)
LOG_SQLITE_MAX_ROWS=100000
# ============================================================
# Worker Watchdog Configuration
# ============================================================
# Runtime directory for restart flag/pid/state files
WATCHDOG_RUNTIME_DIR=./tmp
# Path to restart flag file (watchdog monitors this file)
WATCHDOG_RESTART_FLAG=./tmp/mes_dashboard_restart.flag
# Gunicorn PID file path (must match start script / systemd config)
WATCHDOG_PID_FILE=./tmp/gunicorn.pid
# Path to restart state file (stores last restart info)
WATCHDOG_STATE_FILE=./tmp/mes_dashboard_restart_state.json
# Max entries persisted in restart history (bounded to avoid state growth)
WATCHDOG_RESTART_HISTORY_MAX=50
# Cooldown period between restart requests in seconds (default: 60)
WORKER_RESTART_COOLDOWN=60
# ============================================================
# Runtime Resilience Diagnostics Thresholds
# ============================================================
# Alert window for sustained degraded state (seconds)
RESILIENCE_DEGRADED_ALERT_SECONDS=300
# Pool saturation warning / critical levels
RESILIENCE_POOL_SATURATION_WARNING=0.90
RESILIENCE_POOL_SATURATION_CRITICAL=1.0
# Restart churn threshold: N restarts within window triggers churn warning
RESILIENCE_RESTART_CHURN_WINDOW_SECONDS=600
RESILIENCE_RESTART_CHURN_THRESHOLD=3
# ============================================================
# CORS Configuration
# ============================================================
# Comma-separated list of allowed origins for CORS
# Example: https://example.com,https://app.example.com
# Set to * for development (not recommended for production)
CORS_ALLOWED_ORIGINS=

56
.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Virtual environments
venv/
ENV/
env/
.venv/
# Package build artifacts
*.egg-info/
*.egg
dist/
build/
*.whl
frontend/node_modules/
# IDE
.idea/
.vscode/
*.swp
*.swo
*.sublime-*
# OS
.DS_Store
Thumbs.db
nul
# Logs
*.log
logs/
# Local config (credentials)
.env
# AI/LLM tools
.claude/
.codex/
CLAUDE.md
# Test artifacts
.pytest_cache/
.coverage
htmlcov/
.tox/
# Jupyter
.ipynb_checkpoints/
# Note: openspec/ is tracked (not ignored)
tmp/

658
README.md Normal file
View File

@@ -0,0 +1,658 @@
# MES Dashboard 報表系統
基於 Flask + Gunicorn + Redis + Vite 的 MES 數據報表查詢與可視化系統
> 專案主執行根目錄:`DashBoard_vite/`
> 目前已移除舊版 `DashBoard/` 代碼,僅保留新架構。
---
## 專案狀態
| 功能 | 狀態 |
|------|------|
| WIP 即時概況 | ✅ 已完成 |
| WIP 明細查詢 | ✅ 已完成 |
| Hold 狀態分析 | ✅ 已完成 |
| 數據表查詢工具 | ✅ 已完成 |
| 設備狀態監控 | ✅ 已完成 |
| 設備歷史查詢 | ✅ 已完成 |
| 管理員認證系統 | ✅ 已完成 |
| 頁面狀態管理 | ✅ 已完成 |
| Redis 快取系統 | ✅ 已完成 |
| SQL 查詢安全架構 | ✅ 已完成 |
| 效能監控儀表板 | ✅ 已完成 |
| 熔斷器保護機制 | ✅ 已完成 |
| Worker 重啟控制 | ✅ 已完成 |
| Runtime 韌性診斷threshold/churn/recommendation | ✅ 已完成 |
| WIP 共用 autocomplete core 模組 | ✅ 已完成 |
| 前端核心模組測試Node test | ✅ 已完成 |
| 部署自動化 | ✅ 已完成 |
---
## 遷移與驗收文件
- Root cutover 盤點:`docs/root_cutover_inventory.md`
- 頁面架構與抽屜分類:`docs/page_architecture_map.md`
- 前端計算前移與 parity 規則:`docs/frontend_compute_shift_plan.md`
- Cutover gates / rollout / rollback`docs/migration_gates_and_runbook.md`
- 環境依賴缺口與對策:`docs/environment_gaps_and_mitigation.md`
---
## 最新架構重點
1. 單一 port 契約維持不變
- Flask + Gunicorn + Vite dist 由同一服務提供(`GUNICORN_BIND`),前後端同源。
2. Runtime 韌性採「降級 + 可操作建議」
- `/health``/health/deep``/admin/api/system-status``/admin/api/worker/status` 皆提供:
- 門檻thresholds
- 重啟 churn 摘要
- recovery recommendation值班建議動作
3. Watchdog 維持手動觸發重啟模型
- 仍以 admin API 觸發 reload不預設啟用自動重啟風暴風險。
- state 檔新增 bounded restart history方便追蹤 churn。
4. 前端治理WIP autocomplete/filter 共用化
- `frontend/src/core/autocomplete.js` 作為 WIP overview/detail 共用邏輯來源。
- 維持既有頁面流程與 drill-down 語意,不變更操作習慣。
---
## 快速開始
### 首次部署
```bash
# 1. 執行部署腳本
./scripts/deploy.sh
# 2. 編輯環境設定
nano .env
# 3. 啟動服務
./scripts/start_server.sh start
```
### 日常操作
```bash
# 啟動服務(背景執行)
./scripts/start_server.sh start
# 停止服務
./scripts/start_server.sh stop
# 重啟服務
./scripts/start_server.sh restart
# 查看狀態
./scripts/start_server.sh status
# 查看日誌
./scripts/start_server.sh logs follow
```
訪問網址: **http://localhost:8080** (可在 .env 中配置)
---
## 部署指南
### 環境需求
- Python 3.11+
- Conda (Miniconda/Anaconda)
- Node.js 22+(建議由 Conda `environment.yml` 管理)
- Oracle Database 連線
- Redis Server 7.x+ (設備狀態快取)
### 部署步驟
#### 1. 自動部署(推薦)
```bash
./scripts/deploy.sh
```
此腳本會自動:
- 檢查 Conda 環境
- 建立 `mes-dashboard` 虛擬環境
- 安裝依賴套件
- 複製 `.env.example``.env`
- 驗證資料庫連線
#### 2. 手動部署
```bash
# 建立 Conda 環境
conda create -n mes-dashboard python=3.11 -y
conda activate mes-dashboard
# 安裝依賴
pip install -r requirements.txt
# 安裝前端依賴並建置Vite
npm --prefix frontend install
npm --prefix frontend test
npm --prefix frontend run build
# 設定環境變數
cp .env.example .env
nano .env # 編輯資料庫連線等設定
# 啟動服務
./scripts/start_server.sh start
```
### 環境變數設定
編輯 `.env` 檔案:
```bash
# 資料庫設定(必填)
DB_HOST=your_database_host
DB_PORT=1521
DB_SERVICE=your_service_name
DB_USER=your_username
DB_PASSWORD=your_password
# Flask 設定
FLASK_ENV=production # production | development
SECRET_KEY=your-secret-key # 生產環境請更換
# Gunicorn 設定
GUNICORN_BIND=0.0.0.0:8080 # 服務監聽位址
GUNICORN_WORKERS=2 # Worker 數量
GUNICORN_THREADS=4 # 每個 Worker 的執行緒數
# DB 韌性設定
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20
DB_POOL_TIMEOUT=30
DB_POOL_RECYCLE=1800
DB_CALL_TIMEOUT_MS=55000
# Circuit Breaker
CIRCUIT_BREAKER_ENABLED=true
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
CIRCUIT_BREAKER_FAILURE_RATE=0.5
CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
# Redis 設定
REDIS_URL=redis://localhost:6379/0
REDIS_ENABLED=true
# Watchdog runtime contract
WATCHDOG_RUNTIME_DIR=./tmp
WATCHDOG_RESTART_FLAG=./tmp/mes_dashboard_restart.flag
WATCHDOG_PID_FILE=./tmp/gunicorn.pid
WATCHDOG_STATE_FILE=./tmp/mes_dashboard_restart_state.json
WATCHDOG_RESTART_HISTORY_MAX=50
# Runtime resilience thresholds
RESILIENCE_DEGRADED_ALERT_SECONDS=300
RESILIENCE_POOL_SATURATION_WARNING=0.90
RESILIENCE_POOL_SATURATION_CRITICAL=1.0
RESILIENCE_RESTART_CHURN_WINDOW_SECONDS=600
RESILIENCE_RESTART_CHURN_THRESHOLD=3
# 管理員設定
ADMIN_EMAILS=admin@example.com # 管理員郵件(逗號分隔)
```
### 生產環境注意事項
1. **SECRET_KEY**: 必須設定為隨機字串
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
2. **FLASK_ENV**: 設定為 `production`
3. **防火牆**: 開放服務端口(預設 8080
### Conda + systemd 服務配置
建議在生產環境使用同一份 conda runtime contract 啟動 App 與 Watchdog
```bash
# 1. 複製 systemd 服務檔案
sudo cp deploy/mes-dashboard.service /etc/systemd/system/
sudo cp deploy/mes-dashboard-watchdog.service /etc/systemd/system/
# 2. 準備環境設定檔
sudo mkdir -p /etc/mes-dashboard
sudo cp .env /etc/mes-dashboard/mes-dashboard.env
# 3. 重新載入 systemd
sudo systemctl daemon-reload
# 4. 啟動並設定開機自動啟動
sudo systemctl enable --now mes-dashboard mes-dashboard-watchdog
# 5. 查看狀態
sudo systemctl status mes-dashboard
sudo systemctl status mes-dashboard-watchdog
```
### Rollback 步驟
如需回滾到先前版本:
```bash
# 1. 停止服務
./scripts/start_server.sh stop
sudo systemctl stop mes-dashboard mes-dashboard-watchdog
# 2. 回滾程式碼
git checkout <previous-commit>
# 3. 重新安裝依賴(如有變更)
pip install -r requirements.txt
# 4. 清理新版本資料(可選)
rm -f logs/admin_logs.sqlite # 清理 SQLite 日誌
# 5. 重啟服務
./scripts/start_server.sh start
sudo systemctl start mes-dashboard mes-dashboard-watchdog
```
---
## 使用者操作指南
本節提供一般使用者的操作說明。
### 存取系統
1. 開啟瀏覽器,輸入系統網址(預設為 `http://localhost:8080`
2. 進入 Portal 首頁,可透過上方 Tab 切換各功能模組
### 基本操作
#### WIP 即時概況
- 顯示生產線 WIP在製品的即時統計
- 可透過下拉選單篩選特定工作中心或產品線
- 點擊統計卡片可展開查看詳細明細
- 支援匯出 Excel 報表
#### WIP 明細查詢
1. 選擇篩選條件工作中心、Package、Hold 狀態、製程站點)
2. 點擊「查詢」按鈕執行查詢
3. 查詢結果顯示於下方表格
4. 點擊「匯出 Excel」下載報表
#### 設備狀態監控
- 顯示所有設備的即時狀態PRD/SBY/UDT/SDT/EGT/NST
- 使用階層篩選功能:
- **生產設備**:僅顯示列入生產統計的設備
- **重點設備**:僅顯示標記為重點監控的設備
- **監控設備**:僅顯示需特別監控的設備
- 設備狀態每 30 秒自動更新
#### 設備歷史查詢
1. 選擇查詢日期範圍
2. 可選擇特定設備或工作中心
3. 查看歷史趨勢圖表和稼動率熱力圖
4. 支援 CSV 匯出
### 管理員登入
1. 點擊右上角「登入」按鈕
2. 輸入工號和密碼(使用 LDAP 認證)
3. 登入後可存取開發中功能頁面
4. 管理員可使用效能監控儀表板(`/admin/performance`
### 常見問題
**Q: 頁面顯示「資料載入中」很久沒反應?**
A: 請檢查網路連線,或重新整理頁面。如持續發生請通知系統管理員。
**Q: 查詢結果與預期不符?**
A: 請確認篩選條件是否正確設定。資料來源為 MES 系統,約有 30 秒延遲。
**Q: 無法匯出 Excel**
A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料。
---
## 功能說明
### Portal 入口頁面
透過 Tab 切換各功能模組:
- WIP 即時概況
- WIP 明細查詢
- Hold 狀態分析
- 設備狀態監控
- 設備歷史查詢
- 數據表查詢工具
- 抽屜分組導覽(報表類/查詢類/開發工具類)
### WIP 即時概況
- 總覽統計(總 LOT 數、總數量、總片數)
- 按 SPEC 和 WORKCENTER 統計
- 按產品線統計(匯總 + 明細)
- Hold 狀態分類(品質異常/非品質異常)
- 柏拉圖視覺化圖表
### WIP 明細查詢
- 依工作中心篩選
- 依 Package 篩選
- 依 Hold 狀態篩選
- 依製程站點篩選
- 支援 Excel 匯出
### Hold 狀態分析
- Hold 批次總覽
- 按 Hold 原因分類
- Hold 明細查詢
- 品質異常分類統計
### 設備狀態監控
- 即時設備狀態總覽PRD/SBY/UDT/SDT/EGT/NST
- 按工作中心群組統計
- 設備稼動率OU%與運轉率RUN%
- 階層篩選(廠區/產線/重點設備/監控設備)
- Redis 快取自動更新30 秒間隔)
### 設備歷史查詢
- 歷史狀態趨勢分析
- 稼動率熱力圖視覺化
- 設備狀態明細查詢
- 支援 CSV 匯出
### 管理員功能
- LDAP 認證登入(支援本地測試模式)
- 頁面狀態管理released/dev
- Dev 頁面僅管理員可見
### 效能監控儀表板
管理員專用的系統監控介面(`/admin/performance`
- **系統狀態總覽**Database、Redis、Circuit Breaker、Worker 狀態
- **查詢效能指標**P50/P95/P99 延遲、慢查詢統計、延遲分布圖
- **系統日誌檢視**:即時日誌查詢、等級篩選、關鍵字搜尋
- **日誌管理**:儲存統計、手動清理功能
- **Worker 控制**:優雅重啟(透過 Watchdog 機制)
- 自動更新30 秒間隔)
### 熔斷器保護機制
Circuit Breaker 模式保護資料庫免於雪崩效應:
- **CLOSED**:正常運作,請求通過
- **OPEN**:失敗過多,請求立即拒絕
- **HALF_OPEN**:測試恢復,允許有限請求
配置方式:
```bash
CIRCUIT_BREAKER_ENABLED=true
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
CIRCUIT_BREAKER_FAILURE_RATE=0.5
CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
```
---
## 技術架構
### 後端技術棧
| 技術 | 版本 | 用途 |
|------|------|------|
| Python | 3.11+ | 程式語言 |
| Flask | 3.x | Web 框架 |
| Gunicorn | 23.x | WSGI 伺服器 |
| SQLAlchemy | 2.x | ORM |
| oracledb | 2.x | Oracle 驅動 |
| Redis | 7.x | 快取伺服器 |
| Pandas | 2.x | 資料處理 |
### 前端技術棧
| 技術 | 用途 |
|------|------|
| Jinja2 | 模板引擎 |
| Vite 6 | 前端多頁模組打包 |
| ECharts | 圖表庫 |
| Vanilla JS Modules | 互動功能與頁面邏輯 |
### 資料庫
- Oracle Database 19c Enterprise Edition
- 主機: 詳見 .env 檔案 (DB_HOST:DB_PORT)
- 服務名: 詳見 .env 檔案 (DB_SERVICE)
---
## 專案結構
```
DashBoard_vite/
├── src/mes_dashboard/ # 主程式
│ ├── app.py # Flask 應用
│ ├── config/ # 設定
│ │ ├── settings.py # 環境設定
│ │ ├── constants.py # 常數定義
│ │ ├── field_contracts.py # UI/API/Export 欄位契約
│ │ └── workcenter_groups.py # 工作中心群組設定
│ ├── core/ # 核心模組
│ │ ├── database.py # 資料庫連線
│ │ ├── redis_client.py # Redis 客戶端
│ │ ├── cache.py # 快取管理
│ │ ├── cache_updater.py # 快取自動更新
│ │ ├── circuit_breaker.py # 熔斷器
│ │ ├── metrics.py # 效能指標收集
│ │ ├── log_store.py # SQLite 日誌儲存
│ │ ├── response.py # API 回應格式
│ │ └── permissions.py # 權限管理
│ ├── routes/ # 路由
│ │ ├── wip_routes.py # WIP 相關 API
│ │ ├── resource_routes.py # 設備狀態 API
│ │ ├── dashboard_routes.py # 儀表板 API
│ │ └── ... # 其他路由
│ ├── services/ # 服務層
│ │ ├── wip_service.py # WIP 業務邏輯
│ │ ├── resource_service.py # 設備狀態邏輯
│ │ ├── resource_cache.py # 設備快取服務
│ │ └── ... # 其他服務
│ ├── sql/ # SQL 查詢管理
│ │ ├── loader.py # SQLLoader (LRU 快取)
│ │ ├── builder.py # QueryBuilder (參數化)
│ │ ├── filters.py # CommonFilters
│ │ ├── dashboard/ # 儀表板查詢
│ │ ├── resource/ # 設備查詢
│ │ ├── wip/ # WIP 查詢
│ │ └── resource_history/ # 設備歷史查詢
│ └── templates/ # HTML 模板
├── frontend/ # Vite 前端專案
│ ├── src/core/ # 共用 API/欄位契約/計算 helper
│ ├── src/portal/ # Portal entry
│ ├── src/resource-status/ # 設備即時概況 entry
│ ├── src/resource-history/ # 設備歷史績效 entry
│ ├── src/job-query/ # 設備維修查詢 entry
│ ├── src/excel-query/ # Excel 批次查詢 entry
│ └── src/tables/ # 數據表查詢 entry
├── shared/
│ └── field_contracts.json # 前後端共用欄位契約
├── scripts/ # 腳本
│ ├── deploy.sh # 部署腳本
│ ├── start_server.sh # 服務管理腳本
│ └── worker_watchdog.py # Worker 監控程式
├── deploy/ # 部署設定
│ ├── mes-dashboard.service # Gunicorn systemd 服務 (Conda)
│ └── mes-dashboard-watchdog.service # Watchdog systemd 服務 (Conda)
├── tests/ # 測試
├── data/ # 資料檔案
├── logs/ # 日誌
├── docs/ # 文檔
├── openspec/ # 變更管理
├── .env.example # 環境變數範例
├── requirements.txt # Python 依賴
└── gunicorn.conf.py # Gunicorn 設定
```
---
## 測試
```bash
# 執行所有測試
pytest tests/ -v
# 執行單元測試
pytest tests/test_*.py -v --ignore=tests/e2e --ignore=tests/stress
# 執行整合測試
pytest tests/test_*_integration.py -v
# 執行 E2E 測試
pytest tests/e2e/ -v
# 執行壓力測試
pytest tests/stress/ -v
```
---
## 故障排除
### 服務無法啟動
1. 檢查 Conda 環境:
```bash
conda activate mes-dashboard
```
2. 檢查依賴:
```bash
pip install -r requirements.txt
```
3. 檢查日誌:
```bash
./scripts/start_server.sh logs error
```
### 資料庫連線失敗
1. 確認 `.env` 中的資料庫設定正確
2. 確認網路可連線到資料庫伺服器
3. 確認資料庫帳號密碼正確
### Port 被占用
1. 檢查 port 使用狀況:
```bash
lsof -i :8080
```
2. 修改 `.env` 中的 `GUNICORN_BIND` 設定
---
## 變更日誌
### 2026-02-08
- 完成並封存提案 `post-migration-resilience-governance`
- 新增 runtime 韌性診斷核心thresholds / restart churn / recovery recommendation
- health 與 admin API 新增可操作韌性欄位:
- `/health`、`/health/deep`
- `/admin/api/system-status`、`/admin/api/worker/status`
- watchdog restart state 支援 bounded history`WATCHDOG_RESTART_HISTORY_MAX`
- WIP overview/detail 抽離共用 autocomplete/filter 模組(`frontend/src/core/autocomplete.js`
- 新增前端 Node 測試流程(`npm --prefix frontend test`
- 更新 `README.mdj` 與 migration runbook 文件對齊 gate
### 2026-02-07
- 完成並封存提案 `dashboard-vite-root-refactor`
- 完成並封存提案 `dashboard-vite-complete-migration`
- 完成並封存提案 `vite-jinja-report-parity-hardening`
- 完成並封存提案 `hold-detail-vite-hardening`
- 完成單一 port Vite 架構切換,根目錄成為唯一執行與部署主體
- 完成 portal 抽屜分類導航、獨立頁與 drill-down 路徑對齊
- 完成欄位契約治理與下載欄位一致性驗證
- 完成 runtime resiliencepool/circuit/degraded contract與 migration gates/runbook 建立
### 2026-02-04
- 新增效能監控儀表板(`/admin/performance`
- 新增熔斷器保護機制Circuit Breaker
- 新增效能指標收集P50/P95/P99 延遲、慢查詢統計)
- 新增 SQLite 日誌儲存與管理功能
- 新增 Worker Watchdog 重啟機制
- 新增統一 API 回應格式success_response/error_response
- 新增 404/500 錯誤頁面模板
- 修復熔斷器 get_status() 死鎖問題
- 修復 health_routes.py 模組匯入錯誤
- 新增 psutil 依賴用於 Worker 狀態監控
- 新增完整測試套件59 個效能相關測試)
### 2026-02-03
- 重構 SQL 查詢管理架構,提升安全性與效能
- 新增 SQLLoader (LRU 快取)、QueryBuilder (參數化)、CommonFilters 模組
- 抽取 20 個 SQL 檔案至 `src/mes_dashboard/sql/` 目錄
- 修復所有 SQL 注入風險LIKE 萬用字元跳脫、IN 條件參數化)
- 優化 workcenter_cards API 回應時間55s → 0.1s
### 2026-02-02
- 新增 Hold Summary 柏拉圖視覺化圖表
- 設備頁面統一排序、階層篩選與標籤優化
### 2026-01-30
- 新增本地認證模式支援開發測試環境
### 2026-01-29
- 新增設備狀態監控頁面
- 新增設備歷史查詢頁面
- 整合 Redis 快取系統30 秒自動更新)
### 2026-01-28
- 新增管理員認證系統LDAP 整合)
- 新增頁面狀態管理released/dev
- 新增部署腳本 `deploy.sh`
- 更新啟動腳本自動載入 `.env`
- 新增完整測試套件57 個測試)
### 2026-01-27
- 新增 Hold Detail 頁面
- WIP 查詢排除原物料
- Hold 狀態分類(品質異常/非品質異常)
### 2026-01-26
- 重構為 Flask App Factory 模式
- 新增全域連線管理
- 新增 WIP 篩選增強功能
---
## 聯絡方式
如有技術問題或需求變更,請聯繫系統管理員。
---
**文檔版本**: 4.1
**最後更新**: 2026-02-08

61
README.mdj Normal file
View File

@@ -0,0 +1,61 @@
# MES Dashboard Architecture Snapshot (README.mdj)
本檔案為 `README.md` 的架構摘要鏡像,重點反映目前已完成的 Vite + 單一 port 運行契約與韌性治理策略。
## Runtime Contract
- 單一服務單一 port`GUNICORN_BIND`(預設 `0.0.0.0:8080`
- 前端資產由 Vite build 到 `src/mes_dashboard/static/dist/`,由 Flask/Gunicorn 同源提供
- Watchdog 透過 restart flag + `SIGHUP` 進行 graceful worker reload
## Resilience Contract
- 降級回應:`DB_POOL_EXHAUSTED`、`CIRCUIT_BREAKER_OPEN` + `Retry-After`
- health/admin 診斷輸出包含:
- thresholds
- restart churn summary
- recovery recommendation
- 不預設啟用自動重啟;維持受控人工觸發,避免重啟風暴
## Frontend Governance
- WIP overview/detail 的 autocomplete/filter 查詢邏輯共用 `frontend/src/core/autocomplete.js`
- 目標:維持既有操作語意,同時降低重複邏輯與維護成本
- 前端核心模組測試:`npm --prefix frontend test`
## 開發歷史(摘要)
### 2026-02-08
- 封存 `post-migration-resilience-governance`
- 新增韌性診斷欄位thresholds/churn/recommendation
- 完成 WIP autocomplete 共用模組化與前端測試腳本
### 2026-02-07
- 封存完整 Vite 遷移相關提案群組
- 單一 port 架構、抽屜導航、欄位契約治理與 migration gates 就位
## Key Configs
```bash
WATCHDOG_RUNTIME_DIR=./tmp
WATCHDOG_RESTART_FLAG=./tmp/mes_dashboard_restart.flag
WATCHDOG_PID_FILE=./tmp/gunicorn.pid
WATCHDOG_STATE_FILE=./tmp/mes_dashboard_restart_state.json
WATCHDOG_RESTART_HISTORY_MAX=50
RESILIENCE_DEGRADED_ALERT_SECONDS=300
RESILIENCE_POOL_SATURATION_WARNING=0.90
RESILIENCE_POOL_SATURATION_CRITICAL=1.0
RESILIENCE_RESTART_CHURN_WINDOW_SECONDS=600
RESILIENCE_RESTART_CHURN_THRESHOLD=3
```
## Validation Quick Commands
```bash
npm --prefix frontend test
npm --prefix frontend run build
python -m pytest -q tests/test_resilience.py tests/test_health_routes.py tests/test_performance_integration.py
```
> 詳細部署、使用說明與完整環境配置請參考 `README.md`。

56
data/page_status.json Normal file
View File

@@ -0,0 +1,56 @@
{
"pages": [
{
"route": "/",
"name": "首頁",
"status": "released"
},
{
"route": "/wip-overview",
"name": "WIP 即時概況",
"status": "released"
},
{
"route": "/wip-detail",
"name": "WIP 明細",
"status": "released"
},
{
"route": "/hold-detail",
"name": "Hold 明細",
"status": "released"
},
{
"route": "/resource-history",
"name": "設備歷史績效",
"status": "released"
},
{
"route": "/tables",
"name": "表格總覽",
"status": "dev"
},
{
"route": "/resource",
"name": "機台狀態",
"status": "released"
},
{
"route": "/excel-query",
"name": "Excel 批次查詢",
"status": "dev"
},
{
"route": "/job-query",
"name": "設備維修查詢",
"status": "released"
}
],
"api_public": true,
"db_scan": {
"schema": "DWH",
"updated_at": "2026-01-29 13:49:59",
"object_count": 19,
"source": "tools/query_table_schema.py"
}
}

12093
data/table_schema_info.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
[Unit]
Description=MES Dashboard Worker Watchdog (Conda Runtime)
Documentation=https://github.com/your-org/mes-dashboard
After=network.target mes-dashboard.service
Requires=mes-dashboard.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/mes-dashboard
EnvironmentFile=-/etc/mes-dashboard/mes-dashboard.env
Environment="PYTHONPATH=/opt/mes-dashboard/src"
Environment="CONDA_BIN=/opt/miniconda3/bin/conda"
Environment="CONDA_ENV_NAME=mes-dashboard"
Environment="WATCHDOG_RUNTIME_DIR=/run/mes-dashboard"
Environment="WATCHDOG_RESTART_FLAG=/run/mes-dashboard/mes_dashboard_restart.flag"
Environment="WATCHDOG_PID_FILE=/run/mes-dashboard/gunicorn.pid"
Environment="WATCHDOG_STATE_FILE=/var/lib/mes-dashboard/restart_state.json"
Environment="WATCHDOG_CHECK_INTERVAL=5"
RuntimeDirectory=mes-dashboard
StateDirectory=mes-dashboard
ExecStart=/usr/bin/env bash -lc 'exec "${CONDA_BIN}" run --no-capture-output -n "${CONDA_ENV_NAME}" python scripts/worker_watchdog.py'
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mes-watchdog
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/run/mes-dashboard /var/lib/mes-dashboard
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,43 @@
[Unit]
Description=MES Dashboard Gunicorn Service (Conda Runtime)
Documentation=https://github.com/your-org/mes-dashboard
After=network.target redis-server.service
Wants=redis-server.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/mes-dashboard
EnvironmentFile=-/etc/mes-dashboard/mes-dashboard.env
Environment="PYTHONPATH=/opt/mes-dashboard/src"
Environment="CONDA_BIN=/opt/miniconda3/bin/conda"
Environment="CONDA_ENV_NAME=mes-dashboard"
Environment="GUNICORN_BIND=0.0.0.0:8080"
Environment="WATCHDOG_RUNTIME_DIR=/run/mes-dashboard"
Environment="WATCHDOG_RESTART_FLAG=/run/mes-dashboard/mes_dashboard_restart.flag"
Environment="WATCHDOG_PID_FILE=/run/mes-dashboard/gunicorn.pid"
Environment="WATCHDOG_STATE_FILE=/var/lib/mes-dashboard/restart_state.json"
RuntimeDirectory=mes-dashboard
StateDirectory=mes-dashboard
PIDFile=/run/mes-dashboard/gunicorn.pid
ExecStart=/usr/bin/env bash -lc 'exec "${CONDA_BIN}" run --no-capture-output -n "${CONDA_ENV_NAME}" gunicorn --config gunicorn.conf.py --pid "${WATCHDOG_PID_FILE}" --capture-output "mes_dashboard:create_app()"'
KillSignal=SIGTERM
TimeoutStopSec=30
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mes-dashboard
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/run/mes-dashboard /var/lib/mes-dashboard /opt/mes-dashboard/logs
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,60 @@
SELECT L.LOTID AS ""Run Card Lot ID"",
L.Workorder AS ""Work Order ID"",
L.Qty AS ""Lot Qty(pcs)"",
L.Qty2 AS ""Lot Qty(Wafer pcs)"",
L.Status AS ""Run Card Status"",
L.HOLDREASONNAME AS ""Hold Reason"",
L.CurrentHoldCount AS ""Hold Count"",
L.Owner AS ""Work Order Owner"",
L.StartDate AS ""Run Card Start Date"",
L.UTS,
L.Product AS ""Product P/N"",
L.Productlinename AS ""Package"",
L.Package_LEF as ""Package(LF)"",
L.PJ_FUNCTION AS ""Product Function"",
L.Pj_Type AS ""Product Type"",
L.BOP,
L.FirstName AS ""Wafer Lot ID"",
L.WAFERNAME AS ""Wafer P/N"",
L.WaferLot ""Wafer Lot ID(Prefix)"",
L.SpecName AS ""Spec"",
L.SPECSEQUENCE AS ""Spec Sequence"",
L.SPECSEQUENCE || '_' || L.SpecName AS ""Spec(Order)"",
L.Workcentername AS ""Work Center"",
L.WorkCenterSequence AS ""Work Center Sequence"",
L.WorkCenter_Group AS ""Work Center(Group)"",
L.WorkCenter_Short AS ""Work Center(Short)"",
L.WorkCenterSequence_Group AS ""Work Center Sequence(Group)"",
L.WorkCenterSequence_Group || '_' || L.WorkCenter_Group AS ""Work Center Group(Order)"",
L.AgeByDays AS ""Age By Days"",
L.Equipments AS ""Equipment ID"",
L.EquipmentCount AS ""Equipment Count"",
L.Workflowname AS ""Work Flow Name"",
L.Datecode AS ""Product Date Code"",
L.LEADFRAMENAME AS ""LF Material Part"",
L.LEADFRAMEOPTION AS ""LF Option ID"",
L.COMNAME AS ""Compound Material Part"",
L.LOCATIONNAME AS ""Run Card Location"",
L.Eventname AS ""NCR ID"",
L.Occurrencedate AS ""NCR-issued Time"",
L.ReleaseTime AS ""Release Time"",
L.ReleaseEmp AS ""Release Employee"",
L.ReleaseReason AS ""Release Comment"",
L.COMMENT_HOLD AS ""Hold Comment"",
L.CONTAINERCOMMENTS AS ""Comment"",
L.COMMENT_DATE AS ""Run Card Comment"",
L.COMMENT_EMP AS ""Run Card Comment Employee"",
L.COMMENT_FUTURE AS ""Future Hold Comment"",
L.HOLDEMP AS ""Hold Employee"",
L.DEPTNAME AS ""Hold Employee Dept"",
L.PJ_PRODUCEREGION AS ""Produce Region"",
L.Prioritycodename AS ""Work Order Priority"",
L.TMTT_R AS ""TMTT Remaining"",
L.wafer_factor AS ""Die Consumption Qty"",
Case When (L.EquipmentCount>0) Then 'RUN'
When (L.CurrentHoldCount>0) Then 'HOLD'
ELSE 'QUENE' End AS ""WIP Status"",
Case When (L.EquipmentCount>0) Then 1
When (L.CurrentHoldCount>0) Then 3
ELSE 2 End AS ""WIP Status Sequence"",
sys_date AS ""Data Update Date""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
# Oracle 可使用 TABLE/VIEW 清單DWH
**產生時間**: 2026-01-29 13:34:22
**使用者**: (詳見 .env 中的 DB_USER)
**Schema**: DWH
## 摘要
- 可使用物件總數: 19
- TABLE: 16
- VIEW: 3
- 來源 (去重後物件數): DIRECT 19, PUBLIC 0, ROLE 0, SYSTEM 0
## 物件清單
| 物件 | 類型 | 權限 | 授權來源 |
|------|------|------|----------|
| `DWH.DW_MES_CONTAINER` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_EQUIPMENTSTATUS_WIP_V` | VIEW | SELECT | DIRECT |
| `DWH.DW_MES_HM_LOTMOVEOUT` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_HOLDRELEASEHISTORY` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_JOB` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_JOBTXNHISTORY` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_LOTMATERIALSHISTORY` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_LOTREJECTHISTORY` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_LOTWIPDATAHISTORY` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_LOTWIPHISTORY` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_LOT_V` | VIEW | SELECT | DIRECT |
| `DWH.DW_MES_MAINTENANCE` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_PARTREQUESTORDER` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_PJ_COMBINEDASSYLOTS` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_RESOURCE` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_RESOURCESTATUS` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_RESOURCESTATUS_SHIFT` | TABLE | SELECT | DIRECT |
| `DWH.DW_MES_SPEC_WORKCENTER_V` | VIEW | SELECT | DIRECT |
| `DWH.DW_MES_WIP` | TABLE | SELECT | DIRECT |

View File

@@ -0,0 +1,936 @@
# MES Dashboard - Architecture Findings
本文件記錄專案開發過程中確立的架構設計、全局規範與資料處理規則。
---
## 1. 資料庫連線管理
### 連線池統一使用
所有資料庫操作必須透過 `mes_dashboard.core.database` 模組:
```python
from mes_dashboard.core.database import read_sql_df, get_engine
# 讀取資料 (推薦方式)
df = read_sql_df(sql, params)
# 取得 engine若需要直接操作
engine = get_engine()
```
### 連線池配置 (位置: `core/database.py`)
| 參數 | 開發環境 | 生產環境 | 說明 |
|------|---------|---------|------|
| pool_size | 2 | 10 | 基礎連線數 |
| max_overflow | 3 | 20 | 額外連線數 |
| pool_timeout | 30 | 30 | 等待超時 (秒) |
| pool_recycle | 1800 | 1800 | 回收週期 (30分鐘) |
| pool_pre_ping | True | True | 使用前驗證連線 |
### Keep-Alive 機制
- 背景執行緒每 5 分鐘執行 `SELECT 1 FROM DUAL`
- 防止 NAT/防火牆斷開閒置連線
- 啟動: `start_keepalive()`,停止: `stop_keepalive()`
### 注意事項
- **禁止**在各 service 中自行建立連線
- **禁止**直接使用 `oracledb.connect()`
- 連線池由 `database.py` 統一管理,避免連線洩漏
- 測試環境需在 setUp 中重置:`db._ENGINE = None`
---
## 2. SQL 集中管理
### 目錄結構
所有 SQL 查詢放在 `src/mes_dashboard/sql/` 目錄:
```
sql/
├── loader.py # SQL 檔案載入器 (LRU 快取)
├── builder.py # 參數化查詢構建器
├── filters.py # 通用篩選條件
├── dashboard/ # 儀表板 SQL
│ ├── kpi.sql
│ ├── heatmap.sql
│ └── workcenter_cards.sql
├── wip/ # WIP SQL
│ ├── summary.sql
│ └── detail.sql
├── resource/ # 設備 SQL
│ ├── by_status.sql
│ └── detail.sql
├── resource_history/ # 歷史 SQL
└── job_query/ # 維修工單 SQL
```
### SQLLoader 使用方式
```python
from mes_dashboard.sql.loader import SQLLoader
# 載入 SQL 檔案 (自動 LRU 快取,最多 100 個)
sql = SQLLoader.load("wip/summary")
# 結構性參數替換 (用於 SQL 片段)
sql = SQLLoader.load_with_params("dashboard/kpi",
LATEST_STATUS_SUBQUERY="...",
WHERE_CLAUSE="...")
# 清除快取
SQLLoader.clear_cache()
```
### QueryBuilder 使用方式
```python
from mes_dashboard.sql.builder import QueryBuilder
builder = QueryBuilder()
# 添加條件 (自動參數化,防 SQL 注入)
builder.add_param_condition("STATUS", "PRD")
builder.add_in_condition("STATUS", ["PRD", "SBY"])
builder.add_not_in_condition("HOLD_REASON", exclude_list)
builder.add_like_condition("LOTID", user_input, position="both")
builder.add_or_like_conditions(["COL1", "COL2"], [val1, val2])
builder.add_is_null("COLUMN")
builder.add_is_not_null("COLUMN")
builder.add_condition("FIXED_CONDITION = 1") # 固定條件
# 構建 WHERE 子句
where_clause, params = builder.build_where_only()
# 替換佔位符並執行
sql = sql.replace("{{ WHERE_CLAUSE }}", where_clause)
df = read_sql_df(sql, params)
```
### 佔位符規範
| 類型 | 語法 | 用途 | 安全性 |
|------|------|------|--------|
| 結構性 | `{{ PLACEHOLDER }}` | 靜態 SQL 片段 | 僅限預定義值 |
| 參數 | `:param_name` | 動態用戶輸入 | Oracle bind variables |
### Oracle IN 子句限制
Oracle IN 子句上限 1000 個值,需分批處理:
```python
BATCH_SIZE = 1000
# 參考 job_query_service.py 的 _build_resource_filter()
```
---
## 3. 快取機制
### 多層快取架構
```
請求 → 進程級快取 (30 秒 TTL)
→ Redis 快取 (可配置 TTL)
→ Oracle 資料庫
```
### 全局快取 API
使用 `mes_dashboard.core.cache` 模組:
```python
from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key
# 建立快取 key支援 filters dict
cache_key = make_cache_key("resource_history_summary", filters={
'start_date': start_date,
'workcenter_groups': sorted(groups) if groups else None,
})
# 讀取/寫入快取
result = cache_get(cache_key)
if result is None:
result = query_data()
cache_set(cache_key, result, ttl=CACHE_TTL_TREND)
```
### 快取 TTL 常數
定義於 `mes_dashboard.config.constants`
```python
CACHE_TTL_DEFAULT = 60 # 1 分鐘
CACHE_TTL_FILTER_OPTIONS = 600 # 10 分鐘
CACHE_TTL_PIVOT_COLUMNS = 300 # 5 分鐘
CACHE_TTL_KPI = 60 # 1 分鐘
CACHE_TTL_TREND = 300 # 5 分鐘
```
### Redis 快取配置
環境變數:
```
REDIS_ENABLED=true
REDIS_URL=redis://localhost:6379/0
REDIS_KEY_PREFIX=mes_wip
```
### 專用快取服務
| 服務 | 位置 | 用途 |
|------|------|------|
| WIP 快取更新器 | `core/cache_updater.py` | 背景線程自動更新 WIP 數據 |
| 資源快取 | `services/resource_cache.py` | DW_MES_RESOURCE 表快取 (4 小時同步) |
| 設備狀態快取 | `services/realtime_equipment_cache.py` | 設備實時狀態 (5 分鐘同步) |
| Filter 快取 | `services/filter_cache.py` | 篩選選項快取 |
---
## 4. Filter Cache篩選選項快取
### 位置
`mes_dashboard.services.filter_cache`
### 用途
快取全站共用的篩選選項,避免重複查詢資料庫:
```python
from mes_dashboard.services.filter_cache import (
get_workcenter_groups, # 取得 workcenter group 列表
get_workcenter_mapping, # 取得 workcentername → group 對應
get_workcenters_for_groups, # 根據 group 取得 workcentername 列表
get_resource_families, # 取得 resource family 列表
)
```
### Workcenter 對應關係
```
WORKCENTERNAME (資料庫) → WORKCENTER_GROUP (顯示)
焊接_DB_1 → 焊接_DB
焊接_DB_2 → 焊接_DB
成型_1 → 成型
```
### 資料來源
- Workcenter Groups: `DW_PJ_LOT_V` (WORKCENTER_GROUP, WORKCENTERSEQUENCE_GROUP)
- Resource Families: `DW_MES_RESOURCE` (RESOURCEFAMILYNAME)
---
## 5. 熔斷器 (Circuit Breaker)
### 位置
`mes_dashboard.core.circuit_breaker`
### 狀態機制
```
CLOSED (正常)
↓ 失敗達到閾值
OPEN (故障,拒絕請求)
↓ 等待 recovery_timeout
HALF_OPEN (測試恢復)
↓ 成功 → CLOSED / 失敗 → OPEN
```
### 配置 (環境變數)
```
CIRCUIT_BREAKER_ENABLED=true
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 # 最少失敗次數
CIRCUIT_BREAKER_FAILURE_RATE=0.5 # 失敗率閾值 (0.0-1.0)
CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30 # OPEN 狀態等待秒數
CIRCUIT_BREAKER_WINDOW_SIZE=10 # 滑動窗口大小
```
### 使用方式
熔斷器已整合在 `read_sql_df()` 中,自動:
- 檢查是否允許請求
- 記錄成功/失敗
- 狀態轉移
### 狀態查詢
```python
from mes_dashboard.core.circuit_breaker import get_database_circuit_breaker
cb = get_database_circuit_breaker()
status = cb.get_status()
# status.state, status.failure_count, status.success_count, status.failure_rate
```
---
## 6. 統一 API 響應格式
### 位置
`mes_dashboard.core.response`
### 響應格式
```python
# 成功響應
{
"success": True,
"data": {...},
"meta": {"timestamp": "2024-02-04T10:30:45.123456"}
}
# 錯誤響應
{
"success": False,
"error": {
"code": "DB_CONNECTION_FAILED",
"message": "資料庫連線失敗,請稍後再試",
"details": "ORA-12541" # 僅開發模式
},
"meta": {"timestamp": "..."}
}
```
### 錯誤代碼
| 代碼 | HTTP | 說明 |
|------|------|------|
| DB_CONNECTION_FAILED | 503 | 資料庫連線失敗 |
| DB_QUERY_TIMEOUT | 504 | 查詢逾時 |
| DB_QUERY_ERROR | 500 | 查詢執行錯誤 |
| SERVICE_UNAVAILABLE | 503 | 服務不可用 |
| CIRCUIT_BREAKER_OPEN | 503 | 熔斷器開啟 |
| VALIDATION_ERROR | 400 | 驗證失敗 |
| UNAUTHORIZED | 401 | 未授權 |
| FORBIDDEN | 403 | 禁止訪問 |
| NOT_FOUND | 404 | 不存在 |
| TOO_MANY_REQUESTS | 429 | 過多請求 |
| INTERNAL_ERROR | 500 | 內部錯誤 |
### 便利函數
```python
from mes_dashboard.core.response import (
success_response,
validation_error, # 400
unauthorized_error, # 401
forbidden_error, # 403
not_found_error, # 404
db_connection_error, # 503
internal_error, # 500
)
```
---
## 7. 認證與授權機制
### 認證服務
位置: `mes_dashboard.services.auth_service`
#### LDAP 認證 (生產環境)
```python
from mes_dashboard.services.auth_service import authenticate
user = authenticate(username, password)
# 返回: {username, displayName, mail, department}
```
#### 本地認證 (開發環境)
```
LOCAL_AUTH_ENABLED=true
LOCAL_AUTH_USERNAME=admin
LOCAL_AUTH_PASSWORD=password
```
### Session 管理
```python
# 登入後存入 session
session["admin"] = {
"username": user.get("username"),
"displayName": user.get("displayName"),
"mail": user.get("mail"),
"department": user.get("department"),
"login_time": datetime.now().isoformat()
}
# Session 配置
SESSION_COOKIE_SECURE = True # HTTPS only (生產)
SESSION_COOKIE_HTTPONLY = True # 防止 JS 訪問
SESSION_COOKIE_SAMESITE = 'Lax' # CSRF 防護
PERMANENT_SESSION_LIFETIME = 28800 # 8 小時
```
### 權限檢查
位置: `mes_dashboard.core.permissions`
```python
from mes_dashboard.core.permissions import is_admin_logged_in, admin_required
# 檢查登入狀態
if is_admin_logged_in():
...
# 裝飾器保護路由
@admin_required
def admin_only_view():
...
```
### 登入速率限制
- 單 IP 每 5 分鐘最多 5 次嘗試
- 位置: `routes/auth_routes.py`
---
## 8. 頁面狀態管理
### 位置
- 服務: `mes_dashboard.services.page_registry`
- 數據: `data/page_status.json`
### 狀態定義
| 狀態 | 說明 |
|------|------|
| `released` | 所有用戶可訪問 |
| `dev` | 僅管理員可訪問 |
| `None` | 未註冊,由 Flask 路由控制 |
### 數據格式
```json
{
"pages": [
{"route": "/wip-overview", "name": "WIP 即時概況", "status": "released"},
{"route": "/tables", "name": "表格總覽", "status": "dev"}
],
"api_public": true
}
```
### API
```python
from mes_dashboard.services.page_registry import (
get_page_status, # 取得頁面狀態
set_page_status, # 設定頁面狀態
is_api_public, # API 是否公開
get_all_pages, # 取得所有頁面
)
```
### 權限檢查 (自動)
`app.py``@app.before_request` 中自動執行:
- dev 頁面 + 非管理員 → 403
---
## 9. 日誌系統
### 雙層日誌架構
| 層級 | 目標 | 用途 |
|------|------|------|
| 控制台 (stderr) | Gunicorn 捕獲 | 即時監控 |
| SQLite | 管理員儀表板 | 歷史查詢 |
### 配置 (環境變數)
```
LOG_STORE_ENABLED=true
LOG_SQLITE_PATH=logs/admin_logs.sqlite
LOG_SQLITE_RETENTION_DAYS=7
LOG_SQLITE_MAX_ROWS=100000
```
### 日誌記錄規範
```python
import logging
logger = logging.getLogger('mes_dashboard')
logger.debug("詳細調試資訊")
logger.info("一般操作記錄")
logger.warning("警告但可繼續")
logger.error("錯誤需要關注", exc_info=True) # 包含堆棧
```
### SQLite 日誌查詢
位置: `mes_dashboard.core.log_store`
```python
from mes_dashboard.core.log_store import get_log_store
store = get_log_store()
logs = store.query_logs(
level="ERROR",
limit=100,
offset=0,
search="keyword"
)
```
---
## 10. 健康檢查
### 端點
| 端點 | 認證 | 說明 |
|------|------|------|
| `/health` | 無需 | 基本健康檢查 |
| `/health/deep` | 需管理員 | 詳細指標 |
### 基本檢查項目
- 資料庫連線 (`SELECT 1 FROM DUAL`)
- Redis 連線 (`PING`)
- 各快取狀態
### 詳細檢查項目 (deep)
- 資料庫延遲 (毫秒)
- 連線池狀態 (size, checked_out, overflow)
- 快取新鮮度
- 熔斷器狀態
- 查詢性能指標 (P50/P95/P99)
### 狀態判定
- `200 OK` (healthy/degraded): DB 正常
- `503 Unavailable` (unhealthy): DB 故障
---
## 11. API 路由結構 (Blueprint)
### Blueprint 列表
| Blueprint | URL 前綴 | 檔案 |
|-----------|---------|------|
| wip | `/api/wip` | `wip_routes.py` |
| resource | `/api/resource` | `resource_routes.py` |
| dashboard | `/api/dashboard` | `dashboard_routes.py` |
| excel_query | `/api/excel-query` | `excel_query_routes.py` |
| hold | `/api/hold` | `hold_routes.py` |
| resource_history | `/api/resource-history` | `resource_history_routes.py` |
| job_query | `/api/job-query` | `job_query_routes.py` |
| admin | `/admin` | `admin_routes.py` |
| auth | `/admin` | `auth_routes.py` |
| health | `/` | `health_routes.py` |
### 路由註冊
位置: `routes/__init__.py``register_routes(app)`
---
## 12. 前端全局組件
### Toast 通知
定義於 `static/js/toast.js`,透過 `_base.html` 載入:
```javascript
// 正確用法
Toast.info('訊息');
Toast.success('成功');
Toast.warning('警告');
Toast.error('錯誤', { retry: () => loadData() });
const id = Toast.loading('載入中...');
Toast.update(id, { message: '完成!' });
Toast.dismiss(id);
// 錯誤用法(不存在)
MESToast.warning('...'); // ❌ 錯誤
```
### 自動消失時間
- info: 3000ms
- success: 2000ms
- warning: 5000ms
- error: 永久(需手動關閉)
- loading: 永久
### MesApiHTTP 請求)
定義於 `static/js/mes-api.js`
```javascript
// GET 請求
const data = await MesApi.get('/api/wip/summary', {
params: { page: 1 },
timeout: 60000,
retries: 5,
signal: abortController.signal,
silent: true // 禁用 toast 通知
});
// POST 請求
const data = await MesApi.post('/api/query_table', {
table_name: 'TABLE_A',
filters: {...}
});
```
### MesApi 特性
- 自動重試 (3 次,指數退避: 1s, 2s, 4s)
- 自動 Toast 通知
- 請求 ID 追蹤
- AbortSignal 支援
- 4xx 不重試5xx 重試
---
## 13. 資料表預篩選規則
### 設備類型篩選
定義於 `mes_dashboard.config.constants.EQUIPMENT_TYPE_FILTER`
```sql
((OBJECTCATEGORY = 'ASSEMBLY' AND OBJECTTYPE = 'ASSEMBLY')
OR (OBJECTCATEGORY = 'WAFERSORT' AND OBJECTTYPE = 'WAFERSORT'))
```
### 排除條件
```python
# 排除的地點
EXCLUDED_LOCATIONS = [
'ATEC', 'F區', 'F區焊接站', '報廢', '實驗室',
'山東', '成型站_F區', '焊接F區', '無錫', '熒茂'
]
# 排除的資產狀態
EXCLUDED_ASSET_STATUSES = ['Disapproved']
```
### CommonFilters 使用
位置: `mes_dashboard.sql.filters`
```python
from mes_dashboard.sql.filters import CommonFilters
# 添加標準篩選
CommonFilters.add_location_exclusion(builder, 'r')
CommonFilters.add_asset_status_exclusion(builder, 'r')
CommonFilters.add_wip_base_filters(builder, filters)
CommonFilters.add_equipment_filter(builder, filters)
```
---
## 14. 資料庫欄位對應
### DW_MES_RESOURCE
| 常見錯誤 | 正確欄位名 |
|---------|-----------|
| ASSETSTATUS | PJ_ASSETSSTATUS雙 S|
| LOCATION | LOCATIONNAME |
| ISPRODUCTION | PJ_ISPRODUCTION |
| ISKEY | PJ_ISKEY |
| ISMONITOR | PJ_ISMONITOR |
### DW_MES_RESOURCESTATUS_SHIFT
| 欄位 | 說明 |
|-----|------|
| HISTORYID | 對應 DW_MES_RESOURCE.RESOURCEID |
| TXNDATE | 交易日期 |
| OLDSTATUSNAME | E10 狀態 (PRD, SBY, UDT, SDT, EGT, NST) |
| HOURS | 該狀態時數 |
### DW_PJ_LOT_V
| 欄位 | 說明 |
|-----|------|
| WORKCENTERNAME | 站點名稱(細分)|
| WORKCENTER_GROUP | 站點群組(顯示用)|
| WORKCENTERSEQUENCE_GROUP | 群組排序 |
---
## 15. E10 狀態定義
| 狀態 | 說明 | 計入 OU% |
|-----|------|---------|
| PRD | Production生產| 是(分子)|
| SBY | Standby待機| 是(分母)|
| UDT | Unscheduled Downtime非計畫停機| 是(分母)|
| SDT | Scheduled Downtime計畫停機| 是(分母)|
| EGT | Engineering Time工程時間| 是(分母)|
| NST | Non-Scheduled Time非排程時間| 否 |
### OU% 計算公式
```
OU% = PRD / (PRD + SBY + UDT + SDT + EGT) × 100
```
### 狀態顯示名稱
```python
STATUS_DISPLAY_NAMES = {
'PRD': '生產中',
'SBY': '待機',
'UDT': '非計畫停機',
'SDT': '計畫停機',
'EGT': '工程時間',
'NST': '未排單',
}
```
---
## 16. 配置管理
### 環境變數 (.env)
#### 資料庫
```
DB_HOST=<your_database_host>
DB_PORT=1521
DB_SERVICE=<your_service_name>
DB_USER=<your_username>
DB_PASSWORD=<your_password>
DB_POOL_SIZE=5
DB_MAX_OVERFLOW=10
```
> 實際值請參考 `.env` 或 `.env.example`
#### Flask
```
FLASK_ENV=production
FLASK_DEBUG=0
SECRET_KEY=your_secret_key
SESSION_LIFETIME=28800
```
#### 認證
```
LDAP_API_URL=<your_ldap_api_url>
ADMIN_EMAILS=<admin_email_list>
LOCAL_AUTH_ENABLED=false
```
#### Gunicorn
```
GUNICORN_BIND=0.0.0.0:8080
GUNICORN_WORKERS=4
GUNICORN_THREADS=8
```
#### 快取
```
REDIS_ENABLED=true
REDIS_URL=redis://localhost:6379/0
CACHE_CHECK_INTERVAL=600
RESOURCE_CACHE_ENABLED=true
RESOURCE_SYNC_INTERVAL=14400
```
#### 熔斷器
```
CIRCUIT_BREAKER_ENABLED=true
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
CIRCUIT_BREAKER_FAILURE_RATE=0.5
CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
```
#### 日誌
```
LOG_STORE_ENABLED=true
LOG_SQLITE_PATH=logs/admin_logs.sqlite
LOG_SQLITE_RETENTION_DAYS=7
```
### 環境配置類
位置: `mes_dashboard.config.settings`
```python
class DevelopmentConfig(Config):
DEBUG = True
DB_POOL_SIZE = 2
class ProductionConfig(Config):
DEBUG = False
DB_POOL_SIZE = 10
class TestingConfig(Config):
TESTING = True
DB_POOL_SIZE = 1
```
---
## 17. 平行查詢
### ThreadPoolExecutor
對於多個獨立查詢,使用平行執行提升效能:
```python
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=4) as executor:
futures = {
executor.submit(read_sql_df, kpi_sql): 'kpi',
executor.submit(read_sql_df, trend_sql): 'trend',
executor.submit(read_sql_df, heatmap_sql): 'heatmap',
}
for future in as_completed(futures):
query_name = futures[future]
results[query_name] = future.result()
```
### 注意事項
- Mock 測試時不能使用 `side_effect` 列表(順序不可預測)
- 應使用函式判斷 SQL 內容來回傳對應的 mock 資料
---
## 18. Oracle SQL 優化
### CTE MATERIALIZE Hint
防止 Oracle 優化器將 CTE inline 多次執行:
```sql
WITH shift_data AS (
SELECT /*+ MATERIALIZE */ HISTORYID, TXNDATE, OLDSTATUSNAME, HOURS
FROM DW_MES_RESOURCESTATUS_SHIFT
WHERE TXNDATE >= TO_DATE('2024-01-01', 'YYYY-MM-DD')
AND TXNDATE < TO_DATE('2024-01-07', 'YYYY-MM-DD') + 1
)
SELECT ...
```
### 日期範圍查詢
```sql
-- 包含 end_date 當天
WHERE TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
```
### 慢查詢警告
- 閾值: 1 秒 (警告)5 秒 (`SLOW_QUERY_THRESHOLD`)
- 自動記錄到日誌
---
## 19. 前端資料限制
### 明細資料上限
為避免瀏覽器記憶體問題,明細查詢有筆數限制:
```python
MAX_DETAIL_RECORDS = 5000
if total > MAX_DETAIL_RECORDS:
df = df.head(MAX_DETAIL_RECORDS)
truncated = True
```
前端顯示警告:
```javascript
if (result.truncated) {
Toast.warning(`資料超過 ${result.max_records} 筆,請使用篩選條件縮小範圍。`);
}
```
---
## 20. JavaScript 注意事項
### Array.reverse() 原地修改
```javascript
// 錯誤 - 原地修改陣列
const arr = [1, 2, 3];
arr.reverse(); // arr 被修改為 [3, 2, 1]
// 正確 - 建立新陣列
const reversed = arr.slice().reverse(); // arr 不變
// 或
const reversed = [...arr].reverse();
```
---
## 21. 測試規範
### 測試檔案結構
```
tests/
├── conftest.py # pytest fixtures
├── test_*_service.py # 單元測試service layer
├── test_*_routes.py # 整合測試API endpoints
├── e2e/
│ └── test_*_e2e.py # 端對端測試(完整流程)
└── stress/
└── test_*.py # 壓力測試
```
### 測試前重置
```python
def setUp(self):
db._ENGINE = None # 重置連線池
self.app = create_app('testing')
```
### 執行測試
```bash
# 單一模組
pytest tests/test_resource_history_service.py -v
# 全部相關測試
pytest tests/test_resource_history_*.py tests/e2e/test_resource_history_e2e.py -v
# 覆蓋率報告
pytest tests/ --cov=mes_dashboard
```
---
## 22. 錯誤處理模式
### 三層錯誤處理
```python
# 1. 路由層 - 驗證錯誤
@bp.route('/api/query')
def query():
if not request.json.get('table_name'):
return validation_error("table_name 為必填")
# 2. 服務層 - 業務錯誤 (優雅降級)
def get_wip_summary(filters):
try:
df = query_wip(filters)
if df.empty:
return None
return process_data(df)
except Exception as exc:
logger.error(f"WIP query failed: {exc}")
return None
# 3. 核心層 - 基礎設施錯誤
def read_sql_df(sql, params):
if not circuit_breaker.allow_request():
raise RuntimeError("Circuit breaker open")
```
### 全局錯誤處理
位置: `app.py``_register_error_handlers()`
- 401 → `unauthorized_error()`
- 403 → `forbidden_error()`
- 404 → JSON (API) 或 HTML (頁面)
- 500 → `internal_error()`
- Exception → 通用處理
---
## 參考檔案索引
| 功能 | 檔案位置 |
|------|---------|
| SQL 載入 | `src/mes_dashboard/sql/loader.py` |
| 查詢構建 | `src/mes_dashboard/sql/builder.py` |
| 通用篩選 | `src/mes_dashboard/sql/filters.py` |
| 資料庫操作 | `src/mes_dashboard/core/database.py` |
| 快取 | `src/mes_dashboard/core/cache.py` |
| 熔斷器 | `src/mes_dashboard/core/circuit_breaker.py` |
| API 響應 | `src/mes_dashboard/core/response.py` |
| 權限檢查 | `src/mes_dashboard/core/permissions.py` |
| 日誌存儲 | `src/mes_dashboard/core/log_store.py` |
| 配置類 | `src/mes_dashboard/config/settings.py` |
| 常量定義 | `src/mes_dashboard/config/constants.py` |
| 認證服務 | `src/mes_dashboard/services/auth_service.py` |
| 頁面狀態 | `src/mes_dashboard/services/page_registry.py` |
| Filter 快取 | `src/mes_dashboard/services/filter_cache.py` |
| 資源快取 | `src/mes_dashboard/services/resource_cache.py` |
| API 客戶端 | `src/mes_dashboard/static/js/mes-api.js` |
| Toast 系統 | `src/mes_dashboard/static/js/toast.js` |

View File

@@ -0,0 +1,34 @@
# Environment-dependent Gaps and Mitigation
## Oracle-dependent checks
### Gap
- Service/integration paths that execute Oracle SQL require live DB credentials and network reachability.
- Local CI-like runs may not have Oracle connectivity.
- In this environment, `tests/test_cache_integration.py` has Oracle-dependent fallback failures when cache fixtures are insufficient.
### Mitigation
- Keep unit tests isolated with mocks for SQL entry points.
- Reserve Oracle-connected tests for gated environments.
- Use `testing` config for app factory tests where possible.
## Redis-dependent checks
### Gap
- Redis availability differs across environments.
- Health/caching behavior differs between `L1+L2` and `L1-only degraded` modes.
### Mitigation
- Expose route-cache telemetry in `/health` and `/health/deep`.
- Keep degraded mode visible and non-fatal where DB remains healthy.
- Validate both modes in unit tests (`tests/test_cache.py`, `tests/test_health_routes.py`).
## Frontend build availability
### Gap
- Node/npm may be absent on constrained runtime nodes.
### Mitigation
- Keep inline script fallback in templates when dist assets are missing.
- Build artifacts in deployment pipeline where Node is available.
- Startup script logs fallback mode explicitly on build failure.

View File

@@ -0,0 +1,42 @@
# Frontend Compute Shift Plan
## Targeted Calculations
## Resource History (migrated to frontend helpers)
- `ou_pct`
- `availability_pct`
- status percentages:
- `prd_pct`
- `sby_pct`
- `udt_pct`
- `sdt_pct`
- `egt_pct`
- `nst_pct`
These are now computed by `frontend/src/core/compute.js` via:
- `buildResourceKpiFromHours`
- `calcOuPct`
- `calcAvailabilityPct`
- `calcStatusPct`
## Parity Rules
1. Rounding rule
- one decimal place, identical to backend (`round(..., 1)`)
2. Formula rule
- OU%: `PRD / (PRD + SBY + UDT + SDT + EGT)`
- Availability%: `(PRD + SBY + EGT) / (PRD + SBY + EGT + SDT + UDT + NST)`
- Status%: `status_hours / total_hours`
3. Zero denominator rule
- all percentages return `0`
4. Data compatibility rule
- backend keeps existing fields to preserve API compatibility
- frontend recomputes display values from hours for deterministic parity
## Validation
- Python backend formula baseline: `mes_dashboard.services.resource_history_service`
- Frontend parity check: `tests/test_frontend_compute_parity.py`

View File

@@ -0,0 +1,113 @@
# Migration Gates and Runbook
## Gate Checklist (Cutover Readiness)
A release is cutover-ready only when all gates pass:
1. Frontend build gate
- `npm --prefix frontend run build` succeeds
- expected artifacts exist in `src/mes_dashboard/static/dist/`
2. Root execution gate
- startup and deploy scripts run from repository root only
- no runtime dependency on any legacy subtree path
3. Functional parity gate
- resource-history frontend compute parity checks pass
- job-query/resource-history export headers match shared field contracts
4. Cache observability gate
- `/health` returns route cache telemetry and degraded flags
- `/health/deep` returns route cache telemetry for diagnostics
- `/health` includes `database_pool.runtime/state`, `degraded_reason`
- resource/wip derived index telemetry is visible (`resource_cache.derived_index`, `cache.derived_search_index`)
5. Runtime resilience gate
- pool exhaustion path returns `503` + `DB_POOL_EXHAUSTED` and `Retry-After`
- circuit-open path returns `503` + `CIRCUIT_BREAKER_OPEN` and fail-fast semantics
- frontend client does not aggressively retry on degraded pool exhaustion responses
6. Conda-systemd contract gate
- `deploy/mes-dashboard.service` and `deploy/mes-dashboard-watchdog.service` both run in the same conda runtime contract
- `WATCHDOG_RESTART_FLAG`, `WATCHDOG_PID_FILE`, `WATCHDOG_STATE_FILE` paths are consistent across app/admin/watchdog
- single-port bind (`GUNICORN_BIND`) remains stable during restart workflow
7. Regression gate
- focused unit/integration test subset passes (see validation evidence)
8. Documentation alignment gate
- `README.md` (and project-required mirror docs such as `README.mdj`) reflect current runtime architecture contract
- resilience diagnostics fields (thresholds/churn/recommendation) are documented for operators
- frontend shared-core governance updates are reflected in architecture notes
## Rollout Procedure
1. Prepare environment
- Activate conda env (`mes-dashboard`)
- install Python deps: `pip install -r requirements.txt`
- install frontend deps: `npm --prefix frontend install`
2. Build frontend artifacts
- `npm --prefix frontend run build`
3. Run migration gate tests
- execute focused pytest set covering templates/cache/contracts/health
4. Deploy with single-port mode
- start app with root `scripts/start_server.sh`
- verify portal and module pages render on same origin/port
5. Conda + systemd rehearsal (recommended before production cutover)
- `sudo cp deploy/mes-dashboard.service /etc/systemd/system/`
- `sudo cp deploy/mes-dashboard-watchdog.service /etc/systemd/system/`
- `sudo mkdir -p /etc/mes-dashboard && sudo cp .env /etc/mes-dashboard/mes-dashboard.env`
- `sudo systemctl daemon-reload`
- `sudo systemctl enable --now mes-dashboard mes-dashboard-watchdog`
- call `/admin/api/worker/status` and verify runtime contract paths exist
6. Post-deploy checks
- call `/health` and `/health/deep`
- confirm route cache mode, degraded flags, and pool/runtime diagnostics align with environment (Redis on/off)
- trigger one controlled worker restart from admin API and verify single-port continuity
- verify README architecture section matches deployed runtime contract
## Rollback Procedure
1. Trigger rollback criteria
- any critical gate failure after deployment (page unusable, export mismatch, health degradation beyond acceptable limits)
2. Operational rollback steps
- stop service: `scripts/start_server.sh stop`
- restore previously known-good build artifacts (or prior release package)
- restart service: `scripts/start_server.sh start`
- if using systemd: `sudo systemctl restart mes-dashboard mes-dashboard-watchdog`
3. Validation after rollback
- verify `/health` status is at least expected baseline
- re-run focused smoke tests for portal + key pages
- confirm CSV export downloads and headers
- verify degraded reason is cleared or matches expected dependency outage only
## Rollback Rehearsal Checklist
1. Simulate failure condition (e.g. invalid dist artifact deployment)
2. Execute stop/restore/start sequence
3. Verify health and page smoke checks
4. Capture timings and any manual intervention points
5. Update this runbook if any step was unclear or missing
## Alert Thresholds (Operational Contract)
Use these initial thresholds for alerting/escalation:
1. Sustained degraded state
- `degraded_reason` non-empty for >= 5 minutes
2. Worker restart churn
- >= 3 watchdog-triggered restarts within 10 minutes
3. Pool saturation pressure
- `database_pool.state.saturation >= 0.90` for >= 3 consecutive health probes
4. Frontend/API retry pressure
- significant increase of client retries for `DB_POOL_EXHAUSTED` or `CIRCUIT_BREAKER_OPEN` responses over baseline

View File

@@ -0,0 +1,60 @@
# Migration Validation Evidence
Date: 2026-02-07
## Build
Command:
- `npm --prefix frontend run build`
Result:
- PASS
- Generated page bundles:
- `portal.js`
- `resource-status.js`
- `resource-history.js`
- `job-query.js`
- `excel-query.js`
- `tables.js`
## Root Startup Smoke
Command:
- `PYTHONPATH=src python -c \"from mes_dashboard.app import create_app; app=create_app('testing'); print('routes', len(list(app.url_map.iter_rules())))\"`
Result:
- PASS
- `routes 71`
- Redis/Oracle warnings observed in this local environment; app factory and route registration still completed.
## Focused Test Gate (root project)
Command:
- `python -m pytest -q tests/test_app_factory.py tests/test_template_integration.py tests/test_cache.py tests/test_health_routes.py tests/test_field_contracts.py tests/test_frontend_compute_parity.py tests/test_job_query_service.py tests/test_resource_history_service.py`
Result:
- PASS
- `107 passed`
## Extended Regression Spot-check
Command:
- `python -m pytest -q tests/test_job_query_routes.py tests/test_resource_history_routes.py tests/test_cache_integration.py`
Result:
- PARTIAL
- `45 passed, 2 failed`
- Failed tests:
- `tests/test_cache_integration.py::TestWipApiWithCache::test_wip_matrix_uses_cache`
- `tests/test_cache_integration.py::TestWipApiWithCache::test_packages_uses_cache`
Failure profile:
- cache-fallback path hit Oracle in local environment and returned ORA connectivity/thick-mode errors.
- categorized as environment-dependent (see `docs/environment_gaps_and_mitigation.md`).
## Health/Telemetry Coverage
Validated by tests:
- `/health` includes `route_cache` telemetry and degraded warnings
- `/health/deep` includes route-cache telemetry block
- cache telemetry includes L1/L2 mode, hit/miss counters, degraded state

View File

@@ -0,0 +1,44 @@
# Page Architecture Map
## Portal Navigation Model
Portal (`/`) uses drawer-based navigation and keeps existing operational flow:
- 報表類
- `/wip-overview`
- `/resource`
- `/resource-history`
- 查詢類
- `/tables`
- `/excel-query`
- `/job-query`
- 開發工具
- `/admin/pages`
- `/admin/performance`
## Independent Pages
These pages are independent views (iframe tabs in portal) and can be loaded directly:
- `/wip-overview`
- `/resource`
- `/resource-history`
- `/tables`
- `/excel-query`
- `/job-query`
## Drill-down Pages
These pages are drill-down/detail pages, linked from parent views:
- `/wip-detail` (from WIP flows)
- `/hold-detail` (from hold-related flows)
## Vite Entry Mapping
- `portal` -> `frontend/src/portal/main.js`
- `resource-status` -> `frontend/src/resource-status/main.js`
- `resource-history` -> `frontend/src/resource-history/main.js`
- `job-query` -> `frontend/src/job-query/main.js`
- `excel-query` -> `frontend/src/excel-query/main.js`
- `tables` -> `frontend/src/tables/main.js`
All pages keep inline fallback scripts in templates when module assets are unavailable.

View File

@@ -0,0 +1,56 @@
# Root Cutover Inventory
## Scope
- Workspace root: `/Users/egg/Projects/DashBoard_vite`
- Legacy subtree `DashBoard/`: removed on 2026-02-08
- Objective: ensure runtime/test/deploy flows depend only on root architecture.
## 1. Runtime / Test / Deploy Path Audit
### Legacy path references
- Historical mentions may exist in archived OpenSpec artifacts for traceability.
- Active runtime/test/deploy code MUST NOT reference removed legacy subtree paths.
### Result
- Legacy code directory is removed.
- No active runtime code in `src/`, `scripts/`, or `tests/` requires legacy subtree paths.
- Remaining mentions are documentation-only migration history.
## 2. Root-only Execution Hardening
### Updated
- `scripts/start_server.sh`
- Frontend build readiness now checks all required root dist entries:
- `portal.js`
- `resource-status.js`
- `resource-history.js`
- `job-query.js`
- `excel-query.js`
- `tables.js`
### Verified behavior target
- Startup/build logic remains anchored to root paths:
- `frontend/`
- `src/mes_dashboard/static/dist/`
- `src/`
## 3. Root-only Smoke Checks (single-port)
### Build smoke
- `npm --prefix frontend run build`
### App import smoke
- `PYTHONPATH=src python -c "from mes_dashboard.app import create_app; app=create_app('testing'); print(app.url_map)"`
- Verified route initialization count (`routes 71`) in root-only execution context.
### HTTP smoke (Flask test client)
- Verify page renders and module asset tags resolve/fallback:
- `/`
- `/resource`
- `/resource-history`
- `/job-query`
- `/excel-query`
- `/tables`
### Test smoke
- `python -m pytest -q tests/test_app_factory.py tests/test_template_integration.py tests/test_cache.py`

View File

@@ -0,0 +1,37 @@
# Root Refactor Validation Notes
Date: 2026-02-07
## Focused Validation (Root Project)
- Frontend build:
- `npm --prefix frontend run build`
- Python focused tests:
- `python -m pytest -q tests/test_app_factory.py tests/test_cache.py tests/test_job_query_service.py` ✅ (46 passed)
- Root portal asset integration check:
- GET `/` from Flask test client includes `/static/dist/portal.js` and `/static/dist/portal.css`
## Environment-Dependent Gaps
The following are known non-functional gaps in local validation due to missing external runtime dependencies:
1. Oracle-dependent integration tests
- Some routes/services start background workers that attempt Oracle queries at app init.
- In local environment without valid Oracle connectivity, logs contain `DPY-3001` and related query failures.
2. Redis-dependent runtime checks
- Redis is not reachable in local environment (`localhost:6379` connection refused).
- Cache fallback paths continue to run, but Redis health-dependent behavior is not fully exercised.
3. Dev-page permission tests
- Certain template tests expecting `/tables` or `/excel-query` content may fail when page status is `dev` for non-admin sessions.
## Recommended Next Validation Stage
- Run full test suite in an environment with:
- reachable Oracle test endpoint
- reachable Redis endpoint
- page status fixtures aligned with expected test roles
- Add CI matrix split:
- unit/fallback tests (no Oracle/Redis required)
- integration tests (Oracle/Redis required)

46
environment.yml Normal file
View File

@@ -0,0 +1,46 @@
# Conda environment for MES Dashboard
# Usage: conda env create -f environment.yml
# conda activate mes-dashboard
#
# Note: Most packages use minimum version pins (>=) to allow automatic security updates.
# For reproducible builds, generate a lock file: pip freeze > requirements.lock
name: mes-dashboard
channels:
- conda-forge
- defaults
dependencies:
# Python version - pinned for consistency across deployments
- python=3.11
# Frontend build toolchain (Vite)
- nodejs>=22
# Use pip for Python packages (better compatibility with pypi packages)
- pip
- pip:
# Core Framework
- flask>=3.0.0
# Database
- oracledb>=2.0.0
- sqlalchemy>=2.0.0
# Data Processing
- pandas>=2.0.0
- openpyxl>=3.0.0
# Cache (Redis)
- redis>=5.0.0
- hiredis>=2.0.0 # C parser for better performance
# HTTP Client
- requests>=2.28.0
# Configuration
- python-dotenv>=1.0.0
# WSGI Server (Production)
- gunicorn>=21.2.0
# System Monitoring
- psutil>=5.9.0

2
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
.DS_Store

1105
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

14
frontend/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "mes-dashboard-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"test": "node --test tests/*.test.js"
},
"devDependencies": {
"vite": "^6.3.0"
}
}

82
frontend/src/core/api.js Normal file
View File

@@ -0,0 +1,82 @@
const DEFAULT_TIMEOUT = 30000;
function buildApiError(response, payload) {
const message =
payload?.error?.message ||
(typeof payload?.error === 'string' ? payload.error : null) ||
payload?.message ||
`HTTP ${response.status}`;
const error = new Error(message);
error.status = response.status;
error.payload = payload;
error.errorCode = payload?.error?.code || payload?.code || null;
error.retryAfterSeconds = Number(
payload?.meta?.retry_after_seconds || response.headers.get('Retry-After') || 0
) || null;
return error;
}
async function fetchJson(url, options = {}) {
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
const data = await response.json();
if (!response.ok) {
throw buildApiError(response, data);
}
return data;
} finally {
clearTimeout(timer);
}
}
export async function apiGet(url, options = {}) {
if (window.MesApi?.get) {
return window.MesApi.get(url, options);
}
return fetchJson(url, { ...options, method: 'GET' });
}
export async function apiPost(url, payload, options = {}) {
if (window.MesApi?.post) {
return window.MesApi.post(url, payload, options);
}
return fetchJson(url, {
...options,
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
body: JSON.stringify(payload)
});
}
export async function apiUpload(url, formData, options = {}) {
return fetchJson(url, {
...options,
method: 'POST',
body: formData
});
}
export function ensureMesApiAvailable() {
if (window.MesApi) {
return window.MesApi;
}
const bridge = {
get: (url, options) => apiGet(url, options),
post: (url, payload, options) => apiPost(url, payload, options)
};
window.MesApi = bridge;
return bridge;
}

View File

@@ -0,0 +1,69 @@
const DEFAULT_LIMIT = 20;
const FIELD_MAP = Object.freeze({
workorder: 'workorder',
lotid: 'lotid',
package: 'package',
type: 'pj_type'
});
export function debounce(fn, wait = 300) {
let timer = null;
return (...args) => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => fn(...args), wait);
};
}
export function buildWipAutocompleteParams(searchType, query, filters = {}, limit = DEFAULT_LIMIT) {
const keyword = (query || '').trim();
if (keyword.length < 2) {
return null;
}
const params = {
field: FIELD_MAP[searchType] || searchType,
q: keyword,
limit
};
const filterKeys = ['workorder', 'lotid', 'package', 'type'];
filterKeys.forEach((key) => {
const value = (filters[key] || '').trim();
if (key !== searchType && value) {
params[key] = value;
}
});
return params;
}
export async function fetchWipAutocompleteItems({
searchType,
query,
filters,
request,
limit = DEFAULT_LIMIT,
}) {
const params = buildWipAutocompleteParams(searchType, query, filters, limit);
if (!params) {
return [];
}
try {
const result = await request('/api/wip/meta/search', {
params,
silent: true,
retries: 0,
});
if (result?.success) {
return result?.data?.items || [];
}
return [];
} catch {
return [];
}
}
export { FIELD_MAP as WIP_AUTOCOMPLETE_FIELD_MAP };

View File

@@ -0,0 +1,59 @@
function round1(value) {
const scaled = Number(value) * 10;
const sign = Math.sign(scaled) || 1;
const abs = Math.abs(scaled);
const floor = Math.floor(abs);
const diff = abs - floor;
const epsilon = 1e-9;
let rounded;
if (diff > 0.5 + epsilon) {
rounded = floor + 1;
} else if (diff < 0.5 - epsilon) {
rounded = floor;
} else {
// Match Python round(..., 1): banker's rounding (half to even).
rounded = floor % 2 === 0 ? floor : floor + 1;
}
return (sign * rounded) / 10;
}
export function calcOuPct(prd, sby, udt, sdt, egt) {
const denominator = Number(prd) + Number(sby) + Number(udt) + Number(sdt) + Number(egt);
if (denominator <= 0) return 0;
return round1((Number(prd) / denominator) * 100);
}
export function calcAvailabilityPct(prd, sby, udt, sdt, egt, nst) {
const numerator = Number(prd) + Number(sby) + Number(egt);
const denominator = numerator + Number(sdt) + Number(udt) + Number(nst);
if (denominator <= 0) return 0;
return round1((numerator / denominator) * 100);
}
export function calcStatusPct(value, total) {
if (Number(total) <= 0) return 0;
return round1((Number(value) / Number(total)) * 100);
}
export function buildResourceKpiFromHours(hours = {}) {
const prd = Number(hours.prd_hours || 0);
const sby = Number(hours.sby_hours || 0);
const udt = Number(hours.udt_hours || 0);
const sdt = Number(hours.sdt_hours || 0);
const egt = Number(hours.egt_hours || 0);
const nst = Number(hours.nst_hours || 0);
const total = prd + sby + udt + sdt + egt + nst;
return {
ou_pct: calcOuPct(prd, sby, udt, sdt, egt),
availability_pct: calcAvailabilityPct(prd, sby, udt, sdt, egt, nst),
prd_pct: calcStatusPct(prd, total),
sby_pct: calcStatusPct(sby, total),
udt_pct: calcStatusPct(udt, total),
sdt_pct: calcStatusPct(sdt, total),
egt_pct: calcStatusPct(egt, total),
nst_pct: calcStatusPct(nst, total)
};
}

View File

@@ -0,0 +1,25 @@
import rawContracts from '../../../shared/field_contracts.json';
const contracts = rawContracts || {};
export function getPageContract(pageKey, sectionKey) {
const page = contracts[pageKey] || {};
const section = page[sectionKey] || [];
return Array.isArray(section) ? section : [];
}
export function getFieldContractByApiKey(pageKey, sectionKey, apiKey) {
return getPageContract(pageKey, sectionKey).find((field) => field.api_key === apiKey) || null;
}
export function getUiHeaders(pageKey, sectionKey) {
return getPageContract(pageKey, sectionKey).map((field) => field.ui_label || field.api_key);
}
export function getExportHeaders(pageKey) {
return getPageContract(pageKey, 'export').map((field) => field.export_header || field.ui_label || field.api_key);
}
export function getContractRegistry() {
return contracts;
}

View File

@@ -0,0 +1,44 @@
export function groupBy(items, keySelector) {
return items.reduce((acc, item) => {
const key = keySelector(item);
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(item);
return acc;
}, {});
}
export function sortBy(items, keySelector, direction = 'asc') {
const sign = direction === 'desc' ? -1 : 1;
return [...items].sort((left, right) => {
const a = keySelector(left);
const b = keySelector(right);
if (a === b) return 0;
return a > b ? sign : -sign;
});
}
export function toggleTreeState(state, key) {
state[key] = !state[key];
return state[key];
}
export function setTreeStateBulk(state, keys, expanded) {
keys.forEach((key) => {
state[key] = expanded;
});
}
export function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function safeText(value, fallback = '') {
return value === null || value === undefined ? fallback : String(value);
}

View File

@@ -0,0 +1,624 @@
import { ensureMesApiAvailable } from '../core/api.js';
import { getPageContract } from '../core/field-contracts.js';
import { buildResourceKpiFromHours } from '../core/compute.js';
import { groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText } from '../core/table-tree.js';
ensureMesApiAvailable();
window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText };
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
window.__FIELD_CONTRACTS__['excel_query:result_table'] = getPageContract('excel_query', 'result_table');
// State
let excelColumns = [];
let excelColumnTypes = {}; // { columnName: { detected_type, type_label } }
let searchValues = [];
let tableColumns = []; // Array of column names (for backward compat)
let tableColumnsMeta = []; // Array of { name, data_type, is_date, is_number }
let tableMetadata = null; // Full table metadata including time_field, row_count
let queryResult = null;
// Step 1: Upload Excel
async function uploadExcel() {
const fileInput = document.getElementById('excelFile');
const file = fileInput.files[0];
if (!file) {
alert('請選擇檔案');
return;
}
const formData = new FormData();
formData.append('file', file);
document.getElementById('uploadInfo').innerHTML = '<div class="loading"><div class="loading-spinner"></div><br>上傳中...</div>';
try {
// Note: File upload uses native fetch since MesApi doesn't support FormData
const response = await fetch('/api/excel-query/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.error) {
document.getElementById('uploadInfo').innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
return;
}
excelColumns = data.columns;
document.getElementById('uploadInfo').innerHTML = `
<div class="info-box">
檔案上傳成功!共 ${data.total_rows} 行,${data.columns.length}
</div>
`;
renderPreviewTable(data.columns, data.preview);
const select = document.getElementById('excelColumn');
select.innerHTML = '<option value="">-- 請選擇 --</option>';
excelColumns.forEach(col => {
select.innerHTML += `<option value="${escapeHtml(col)}">${escapeHtml(col)}</option>`;
});
document.getElementById('step2').classList.remove('disabled');
loadTables();
} catch (error) {
document.getElementById('uploadInfo').innerHTML = `<div class="error">上傳失敗: ${escapeHtml(error.message)}</div>`;
}
}
function renderPreviewTable(columns, data) {
if (!data || data.length === 0) return;
let html = '<table class="preview-table"><thead><tr>';
columns.forEach(col => {
html += `<th>${escapeHtml(col)}</th>`;
});
html += '</tr></thead><tbody>';
data.forEach(row => {
html += '<tr>';
columns.forEach(col => {
const val = row[col] !== null && row[col] !== undefined ? row[col] : '';
const textVal = safeText(val);
const escaped = escapeHtml(textVal);
html += `<td title="${escaped}">${escaped}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
document.getElementById('previewTable').innerHTML = html;
}
// Step 2: Load column values
async function loadColumnValues() {
const column = document.getElementById('excelColumn').value;
if (!column) {
searchValues = [];
document.getElementById('columnInfo').innerHTML = '';
return;
}
document.getElementById('columnInfo').innerHTML = '<div class="loading"><div class="loading-spinner"></div><br>讀取中...</div>';
try {
// Get column values
const data = await MesApi.post('/api/excel-query/column-values', { column_name: column });
if (data.error) {
document.getElementById('columnInfo').innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
return;
}
searchValues = data.values;
// Get column type detection
try {
const typeData = await MesApi.post('/api/excel-query/column-type', { column_name: column });
if (!typeData.error) {
excelColumnTypes[column] = typeData;
}
} catch (e) {
console.warn('Could not detect column type:', e);
}
// Build info display
const typeInfo = excelColumnTypes[column];
const typeBadge = typeInfo ? `<span class="type-badge ${typeInfo.detected_type}">${typeInfo.type_label}</span>` : '';
const warningClass = data.count > 1000 ? ' warning' : '';
document.getElementById('columnInfo').innerHTML = `
<div class="info-box${warningClass}">
${data.count} 個不重複值 ${typeBadge}
${data.count > 1000 ? '(將分批查詢,每批 1000 筆)' : ''}
</div>
`;
document.getElementById('step3').classList.remove('disabled');
} catch (error) {
document.getElementById('columnInfo').innerHTML = `<div class="error">讀取失敗: ${escapeHtml(error.message)}</div>`;
}
}
// Load available tables
async function loadTables() {
try {
const data = await MesApi.get('/api/excel-query/tables', { silent: true });
const select = document.getElementById('targetTable');
select.innerHTML = '<option value="">-- 請選擇 --</option>';
data.tables.forEach(table => {
select.innerHTML += `<option value="${escapeHtml(table.name)}">${escapeHtml(table.display_name)} (${escapeHtml(table.name)})</option>`;
});
} catch (error) {
console.error('Failed to load tables:', error);
}
}
// Step 3: Load table columns (using new table-metadata endpoint)
async function loadTableColumns() {
const tableName = document.getElementById('targetTable').value;
if (!tableName) {
tableColumns = [];
tableColumnsMeta = [];
tableMetadata = null;
document.getElementById('tableInfo').innerHTML = '';
document.getElementById('dateRangeSection').style.display = 'none';
document.getElementById('performanceWarning').style.display = 'none';
return;
}
document.getElementById('tableInfo').innerHTML = '<div class="loading"><div class="loading-spinner"></div><br>讀取欄位...</div>';
try {
const data = await MesApi.post('/api/excel-query/table-metadata', { table_name: tableName });
if (data.error) {
document.getElementById('tableInfo').innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
return;
}
tableColumnsMeta = data.columns || [];
tableColumns = tableColumnsMeta.map(c => c.name);
tableMetadata = data;
// Show table info
let infoHtml = `${tableColumns.length} 個欄位`;
if (data.row_count) {
infoHtml += ` | 約 ${data.row_count.toLocaleString()}`;
}
if (data.time_field) {
infoHtml += ` | 時間欄位: ${escapeHtml(data.time_field)}`;
}
document.getElementById('tableInfo').innerHTML = `<div class="info-box">${infoHtml}</div>`;
// Populate search column dropdown with type badges
const searchSelect = document.getElementById('searchColumn');
searchSelect.innerHTML = '<option value="">-- 請選擇 --</option>';
tableColumnsMeta.forEach(col => {
const typeBadge = getTypeBadgeHtml(col.data_type);
searchSelect.innerHTML += `<option value="${escapeHtml(col.name)}" data-type="${escapeHtml(col.data_type || '')}">${escapeHtml(col.name)} ${typeBadge}</option>`;
});
// Populate return columns with type badges
const container = document.getElementById('returnColumns');
container.innerHTML = '';
tableColumnsMeta.forEach(col => {
const typeBadge = getTypeBadgeHtml(col.data_type);
container.innerHTML += `
<label class="checkbox-item">
<input type="checkbox" value="${escapeHtml(col.name)}" checked>
${escapeHtml(col.name)} ${typeBadge}
</label>
`;
});
// Setup date range section
setupDateRangeSection(data);
// Show performance warning if applicable
if (data.performance_warning) {
document.getElementById('performanceWarning').textContent = data.performance_warning;
document.getElementById('performanceWarning').style.display = 'block';
} else {
document.getElementById('performanceWarning').style.display = 'none';
}
document.getElementById('step4').classList.remove('disabled');
document.getElementById('step5').classList.remove('disabled');
} catch (error) {
document.getElementById('tableInfo').innerHTML = `<div class="error">讀取失敗: ${escapeHtml(error.message)}</div>`;
}
}
// Helper: Get type badge HTML
function getTypeBadgeHtml(dataType) {
if (!dataType || dataType === 'UNKNOWN') return '';
const typeMap = {
'VARCHAR2': { class: 'text', label: '文字' },
'CHAR': { class: 'text', label: '文字' },
'NVARCHAR2': { class: 'text', label: '文字' },
'CLOB': { class: 'text', label: '文字' },
'NUMBER': { class: 'number', label: '數值' },
'FLOAT': { class: 'number', label: '數值' },
'INTEGER': { class: 'number', label: '數值' },
'DATE': { class: 'date', label: '日期' },
'TIMESTAMP': { class: 'datetime', label: '日期時間' },
};
// Find matching type
for (const [key, val] of Object.entries(typeMap)) {
if (dataType.toUpperCase().includes(key)) {
return `<span class="type-badge ${val.class}">${val.label}</span>`;
}
}
return `<span class="type-badge unknown">${escapeHtml(dataType)}</span>`;
}
// Setup date range section based on table metadata
function setupDateRangeSection(metadata) {
const section = document.getElementById('dateRangeSection');
const dateColumnSelect = document.getElementById('dateColumn');
// Find date/timestamp columns
const dateColumns = tableColumnsMeta.filter(c => c.is_date);
if (dateColumns.length === 0 && !metadata.time_field) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
dateColumnSelect.innerHTML = '<option value="">-- 不限時間 --</option>';
// Add configured time_field first if available
if (metadata.time_field) {
dateColumnSelect.innerHTML += `<option value="${escapeHtml(metadata.time_field)}" selected>${escapeHtml(metadata.time_field)} (預設)</option>`;
}
// Add other date columns
dateColumns.forEach(col => {
if (col.name !== metadata.time_field) {
dateColumnSelect.innerHTML += `<option value="${escapeHtml(col.name)}">${escapeHtml(col.name)}</option>`;
}
});
}
// Set default date range (last 90 days)
function setDefaultDateRange() {
const today = new Date();
const past = new Date();
past.setDate(today.getDate() - 90);
document.getElementById('dateFrom').value = past.toISOString().split('T')[0];
document.getElementById('dateTo').value = today.toISOString().split('T')[0];
}
// Toggle advanced panel
function toggleAdvancedPanel() {
const panel = document.getElementById('advancedPanel');
panel.classList.toggle('collapsed');
}
// Handle query type change
function onQueryTypeChange() {
const queryType = document.getElementById('queryType').value;
const warningDiv = document.getElementById('performanceWarning');
// Show warning for LIKE contains on large tables
if (queryType === 'like_contains' && tableMetadata && tableMetadata.row_count > 10000000) {
warningDiv.textContent = '此資料表超過 1000 萬筆,包含查詢可能較慢,建議配合日期範圍縮小查詢範圍';
warningDiv.style.display = 'block';
} else if (tableMetadata && tableMetadata.performance_warning && queryType === 'like_contains') {
warningDiv.textContent = tableMetadata.performance_warning;
warningDiv.style.display = 'block';
} else {
warningDiv.style.display = 'none';
}
}
// Check for type mismatch between Excel column and Oracle column
function checkTypeMismatch() {
const warningDiv = document.getElementById('typeMismatchWarning');
const searchCol = document.getElementById('searchColumn').value;
const excelCol = document.getElementById('excelColumn').value;
if (!searchCol || !excelCol) {
warningDiv.innerHTML = '';
return;
}
// Get types
const oracleMeta = tableColumnsMeta.find(c => c.name === searchCol);
const excelType = excelColumnTypes[excelCol];
if (!oracleMeta || !excelType) {
warningDiv.innerHTML = '';
return;
}
// Check for potential mismatches
let warning = '';
if (oracleMeta.is_number && excelType.detected_type === 'text') {
warning = '欄位類型可能不相符Excel 欄位為文字Oracle 欄位為數值';
} else if (oracleMeta.is_date && excelType.detected_type !== 'date' && excelType.detected_type !== 'datetime') {
warning = '欄位類型可能不相符Oracle 欄位為日期類型';
}
if (warning) {
warningDiv.innerHTML = `<div class="type-mismatch-warning">${warning}</div>`;
} else {
warningDiv.innerHTML = '';
}
}
function selectAllColumns() {
document.querySelectorAll('#returnColumns input[type="checkbox"]').forEach(cb => cb.checked = true);
}
function deselectAllColumns() {
document.querySelectorAll('#returnColumns input[type="checkbox"]').forEach(cb => cb.checked = false);
}
function getSelectedReturnColumns() {
const checkboxes = document.querySelectorAll('#returnColumns input[type="checkbox"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
function getQueryParams() {
const params = {
table_name: document.getElementById('targetTable').value,
search_column: document.getElementById('searchColumn').value,
return_columns: getSelectedReturnColumns(),
search_values: searchValues,
query_type: document.getElementById('queryType').value
};
// Add date range if specified
const dateColumn = document.getElementById('dateColumn').value;
const dateFrom = document.getElementById('dateFrom').value;
const dateTo = document.getElementById('dateTo').value;
if (dateColumn) {
params.date_column = dateColumn;
if (dateFrom) params.date_from = dateFrom;
if (dateTo) params.date_to = dateTo;
}
return params;
}
function validateQuery() {
const params = getQueryParams();
if (!params.table_name) {
alert('請選擇資料表');
return false;
}
if (!params.search_column) {
alert('請選擇查詢欄位');
return false;
}
if (params.return_columns.length === 0) {
alert('請至少選擇一個回傳欄位');
return false;
}
if (params.search_values.length === 0) {
alert('無查詢值,請先選擇 Excel 欄位');
return false;
}
// Validate LIKE keyword limit
if (params.query_type.startsWith('like_') && params.search_values.length > 100) {
alert('LIKE 查詢最多支援 100 個關鍵字,目前有 ' + params.search_values.length + ' 個');
return false;
}
// Validate date range
if (params.date_from && params.date_to) {
const from = new Date(params.date_from);
const to = new Date(params.date_to);
if (from > to) {
alert('起始日期不可晚於結束日期');
document.getElementById('dateRangeError').textContent = '起始日期不可晚於結束日期';
document.getElementById('dateRangeError').style.display = 'block';
return false;
}
const daysDiff = (to - from) / (1000 * 60 * 60 * 24);
if (daysDiff > 365) {
alert('日期範圍不可超過 365 天');
document.getElementById('dateRangeError').textContent = '日期範圍不可超過 365 天';
document.getElementById('dateRangeError').style.display = 'block';
return false;
}
document.getElementById('dateRangeError').style.display = 'none';
}
return true;
}
// Step 5: Execute query
async function executeQuery() {
if (!validateQuery()) return;
const params = getQueryParams();
const isAdvanced = params.query_type !== 'in' || params.date_column;
const batchCount = params.query_type === 'in' ? Math.ceil(params.search_values.length / 1000) : 1;
// Build loading message
let loadingMsg = `查詢中... (${params.search_values.length}`;
if (params.query_type !== 'in') {
const typeLabels = {
'like_contains': '包含查詢',
'like_prefix': '前綴查詢',
'like_suffix': '後綴查詢'
};
loadingMsg += `${typeLabels[params.query_type] || params.query_type}`;
} else if (batchCount > 1) {
loadingMsg += `${batchCount} 批次`;
}
if (params.date_from || params.date_to) {
loadingMsg += `,日期篩選`;
}
loadingMsg += ')';
document.getElementById('executeInfo').innerHTML = `
<div class="loading">
<div class="loading-spinner"></div><br>
${loadingMsg}
</div>
`;
document.getElementById('resultSection').classList.remove('active');
try {
// Use advanced endpoint if using advanced features
const endpoint = isAdvanced ? '/api/excel-query/execute-advanced' : '/api/excel-query/execute';
const data = await MesApi.post(endpoint, params);
if (data.error) {
document.getElementById('executeInfo').innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
return;
}
queryResult = data;
// Build result message
let resultMsg = `查詢完成!搜尋 ${data.search_count} 筆,找到 ${data.row_count} 筆結果`;
if (data.query_type && data.query_type !== 'in') {
resultMsg += ` (${data.query_type})`;
}
document.getElementById('executeInfo').innerHTML = `
<div class="info-box">${resultMsg}</div>
`;
renderResult(data);
} catch (error) {
document.getElementById('executeInfo').innerHTML = `<div class="error">查詢失敗: ${escapeHtml(error.message)}</div>`;
}
}
function renderResult(data) {
const section = document.getElementById('resultSection');
const statsDiv = document.getElementById('resultStats');
const tableDiv = document.getElementById('resultTable');
statsDiv.innerHTML = `
<span>搜尋值: ${data.search_count}</span>
<span>結果: ${data.row_count} 筆</span>
${data.batch_count > 1 ? `<span>批次: ${data.batch_count}</span>` : ''}
`;
if (data.data.length === 0) {
tableDiv.innerHTML = '<div style="padding: 40px; text-align: center; color: #999;">查無資料</div>';
} else {
let html = '<table><thead><tr>';
data.columns.forEach(col => {
html += `<th>${escapeHtml(col)}</th>`;
});
html += '</tr></thead><tbody>';
const previewData = data.data.slice(0, 1000);
previewData.forEach(row => {
html += '<tr>';
data.columns.forEach(col => {
if (row[col] === null || row[col] === undefined) {
html += '<td><i style="color:#999">NULL</i></td>';
} else {
html += `<td>${escapeHtml(safeText(row[col]))}</td>`;
}
});
html += '</tr>';
});
html += '</tbody></table>';
if (data.data.length > 1000) {
html += `<div style="padding: 15px; text-align: center; color: #666; background: #f8f9fa;">
顯示前 1000 筆,完整資料請匯出 CSV
</div>`;
}
tableDiv.innerHTML = html;
}
section.classList.add('active');
section.scrollIntoView({ behavior: 'smooth' });
}
// Export CSV
async function exportCSV() {
if (!validateQuery()) return;
const params = getQueryParams();
const batchCount = Math.ceil(params.search_values.length / 1000);
document.getElementById('executeInfo').innerHTML = `
<div class="loading">
<div class="loading-spinner"></div><br>
匯出中... (${params.search_values.length} 筆,${batchCount} 批次)
</div>
`;
try {
// Note: CSV export uses native fetch for blob response
const response = await fetch('/api/excel-query/export-csv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
if (!response.ok) {
const data = await response.json();
document.getElementById('executeInfo').innerHTML = `<div class="error">${escapeHtml(data.error || '匯出失敗')}</div>`;
return;
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'query_result.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
document.getElementById('executeInfo').innerHTML = `
<div class="info-box">CSV 匯出完成!</div>
`;
} catch (error) {
document.getElementById('executeInfo').innerHTML = `<div class="error">匯出失敗: ${escapeHtml(error.message)}</div>`;
}
}
Object.assign(window, {
uploadExcel,
renderPreviewTable,
loadColumnValues,
loadTables,
loadTableColumns,
getTypeBadgeHtml,
setupDateRangeSection,
setDefaultDateRange,
toggleAdvancedPanel,
onQueryTypeChange,
checkTypeMismatch,
selectAllColumns,
deselectAllColumns,
getSelectedReturnColumns,
getQueryParams,
validateQuery,
executeQuery,
renderResult,
exportCSV,
});

View File

@@ -0,0 +1,336 @@
import { ensureMesApiAvailable } from '../core/api.js';
import { escapeHtml, safeText } from '../core/table-tree.js';
ensureMesApiAvailable();
(function initHoldDetailPage() {
// ============================================================
// State
// ============================================================
const state = {
reason: new URLSearchParams(window.location.search).get('reason') || '',
summary: null,
distribution: null,
lots: null,
page: 1,
perPage: 50,
filters: {
workcenter: null,
package: null,
ageRange: null
}
};
// ============================================================
// Utility
// ============================================================
function formatNumber(num) {
if (num === null || num === undefined || num === '-') return '-';
return num.toLocaleString('zh-TW');
}
function jsSingleQuote(value) {
return safeText(value, '')
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'");
}
// ============================================================
// API Functions
// ============================================================
const API_TIMEOUT = 60000;
async function fetchSummary() {
const result = await MesApi.get('/api/wip/hold-detail/summary', {
params: { reason: state.reason },
timeout: API_TIMEOUT
});
if (result.success) return result.data;
throw new Error(result.error);
}
async function fetchDistribution() {
const result = await MesApi.get('/api/wip/hold-detail/distribution', {
params: { reason: state.reason },
timeout: API_TIMEOUT
});
if (result.success) return result.data;
throw new Error(result.error);
}
async function fetchLots() {
const params = {
reason: state.reason,
page: state.page,
per_page: state.perPage
};
if (state.filters.workcenter) params.workcenter = state.filters.workcenter;
if (state.filters.package) params.package = state.filters.package;
if (state.filters.ageRange) params.age_range = state.filters.ageRange;
const result = await MesApi.get('/api/wip/hold-detail/lots', {
params,
timeout: API_TIMEOUT
});
if (result.success) return result.data;
throw new Error(result.error);
}
// ============================================================
// Render Functions
// ============================================================
function renderSummary(data) {
document.getElementById('totalLots').textContent = formatNumber(data.totalLots);
document.getElementById('totalQty').textContent = formatNumber(data.totalQty);
document.getElementById('avgAge').textContent = data.avgAge ? `${data.avgAge}` : '-';
document.getElementById('maxAge').textContent = data.maxAge ? `${data.maxAge}` : '-';
document.getElementById('workcenterCount').textContent = formatNumber(data.workcenterCount);
}
function renderDistribution(data) {
// Age distribution
const ageMap = {};
data.byAge.forEach(item => { ageMap[item.range] = item; });
const age01 = ageMap['0-1'] || { lots: 0, qty: 0, percentage: 0 };
const age13 = ageMap['1-3'] || { lots: 0, qty: 0, percentage: 0 };
const age37 = ageMap['3-7'] || { lots: 0, qty: 0, percentage: 0 };
const age7 = ageMap['7+'] || { lots: 0, qty: 0, percentage: 0 };
document.getElementById('age01Lots').textContent = formatNumber(age01.lots);
document.getElementById('age01Qty').textContent = formatNumber(age01.qty);
document.getElementById('age01Pct').textContent = `${age01.percentage}%`;
document.getElementById('age13Lots').textContent = formatNumber(age13.lots);
document.getElementById('age13Qty').textContent = formatNumber(age13.qty);
document.getElementById('age13Pct').textContent = `${age13.percentage}%`;
document.getElementById('age37Lots').textContent = formatNumber(age37.lots);
document.getElementById('age37Qty').textContent = formatNumber(age37.qty);
document.getElementById('age37Pct').textContent = `${age37.percentage}%`;
document.getElementById('age7Lots').textContent = formatNumber(age7.lots);
document.getElementById('age7Qty').textContent = formatNumber(age7.qty);
document.getElementById('age7Pct').textContent = `${age7.percentage}%`;
// Workcenter table
const wcBody = document.getElementById('workcenterBody');
if (data.byWorkcenter.length === 0) {
wcBody.innerHTML = '<tr><td colspan="4" class="placeholder">No data</td></tr>';
} else {
wcBody.innerHTML = data.byWorkcenter.map(item => `
<tr data-workcenter="${escapeHtml(safeText(item.name))}" onclick="toggleWorkcenterFilter('${jsSingleQuote(item.name)}')" class="${state.filters.workcenter === item.name ? 'active' : ''}">
<td>${escapeHtml(safeText(item.name))}</td>
<td>${escapeHtml(formatNumber(item.lots))}</td>
<td>${escapeHtml(formatNumber(item.qty))}</td>
<td>${escapeHtml(safeText(item.percentage, 0))}%</td>
</tr>
`).join('');
}
// Package table
const pkgBody = document.getElementById('packageBody');
if (data.byPackage.length === 0) {
pkgBody.innerHTML = '<tr><td colspan="4" class="placeholder">No data</td></tr>';
} else {
pkgBody.innerHTML = data.byPackage.map(item => `
<tr data-package="${escapeHtml(safeText(item.name))}" onclick="togglePackageFilter('${jsSingleQuote(item.name)}')" class="${state.filters.package === item.name ? 'active' : ''}">
<td>${escapeHtml(safeText(item.name))}</td>
<td>${escapeHtml(formatNumber(item.lots))}</td>
<td>${escapeHtml(formatNumber(item.qty))}</td>
<td>${escapeHtml(safeText(item.percentage, 0))}%</td>
</tr>
`).join('');
}
}
function renderLots(data) {
const tbody = document.getElementById('lotBody');
const lots = data.lots;
if (lots.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="placeholder">No data</td></tr>';
document.getElementById('tableInfo').textContent = 'No data';
document.getElementById('pagination').style.display = 'none';
return;
}
tbody.innerHTML = lots.map(lot => `
<tr>
<td>${escapeHtml(safeText(lot.lotId))}</td>
<td>${escapeHtml(safeText(lot.workorder))}</td>
<td>${escapeHtml(formatNumber(lot.qty))}</td>
<td>${escapeHtml(safeText(lot.package))}</td>
<td>${escapeHtml(safeText(lot.workcenter))}</td>
<td>${escapeHtml(safeText(lot.spec))}</td>
<td>${escapeHtml(safeText(lot.age))}天</td>
<td>${escapeHtml(safeText(lot.holdBy))}</td>
<td>${escapeHtml(safeText(lot.dept))}</td>
<td>${escapeHtml(safeText(lot.holdComment))}</td>
</tr>
`).join('');
// Update pagination
const pg = data.pagination;
const start = (pg.page - 1) * pg.perPage + 1;
const end = Math.min(pg.page * pg.perPage, pg.total);
document.getElementById('tableInfo').textContent = `顯示 ${start} - ${end} / ${formatNumber(pg.total)}`;
if (pg.totalPages > 1) {
document.getElementById('pagination').style.display = 'flex';
document.getElementById('pageInfo').textContent = `Page ${pg.page} / ${pg.totalPages}`;
document.getElementById('btnPrev').disabled = pg.page <= 1;
document.getElementById('btnNext').disabled = pg.page >= pg.totalPages;
} else {
document.getElementById('pagination').style.display = 'none';
}
}
function updateFilterIndicator() {
const indicator = document.getElementById('filterIndicator');
const text = document.getElementById('filterText');
const parts = [];
if (state.filters.workcenter) parts.push(`Workcenter=${state.filters.workcenter}`);
if (state.filters.package) parts.push(`Package=${state.filters.package}`);
if (state.filters.ageRange) parts.push(`Age=${state.filters.ageRange}`);
if (parts.length > 0) {
text.textContent = '篩選: ' + parts.join(', ');
indicator.style.display = 'flex';
} else {
indicator.style.display = 'none';
}
// Update active states
document.querySelectorAll('.age-card').forEach(card => {
card.classList.toggle('active', card.dataset.range === state.filters.ageRange);
});
document.querySelectorAll('#workcenterBody tr').forEach(row => {
row.classList.toggle('active', row.dataset.workcenter === state.filters.workcenter);
});
document.querySelectorAll('#packageBody tr').forEach(row => {
row.classList.toggle('active', row.dataset.package === state.filters.package);
});
}
// ============================================================
// Filter Functions
// ============================================================
function toggleAgeFilter(range) {
state.filters.ageRange = state.filters.ageRange === range ? null : range;
state.page = 1;
updateFilterIndicator();
loadLots();
}
function toggleWorkcenterFilter(wc) {
state.filters.workcenter = state.filters.workcenter === wc ? null : wc;
state.page = 1;
updateFilterIndicator();
loadLots();
}
function togglePackageFilter(pkg) {
state.filters.package = state.filters.package === pkg ? null : pkg;
state.page = 1;
updateFilterIndicator();
loadLots();
}
function clearFilters() {
state.filters = { workcenter: null, package: null, ageRange: null };
state.page = 1;
updateFilterIndicator();
loadLots();
}
// ============================================================
// Pagination
// ============================================================
function prevPage() {
if (state.page > 1) {
state.page--;
loadLots();
}
}
function nextPage() {
if (state.lots && state.page < state.lots.pagination.totalPages) {
state.page++;
loadLots();
}
}
// ============================================================
// Data Loading
// ============================================================
async function loadLots() {
document.getElementById('lotBody').innerHTML = '<tr><td colspan="10" class="placeholder">Loading...</td></tr>';
document.getElementById('refreshIndicator').classList.add('active');
try {
state.lots = await fetchLots();
renderLots(state.lots);
} catch (error) {
console.error('Load lots failed:', error);
document.getElementById('lotBody').innerHTML = '<tr><td colspan="10" class="placeholder">Error loading data</td></tr>';
} finally {
document.getElementById('refreshIndicator').classList.remove('active');
}
}
async function loadAllData(showOverlay = true) {
if (showOverlay) {
document.getElementById('loadingOverlay').style.display = 'flex';
}
document.getElementById('refreshIndicator').classList.add('active');
try {
const [summary, distribution, lots] = await Promise.all([
fetchSummary(),
fetchDistribution(),
fetchLots()
]);
state.summary = summary;
state.distribution = distribution;
state.lots = lots;
renderSummary(summary);
renderDistribution(distribution);
renderLots(lots);
updateFilterIndicator();
document.getElementById('lastUpdate').textContent = `Last Update: ${new Date().toLocaleString('zh-TW')}`;
} catch (error) {
console.error('Load data failed:', error);
} finally {
document.getElementById('loadingOverlay').style.display = 'none';
document.getElementById('refreshIndicator').classList.remove('active');
}
}
function manualRefresh() {
loadAllData(false);
}
// ============================================================
// Initialize
// ============================================================
window.onload = function() {
loadAllData(true);
};
Object.assign(window, {
toggleAgeFilter,
toggleWorkcenterFilter,
togglePackageFilter,
clearFilters,
prevPage,
nextPage,
manualRefresh,
loadAllData,
loadLots
});
})();

View File

@@ -0,0 +1,474 @@
import { ensureMesApiAvailable } from '../core/api.js';
import { getPageContract } from '../core/field-contracts.js';
import { escapeHtml, groupBy, sortBy, safeText } from '../core/table-tree.js';
ensureMesApiAvailable();
window.__MES_FRONTEND_CORE__ = { groupBy, sortBy, escapeHtml, safeText };
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
window.__FIELD_CONTRACTS__['job_query:jobs_table'] = getPageContract('job_query', 'jobs_table');
window.__FIELD_CONTRACTS__['job_query:txn_table'] = getPageContract('job_query', 'txn_table');
const jobTableFields = getPageContract('job_query', 'jobs_table');
const txnTableFields = getPageContract('job_query', 'txn_table');
function renderJobCell(job, apiKey) {
if (apiKey === 'JOBSTATUS') {
const value = safeText(job[apiKey]);
return `<span class="status-badge ${value}">${value}</span>`;
}
if (apiKey === 'CREATEDATE' || apiKey === 'COMPLETEDATE') {
return formatDate(job[apiKey]);
}
return escapeHtml(safeText(job[apiKey]));
}
function renderTxnCell(txn, apiKey) {
if (apiKey === 'FROMJOBSTATUS' || apiKey === 'JOBSTATUS') {
const value = safeText(txn[apiKey], '-');
return `<span class="status-badge ${escapeHtml(value)}">${escapeHtml(value)}</span>`;
}
if (apiKey === 'TXNDATE') {
return formatDate(txn[apiKey]);
}
if (apiKey === 'USER_NAME') {
return escapeHtml(safeText(txn.USER_NAME || txn.EMP_NAME));
}
return escapeHtml(safeText(txn[apiKey]));
}
// State
let allEquipments = [];
let selectedEquipments = new Set();
let jobsData = [];
let expandedJobs = new Set();
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadEquipments();
setLast90Days();
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
const dropdown = document.getElementById('equipmentDropdown');
const selector = document.querySelector('.equipment-selector');
if (!selector.contains(e.target)) {
dropdown.classList.remove('show');
}
});
});
// Load equipments from cache
async function loadEquipments() {
try {
const data = await MesApi.get('/api/job-query/resources');
if (data.error) {
document.getElementById('equipmentList').innerHTML = `<div class="error">${data.error}</div>`;
return;
}
allEquipments = data.data;
renderEquipmentList(allEquipments);
} catch (error) {
document.getElementById('equipmentList').innerHTML = `<div class="error">載入失敗: ${error.message}</div>`;
}
}
// Render equipment list
function renderEquipmentList(equipments) {
const container = document.getElementById('equipmentList');
if (!equipments || equipments.length === 0) {
container.innerHTML = '<div class="empty-state">無設備資料</div>';
return;
}
let html = '';
const grouped = groupBy(equipments, (eq) => safeText(eq.WORKCENTERNAME, '未分類'));
const workcenters = sortBy(Object.keys(grouped), (name) => name);
workcenters.forEach((workcenterName) => {
html += `<div style="padding: 8px 15px; background: #f0f0f0; font-weight: 600; font-size: 12px; color: #666;">${escapeHtml(workcenterName)}</div>`;
grouped[workcenterName].forEach((eq) => {
const isSelected = selectedEquipments.has(eq.RESOURCEID);
const resourceId = escapeHtml(safeText(eq.RESOURCEID));
const resourceName = escapeHtml(safeText(eq.RESOURCENAME));
const familyName = escapeHtml(safeText(eq.RESOURCEFAMILYNAME));
html += `
<div class="equipment-item ${isSelected ? 'selected' : ''}" onclick="toggleEquipment('${resourceId}')">
<input type="checkbox" ${isSelected ? 'checked' : ''} onclick="event.stopPropagation(); toggleEquipment('${resourceId}')">
<div class="equipment-info">
<div class="equipment-name">${resourceName}</div>
<div class="equipment-workcenter">${familyName}</div>
</div>
</div>
`;
});
});
container.innerHTML = html;
}
// Toggle equipment dropdown
function toggleEquipmentDropdown() {
const dropdown = document.getElementById('equipmentDropdown');
dropdown.classList.toggle('show');
}
// Filter equipments by search
function filterEquipments(query) {
const q = query.toLowerCase();
const filtered = allEquipments.filter(eq =>
(eq.RESOURCENAME && eq.RESOURCENAME.toLowerCase().includes(q)) ||
(eq.WORKCENTERNAME && eq.WORKCENTERNAME.toLowerCase().includes(q)) ||
(eq.RESOURCEFAMILYNAME && eq.RESOURCEFAMILYNAME.toLowerCase().includes(q))
);
renderEquipmentList(filtered);
}
// Toggle equipment selection
function toggleEquipment(resourceId) {
if (selectedEquipments.has(resourceId)) {
selectedEquipments.delete(resourceId);
} else {
selectedEquipments.add(resourceId);
}
updateSelectedDisplay();
renderEquipmentList(allEquipments.filter(eq => {
const search = document.querySelector('.equipment-search');
if (!search || !search.value) return true;
const q = search.value.toLowerCase();
return (eq.RESOURCENAME && eq.RESOURCENAME.toLowerCase().includes(q)) ||
(eq.WORKCENTERNAME && eq.WORKCENTERNAME.toLowerCase().includes(q));
}));
}
// Update selected display
function updateSelectedDisplay() {
const display = document.getElementById('equipmentDisplay');
const count = document.getElementById('selectedCount');
if (selectedEquipments.size === 0) {
display.textContent = '點擊選擇設備...';
count.textContent = '';
} else if (selectedEquipments.size <= 3) {
const names = allEquipments
.filter(eq => selectedEquipments.has(eq.RESOURCEID))
.map(eq => eq.RESOURCENAME)
.join(', ');
display.textContent = names;
count.textContent = `已選擇 ${selectedEquipments.size} 台設備`;
} else {
display.textContent = `已選擇 ${selectedEquipments.size} 台設備`;
count.textContent = '';
}
}
// Set last 90 days
function setLast90Days() {
const today = new Date();
const past = new Date();
past.setDate(today.getDate() - 90);
document.getElementById('dateFrom').value = past.toISOString().split('T')[0];
document.getElementById('dateTo').value = today.toISOString().split('T')[0];
}
// Validate inputs
function validateInputs() {
if (selectedEquipments.size === 0) {
Toast.error('請選擇至少一台設備');
return false;
}
const dateFrom = document.getElementById('dateFrom').value;
const dateTo = document.getElementById('dateTo').value;
if (!dateFrom || !dateTo) {
Toast.error('請指定日期範圍');
return false;
}
const from = new Date(dateFrom);
const to = new Date(dateTo);
if (to < from) {
Toast.error('結束日期不可早於起始日期');
return false;
}
const daysDiff = (to - from) / (1000 * 60 * 60 * 24);
if (daysDiff > 365) {
Toast.error('日期範圍不可超過 365 天');
return false;
}
return true;
}
// Query jobs
async function queryJobs() {
if (!validateInputs()) return;
const resultSection = document.getElementById('resultSection');
resultSection.innerHTML = `
<div class="loading">
<div class="loading-spinner"></div>
<br>查詢中...
</div>
`;
document.getElementById('queryBtn').disabled = true;
document.getElementById('exportBtn').disabled = true;
try {
const data = await MesApi.post('/api/job-query/jobs', {
resource_ids: Array.from(selectedEquipments),
start_date: document.getElementById('dateFrom').value,
end_date: document.getElementById('dateTo').value
});
if (data.error) {
resultSection.innerHTML = `<div class="error">${data.error}</div>`;
return;
}
jobsData = data.data;
expandedJobs.clear();
renderJobsTable();
document.getElementById('exportBtn').disabled = jobsData.length === 0;
} catch (error) {
resultSection.innerHTML = `<div class="error">查詢失敗: ${error.message}</div>`;
} finally {
document.getElementById('queryBtn').disabled = false;
}
}
// Render jobs table
function renderJobsTable() {
const resultSection = document.getElementById('resultSection');
const jobHeaders = jobTableFields.map((field) => `<th>${escapeHtml(field.ui_label)}</th>`).join('');
if (!jobsData || jobsData.length === 0) {
resultSection.innerHTML = `
<div class="empty-state">
<p>無符合條件的工單</p>
</div>
`;
return;
}
let html = `
<div class="result-header">
<div class="result-info">共 ${jobsData.length} 筆工單</div>
<div class="result-actions">
<button class="btn btn-secondary btn-sm" onclick="expandAll()">全部展開</button>
<button class="btn btn-secondary btn-sm" onclick="collapseAll()">全部收合</button>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th style="width: 40px;"></th>
${jobHeaders}
</tr>
</thead>
<tbody>
`;
jobsData.forEach((job, idx) => {
const isExpanded = expandedJobs.has(job.JOBID);
const jobCells = jobTableFields
.map((field) => `<td>${renderJobCell(job, field.api_key)}</td>`)
.join('');
html += `
<tr class="job-row ${isExpanded ? 'expanded' : ''}" id="job-row-${idx}">
<td>
<button class="expand-btn" onclick="toggleJobHistory('${escapeHtml(safeText(job.JOBID))}', ${idx})">
<span class="arrow-icon ${isExpanded ? 'rotated' : ''}">▶</span>
</button>
</td>
${jobCells}
</tr>
<tr class="txn-history-row ${isExpanded ? 'show' : ''}" id="txn-row-${idx}">
<td colspan="${jobTableFields.length + 1}" class="txn-history-cell">
<div id="txn-content-${idx}">
${isExpanded ? '<div class="loading"><div class="loading-spinner"></div></div>' : ''}
</div>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
resultSection.innerHTML = html;
// Load expanded histories
expandedJobs.forEach(jobId => {
const idx = jobsData.findIndex(j => j.JOBID === jobId);
if (idx >= 0) loadJobHistory(jobId, idx);
});
}
// Toggle job history
async function toggleJobHistory(jobId, idx) {
const txnRow = document.getElementById(`txn-row-${idx}`);
const jobRow = document.getElementById(`job-row-${idx}`);
const arrow = jobRow.querySelector('.arrow-icon');
if (expandedJobs.has(jobId)) {
expandedJobs.delete(jobId);
txnRow.classList.remove('show');
jobRow.classList.remove('expanded');
arrow.classList.remove('rotated');
} else {
expandedJobs.add(jobId);
txnRow.classList.add('show');
jobRow.classList.add('expanded');
arrow.classList.add('rotated');
loadJobHistory(jobId, idx);
}
}
// Load job history
async function loadJobHistory(jobId, idx) {
const container = document.getElementById(`txn-content-${idx}`);
container.innerHTML = '<div class="loading" style="padding: 20px;"><div class="loading-spinner"></div></div>';
try {
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>`;
return;
}
if (!data.data || data.data.length === 0) {
container.innerHTML = '<div style="padding: 20px; color: #666;">無交易歷史記錄</div>';
return;
}
const txnHeaders = txnTableFields.map((field) => `<th>${escapeHtml(field.ui_label)}</th>`).join('');
let html = `
<table class="txn-history-table">
<thead>
<tr>
${txnHeaders}
</tr>
</thead>
<tbody>
`;
data.data.forEach(txn => {
const txnCells = txnTableFields
.map((field) => `<td>${renderTxnCell(txn, field.api_key)}</td>`)
.join('');
html += `
<tr>
${txnCells}
</tr>
`;
});
html += '</tbody></table>';
container.innerHTML = html;
} catch (error) {
container.innerHTML = `<div class="error" style="margin: 10px 20px;">載入失敗: ${error.message}</div>`;
}
}
// Expand all
function expandAll() {
jobsData.forEach((job, idx) => {
if (!expandedJobs.has(job.JOBID)) {
expandedJobs.add(job.JOBID);
}
});
renderJobsTable();
}
// Collapse all
function collapseAll() {
expandedJobs.clear();
renderJobsTable();
}
// Export CSV
async function exportCsv() {
if (!validateInputs()) return;
document.getElementById('exportBtn').disabled = true;
document.getElementById('exportBtn').textContent = '匯出中...';
try {
const response = await fetch('/api/job-query/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resource_ids: Array.from(selectedEquipments),
start_date: document.getElementById('dateFrom').value,
end_date: document.getElementById('dateTo').value
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || '匯出失敗');
}
// Download file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `job_history_${document.getElementById('dateFrom').value}_${document.getElementById('dateTo').value}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
Toast.success('CSV 匯出完成');
} catch (error) {
Toast.error('匯出失敗: ' + error.message);
} finally {
document.getElementById('exportBtn').disabled = false;
document.getElementById('exportBtn').textContent = '匯出 CSV';
}
}
// Format date
function formatDate(dateStr) {
if (!dateStr) return '';
return dateStr.replace('T', ' ').substring(0, 19);
}
Object.assign(window, {
loadEquipments,
renderEquipmentList,
toggleEquipmentDropdown,
filterEquipments,
toggleEquipment,
updateSelectedDisplay,
setLast90Days,
validateInputs,
queryJobs,
renderJobsTable,
toggleJobHistory,
loadJobHistory,
expandAll,
collapseAll,
exportCsv,
formatDate,
});

193
frontend/src/portal/main.js Normal file
View File

@@ -0,0 +1,193 @@
import './portal.css';
(function initPortal() {
const tabs = document.querySelectorAll('.tab');
const frames = document.querySelectorAll('iframe');
const toolFrame = document.getElementById('toolFrame');
const healthDot = document.getElementById('healthDot');
const healthLabel = document.getElementById('healthLabel');
const healthPopup = document.getElementById('healthPopup');
const healthStatus = document.getElementById('healthStatus');
const dbStatus = document.getElementById('dbStatus');
const redisStatus = document.getElementById('redisStatus');
const cacheEnabled = document.getElementById('cacheEnabled');
const cacheSysDate = document.getElementById('cacheSysDate');
const cacheUpdatedAt = document.getElementById('cacheUpdatedAt');
const resourceCacheEnabled = document.getElementById('resourceCacheEnabled');
const resourceCacheCount = document.getElementById('resourceCacheCount');
const resourceCacheUpdatedAt = document.getElementById('resourceCacheUpdatedAt');
const routeCacheMode = document.getElementById('routeCacheMode');
const routeCacheHitRate = document.getElementById('routeCacheHitRate');
const routeCacheDegraded = document.getElementById('routeCacheDegraded');
function setFrameHeight() {
const header = document.querySelector('.header');
const tabArea = document.querySelector('.tabs');
if (!header || !tabArea) return;
const height = Math.max(600, window.innerHeight - header.offsetHeight - tabArea.offsetHeight - 60);
frames.forEach((frame) => {
frame.style.height = `${height}px`;
});
}
function activateTab(targetId) {
tabs.forEach((tab) => tab.classList.remove('active'));
frames.forEach((frame) => frame.classList.remove('active'));
const tabBtn = document.querySelector(`[data-target="${targetId}"]`);
const targetFrame = document.getElementById(targetId);
if (tabBtn) tabBtn.classList.add('active');
if (targetFrame) {
targetFrame.classList.add('active');
if (targetFrame.dataset.src && !targetFrame.src) {
targetFrame.src = targetFrame.dataset.src;
}
}
}
function openTool(path) {
if (!toolFrame) return false;
tabs.forEach((tab) => tab.classList.remove('active'));
frames.forEach((frame) => frame.classList.remove('active'));
toolFrame.classList.add('active');
if (toolFrame.src !== path) {
toolFrame.src = path;
}
return false;
}
function toggleHealthPopup() {
if (!healthPopup) return;
healthPopup.classList.toggle('show');
}
function formatStatus(status) {
const icons = { ok: '✓', error: '✗', disabled: '○' };
return icons[status] || status;
}
function setStatusClass(element, status) {
if (!element) return;
element.classList.remove('ok', 'error', 'disabled');
element.classList.add(status === 'ok' ? 'ok' : status === 'error' ? 'error' : 'disabled');
}
function formatDateTime(dateStr) {
if (!dateStr) return '--';
try {
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return dateStr;
return date.toLocaleString('zh-TW', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateStr;
}
}
async function checkHealth() {
if (!healthDot || !healthLabel) return;
try {
const response = await fetch('/health', { cache: 'no-store' });
const data = await response.json();
healthDot.classList.remove('loading', 'healthy', 'degraded', 'unhealthy');
if (data.status === 'healthy') {
healthDot.classList.add('healthy');
healthLabel.textContent = '連線正常';
} else if (data.status === 'degraded') {
healthDot.classList.add('degraded');
healthLabel.textContent = '部分降級';
} else {
healthDot.classList.add('unhealthy');
healthLabel.textContent = '連線異常';
}
const dbState = data.services?.database || 'error';
if (dbStatus) dbStatus.innerHTML = `${formatStatus(dbState)} ${dbState === 'ok' ? '正常' : '異常'}`;
setStatusClass(dbStatus, dbState);
const redisState = data.services?.redis || 'disabled';
const redisText = redisState === 'ok' ? '正常' : redisState === 'disabled' ? '未啟用' : '異常';
if (redisStatus) redisStatus.innerHTML = `${formatStatus(redisState)} ${redisText}`;
setStatusClass(redisStatus, redisState);
const cache = data.cache || {};
if (cacheEnabled) cacheEnabled.textContent = cache.enabled ? '已啟用' : '未啟用';
if (cacheSysDate) cacheSysDate.textContent = cache.sys_date || '--';
if (cacheUpdatedAt) cacheUpdatedAt.textContent = formatDateTime(cache.updated_at);
const resCache = data.resource_cache || {};
if (resCache.enabled) {
if (resourceCacheEnabled) {
resourceCacheEnabled.textContent = resCache.loaded ? '已載入' : '未載入';
resourceCacheEnabled.style.color = resCache.loaded ? '#22c55e' : '#f59e0b';
}
if (resourceCacheCount) resourceCacheCount.textContent = resCache.count ? `${resCache.count}` : '--';
if (resourceCacheUpdatedAt) resourceCacheUpdatedAt.textContent = formatDateTime(resCache.updated_at);
} else {
if (resourceCacheEnabled) {
resourceCacheEnabled.textContent = '未啟用';
resourceCacheEnabled.style.color = '#9ca3af';
}
if (resourceCacheCount) resourceCacheCount.textContent = '--';
if (resourceCacheUpdatedAt) resourceCacheUpdatedAt.textContent = '--';
}
const routeCache = data.route_cache || {};
if (routeCacheMode) {
routeCacheMode.textContent = routeCache.mode || '--';
}
if (routeCacheHitRate) {
const l1 = routeCache.l1_hit_rate ?? '--';
const l2 = routeCache.l2_hit_rate ?? '--';
routeCacheHitRate.textContent = `${l1} / ${l2}`;
}
if (routeCacheDegraded) {
routeCacheDegraded.textContent = routeCache.degraded ? '是' : '否';
routeCacheDegraded.style.color = routeCache.degraded ? '#f59e0b' : '#22c55e';
}
} catch (error) {
console.error('Health check failed:', error);
healthDot.classList.remove('loading', 'healthy', 'degraded');
healthDot.classList.add('unhealthy');
healthLabel.textContent = '無法連線';
if (dbStatus) {
dbStatus.innerHTML = '✗ 無法確認';
setStatusClass(dbStatus, 'error');
}
if (redisStatus) {
redisStatus.innerHTML = '✗ 無法確認';
setStatusClass(redisStatus, 'error');
}
}
}
tabs.forEach((tab) => {
tab.addEventListener('click', () => activateTab(tab.dataset.target));
});
if (tabs.length > 0) {
activateTab(tabs[0].dataset.target);
}
window.openTool = openTool;
window.toggleHealthPopup = toggleHealthPopup;
if (healthStatus) {
healthStatus.addEventListener('click', toggleHealthPopup);
}
document.addEventListener('click', (e) => {
if (!e.target.closest('#healthStatus') && !e.target.closest('#healthPopup') && healthPopup) {
healthPopup.classList.remove('show');
}
});
checkHealth();
setInterval(checkHealth, 30000);
window.addEventListener('resize', setFrameHeight);
setFrameHeight();
})();

View File

@@ -0,0 +1,29 @@
.drawer {
background: #fff;
border-radius: 10px;
border: 1px solid #e3e8f2;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.drawer > summary {
list-style: none;
cursor: pointer;
padding: 10px 14px;
font-size: 14px;
font-weight: 600;
color: #334155;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.drawer > summary::-webkit-details-marker {
display: none;
}
.drawer-content {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 12px;
}

View File

@@ -0,0 +1,844 @@
import { ensureMesApiAvailable } from '../core/api.js';
import { getPageContract } from '../core/field-contracts.js';
import { buildResourceKpiFromHours } from '../core/compute.js';
import { groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText } from '../core/table-tree.js';
ensureMesApiAvailable();
window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText };
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
window.__FIELD_CONTRACTS__['resource_history:detail_table'] = getPageContract('resource_history', 'detail_table');
window.__FIELD_CONTRACTS__['resource_history:kpi'] = getPageContract('resource_history', 'kpi');
const detailTableFields = getPageContract('resource_history', 'detail_table');
(function() {
// ============================================================
// State
// ============================================================
let currentGranularity = 'day';
let summaryData = null;
let detailData = null;
let hierarchyState = {}; // Track expanded/collapsed state
let charts = {};
// ============================================================
// DOM Elements
// ============================================================
const startDateInput = document.getElementById('startDate');
const endDateInput = document.getElementById('endDate');
const workcenterGroupsTrigger = document.getElementById('workcenterGroupsTrigger');
const workcenterGroupsDropdown = document.getElementById('workcenterGroupsDropdown');
const workcenterGroupsOptions = document.getElementById('workcenterGroupsOptions');
const familiesTrigger = document.getElementById('familiesTrigger');
const familiesDropdown = document.getElementById('familiesDropdown');
const familiesOptions = document.getElementById('familiesOptions');
const isProductionCheckbox = document.getElementById('isProduction');
const isKeyCheckbox = document.getElementById('isKey');
const isMonitorCheckbox = document.getElementById('isMonitor');
const queryBtn = document.getElementById('queryBtn');
const exportBtn = document.getElementById('exportBtn');
const expandAllBtn = document.getElementById('expandAllBtn');
const collapseAllBtn = document.getElementById('collapseAllBtn');
const loadingOverlay = document.getElementById('loadingOverlay');
// Selected values for multi-select
let selectedWorkcenterGroups = [];
let selectedFamilies = [];
// ============================================================
// Initialization
// ============================================================
function init() {
setDefaultDates();
applyDetailTableHeaders();
loadFilterOptions();
setupEventListeners();
initCharts();
}
function setDefaultDates() {
const today = new Date();
const endDate = new Date(today);
endDate.setDate(endDate.getDate() - 1); // Yesterday
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 6); // 7 days ago
startDateInput.value = formatDate(startDate);
endDateInput.value = formatDate(endDate);
}
function formatDate(date) {
return date.toISOString().split('T')[0];
}
function setupEventListeners() {
// Granularity buttons
document.querySelectorAll('.granularity-btns button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.granularity-btns button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentGranularity = btn.dataset.granularity;
});
});
// Query button
queryBtn.addEventListener('click', executeQuery);
// Export button
exportBtn.addEventListener('click', exportCsv);
// Expand/Collapse buttons
expandAllBtn.addEventListener('click', () => toggleAllRows(true));
collapseAllBtn.addEventListener('click', () => toggleAllRows(false));
}
function applyDetailTableHeaders() {
const headers = document.querySelectorAll('.detail-table thead th');
if (!headers || headers.length < 10) return;
const byKey = {};
detailTableFields.forEach((field) => {
byKey[field.api_key] = field.ui_label;
});
headers[1].textContent = byKey.ou_pct || headers[1].textContent;
headers[2].textContent = byKey.availability_pct || headers[2].textContent;
headers[3].textContent = byKey.prd_hours ? byKey.prd_hours.replace('(h)', '') : headers[3].textContent;
headers[4].textContent = byKey.sby_hours ? byKey.sby_hours.replace('(h)', '') : headers[4].textContent;
headers[5].textContent = byKey.udt_hours ? byKey.udt_hours.replace('(h)', '') : headers[5].textContent;
headers[6].textContent = byKey.sdt_hours ? byKey.sdt_hours.replace('(h)', '') : headers[6].textContent;
headers[7].textContent = byKey.egt_hours ? byKey.egt_hours.replace('(h)', '') : headers[7].textContent;
headers[8].textContent = byKey.nst_hours ? byKey.nst_hours.replace('(h)', '') : headers[8].textContent;
}
function initCharts() {
charts.trend = echarts.init(document.getElementById('trendChart'));
charts.stacked = echarts.init(document.getElementById('stackedChart'));
charts.comparison = echarts.init(document.getElementById('comparisonChart'));
charts.heatmap = echarts.init(document.getElementById('heatmapChart'));
// Handle window resize
window.addEventListener('resize', () => {
Object.values(charts).forEach(chart => chart.resize());
});
}
// ============================================================
// API Calls (using MesApi client with timeout and retry)
// ============================================================
const API_TIMEOUT = 60000; // 60 seconds timeout
async function loadFilterOptions() {
try {
const result = await MesApi.get('/api/resource/history/options', {
timeout: API_TIMEOUT,
silent: true // Don't show toast for filter options
});
if (result.success) {
populateMultiSelect(workcenterGroupsOptions, result.data.workcenter_groups, 'workcenter');
populateMultiSelect(familiesOptions, result.data.families.map(f => ({name: f})), 'family');
setupMultiSelectDropdowns();
}
} catch (error) {
console.error('Failed to load filter options:', error);
}
}
function populateMultiSelect(container, options, type) {
container.innerHTML = '';
options.forEach(opt => {
const name = opt.name || opt;
const div = document.createElement('div');
div.className = 'multi-select-option';
div.innerHTML = `
<input type="checkbox" value="${name}" data-type="${type}">
<span>${name}</span>
`;
div.querySelector('input').addEventListener('change', (e) => {
if (type === 'workcenter') {
updateSelectedWorkcenterGroups();
} else {
updateSelectedFamilies();
}
});
container.appendChild(div);
});
}
function setupMultiSelectDropdowns() {
// Workcenter Groups dropdown toggle
workcenterGroupsTrigger.addEventListener('click', (e) => {
e.stopPropagation();
workcenterGroupsDropdown.classList.toggle('show');
familiesDropdown.classList.remove('show');
});
// Families dropdown toggle
familiesTrigger.addEventListener('click', (e) => {
e.stopPropagation();
familiesDropdown.classList.toggle('show');
workcenterGroupsDropdown.classList.remove('show');
});
// Close dropdowns when clicking outside
document.addEventListener('click', () => {
workcenterGroupsDropdown.classList.remove('show');
familiesDropdown.classList.remove('show');
});
// Prevent dropdown close when clicking inside
workcenterGroupsDropdown.addEventListener('click', (e) => e.stopPropagation());
familiesDropdown.addEventListener('click', (e) => e.stopPropagation());
}
function updateSelectedWorkcenterGroups() {
const checkboxes = workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]:checked');
selectedWorkcenterGroups = Array.from(checkboxes).map(cb => cb.value);
updateMultiSelectText(workcenterGroupsTrigger, selectedWorkcenterGroups, '全部站點');
}
function updateSelectedFamilies() {
const checkboxes = familiesOptions.querySelectorAll('input[type="checkbox"]:checked');
selectedFamilies = Array.from(checkboxes).map(cb => cb.value);
updateMultiSelectText(familiesTrigger, selectedFamilies, '全部型號');
}
function updateMultiSelectText(trigger, selected, defaultText) {
const textSpan = trigger.querySelector('.multi-select-text');
if (selected.length === 0) {
textSpan.textContent = defaultText;
} else if (selected.length === 1) {
textSpan.textContent = selected[0];
} else {
textSpan.textContent = `已選 ${selected.length}`;
}
}
// Global functions for select all / clear all
window.selectAllWorkcenterGroups = function() {
workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
updateSelectedWorkcenterGroups();
};
window.clearAllWorkcenterGroups = function() {
workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
updateSelectedWorkcenterGroups();
};
window.selectAllFamilies = function() {
familiesOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
updateSelectedFamilies();
};
window.clearAllFamilies = function() {
familiesOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
updateSelectedFamilies();
};
function buildQueryString() {
const params = new URLSearchParams();
params.append('start_date', startDateInput.value);
params.append('end_date', endDateInput.value);
params.append('granularity', currentGranularity);
// Add multi-select params
selectedWorkcenterGroups.forEach(g => params.append('workcenter_groups', g));
selectedFamilies.forEach(f => params.append('families', f));
if (isProductionCheckbox.checked) params.append('is_production', '1');
if (isKeyCheckbox.checked) params.append('is_key', '1');
if (isMonitorCheckbox.checked) params.append('is_monitor', '1');
return params.toString();
}
async function executeQuery() {
// Validate date range
const startDate = new Date(startDateInput.value);
const endDate = new Date(endDateInput.value);
const diffDays = (endDate - startDate) / (1000 * 60 * 60 * 24);
if (diffDays > 730) {
Toast.warning('查詢範圍不可超過兩年');
return;
}
if (diffDays < 0) {
Toast.warning('結束日期必須大於起始日期');
return;
}
showLoading();
queryBtn.disabled = true;
try {
const queryString = buildQueryString();
const summaryUrl = `/api/resource/history/summary?${queryString}`;
const detailUrl = `/api/resource/history/detail?${queryString}`;
// Fetch summary and detail in parallel using MesApi
const [summaryResult, detailResult] = await Promise.all([
MesApi.get(summaryUrl, { timeout: API_TIMEOUT }),
MesApi.get(detailUrl, { timeout: API_TIMEOUT })
]);
if (summaryResult.success) {
const rawSummary = summaryResult.data || {};
const computedKpi = mergeComputedKpi(rawSummary.kpi || {});
const computedTrend = (rawSummary.trend || []).map((trendPoint) => mergeComputedKpi(trendPoint));
summaryData = {
...rawSummary,
kpi: computedKpi,
trend: computedTrend
};
updateKpiCards(summaryData.kpi);
updateTrendChart(summaryData.trend);
updateStackedChart(summaryData.trend);
updateComparisonChart(summaryData.workcenter_comparison);
updateHeatmapChart(summaryData.heatmap);
} else {
Toast.error(summaryResult.error || '查詢摘要失敗');
}
if (detailResult.success) {
detailData = detailResult.data;
hierarchyState = {};
renderDetailTable(detailData);
// Show warning if data was truncated
if (detailResult.truncated) {
Toast.warning(`明細資料超過 ${detailResult.max_records} 筆,僅顯示前 ${detailResult.max_records} 筆。請使用篩選條件縮小範圍。`);
}
} else {
Toast.error(detailResult.error || '查詢明細失敗');
}
} catch (error) {
console.error('Query failed:', error);
Toast.error('查詢失敗: ' + error.message);
} finally {
hideLoading();
queryBtn.disabled = false;
}
}
// ============================================================
// KPI Cards
// ============================================================
function mergeComputedKpi(kpi) {
return {
...kpi,
...buildResourceKpiFromHours(kpi)
};
}
function updateKpiCards(kpi) {
// OU% and AVAIL%
document.getElementById('kpiOuPct').textContent = kpi.ou_pct + '%';
document.getElementById('kpiAvailabilityPct').textContent = kpi.availability_pct + '%';
// PRD
document.getElementById('kpiPrdHours').textContent = formatHours(kpi.prd_hours);
document.getElementById('kpiPrdPct').textContent = `生產 (${kpi.prd_pct || 0}%)`;
// SBY
document.getElementById('kpiSbyHours').textContent = formatHours(kpi.sby_hours);
document.getElementById('kpiSbyPct').textContent = `待機 (${kpi.sby_pct || 0}%)`;
// UDT
document.getElementById('kpiUdtHours').textContent = formatHours(kpi.udt_hours);
document.getElementById('kpiUdtPct').textContent = `非計畫停機 (${kpi.udt_pct || 0}%)`;
// SDT
document.getElementById('kpiSdtHours').textContent = formatHours(kpi.sdt_hours);
document.getElementById('kpiSdtPct').textContent = `計畫停機 (${kpi.sdt_pct || 0}%)`;
// EGT
document.getElementById('kpiEgtHours').textContent = formatHours(kpi.egt_hours);
document.getElementById('kpiEgtPct').textContent = `工程 (${kpi.egt_pct || 0}%)`;
// NST
document.getElementById('kpiNstHours').textContent = formatHours(kpi.nst_hours);
document.getElementById('kpiNstPct').textContent = `未排程 (${kpi.nst_pct || 0}%)`;
// Machine count
const machineCount = Number(kpi.machine_count || 0);
document.getElementById('kpiMachineCount').textContent = machineCount.toLocaleString();
}
function formatHours(hours) {
if (hours >= 1000) {
return (hours / 1000).toFixed(1) + 'K';
}
return hours.toLocaleString();
}
// ============================================================
// Charts
// ============================================================
function updateTrendChart(trend) {
const dates = trend.map(t => t.date);
const ouPcts = trend.map(t => t.ou_pct);
const availabilityPcts = trend.map(t => t.availability_pct);
charts.trend.setOption({
tooltip: {
trigger: 'axis',
formatter: function(params) {
const d = trend[params[0].dataIndex];
return `${d.date}<br/>
<span style="color:#3B82F6">●</span> OU%: <b>${d.ou_pct}%</b><br/>
<span style="color:#10B981">●</span> AVAIL%: <b>${d.availability_pct}%</b><br/>
PRD: ${d.prd_hours}h<br/>
SBY: ${d.sby_hours}h<br/>
UDT: ${d.udt_hours}h`;
}
},
legend: {
data: ['OU%', 'AVAIL%'],
bottom: 0,
textStyle: { fontSize: 11 }
},
xAxis: {
type: 'category',
data: dates,
axisLabel: { fontSize: 11 }
},
yAxis: {
type: 'value',
name: '%',
max: 100,
axisLabel: { formatter: '{value}%' }
},
series: [
{
name: 'OU%',
data: ouPcts,
type: 'line',
smooth: true,
areaStyle: { opacity: 0.2 },
itemStyle: { color: '#3B82F6' },
lineStyle: { width: 2 }
},
{
name: 'AVAIL%',
data: availabilityPcts,
type: 'line',
smooth: true,
areaStyle: { opacity: 0.2 },
itemStyle: { color: '#10B981' },
lineStyle: { width: 2 }
}
],
grid: { left: 50, right: 20, top: 30, bottom: 50 }
});
}
function updateStackedChart(trend) {
const dates = trend.map(t => t.date);
charts.stacked.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: function(params) {
const idx = params[0].dataIndex;
const d = trend[idx];
const total = d.prd_hours + d.sby_hours + d.udt_hours + d.sdt_hours + d.egt_hours + d.nst_hours;
const pct = (v) => total > 0 ? (v / total * 100).toFixed(1) : 0;
return `<b>${d.date}</b><br/>
<span style="color:#22c55e">●</span> PRD: ${d.prd_hours}h (${pct(d.prd_hours)}%)<br/>
<span style="color:#3b82f6">●</span> SBY: ${d.sby_hours}h (${pct(d.sby_hours)}%)<br/>
<span style="color:#ef4444">●</span> UDT: ${d.udt_hours}h (${pct(d.udt_hours)}%)<br/>
<span style="color:#f59e0b">●</span> SDT: ${d.sdt_hours}h (${pct(d.sdt_hours)}%)<br/>
<span style="color:#8b5cf6">●</span> EGT: ${d.egt_hours}h (${pct(d.egt_hours)}%)<br/>
<span style="color:#64748b">●</span> NST: ${d.nst_hours}h (${pct(d.nst_hours)}%)<br/>
<b>Total: ${total.toFixed(1)}h</b>`;
}
},
legend: {
data: ['PRD', 'SBY', 'UDT', 'SDT', 'EGT', 'NST'],
bottom: 0,
textStyle: { fontSize: 10 }
},
xAxis: {
type: 'category',
data: dates,
axisLabel: { fontSize: 10 }
},
yAxis: {
type: 'value',
name: '時數',
axisLabel: { formatter: '{value}h' }
},
series: [
{ name: 'PRD', type: 'bar', stack: 'total', data: trend.map(t => t.prd_hours), itemStyle: { color: '#22c55e' } },
{ name: 'SBY', type: 'bar', stack: 'total', data: trend.map(t => t.sby_hours), itemStyle: { color: '#3b82f6' } },
{ name: 'UDT', type: 'bar', stack: 'total', data: trend.map(t => t.udt_hours), itemStyle: { color: '#ef4444' } },
{ name: 'SDT', type: 'bar', stack: 'total', data: trend.map(t => t.sdt_hours), itemStyle: { color: '#f59e0b' } },
{ name: 'EGT', type: 'bar', stack: 'total', data: trend.map(t => t.egt_hours), itemStyle: { color: '#8b5cf6' } },
{ name: 'NST', type: 'bar', stack: 'total', data: trend.map(t => t.nst_hours), itemStyle: { color: '#64748b' } }
],
grid: { left: 50, right: 20, top: 30, bottom: 60 }
});
}
function updateComparisonChart(comparison) {
// Take top 15 workcenters and reverse for bottom-to-top display (highest at top)
const data = comparison.slice(0, 15).reverse();
const workcenters = data.map(d => d.workcenter);
const ouPcts = data.map(d => d.ou_pct);
charts.comparison.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: function(params) {
const d = data[params[0].dataIndex];
return `${d.workcenter}<br/>OU%: <b>${d.ou_pct}%</b><br/>機台數: ${d.machine_count}`;
}
},
xAxis: {
type: 'value',
name: 'OU%',
max: 100,
axisLabel: { formatter: '{value}%' }
},
yAxis: {
type: 'category',
data: workcenters,
axisLabel: { fontSize: 10 }
},
series: [{
type: 'bar',
data: ouPcts,
itemStyle: {
color: function(params) {
const val = params.value;
if (val >= 80) return '#22c55e';
if (val >= 50) return '#f59e0b';
return '#ef4444';
}
}
}],
grid: { left: 100, right: 30, top: 20, bottom: 30 }
});
}
function updateHeatmapChart(heatmap) {
if (!heatmap || heatmap.length === 0) {
charts.heatmap.clear();
return;
}
// Build workcenter list with sequence for sorting
const wcSeqMap = {};
heatmap.forEach(h => {
wcSeqMap[h.workcenter] = h.workcenter_seq ?? 999;
});
// Get unique workcenters sorted by sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
const workcenters = [...new Set(heatmap.map(h => h.workcenter))]
.sort((a, b) => wcSeqMap[a] - wcSeqMap[b]);
const dates = [...new Set(heatmap.map(h => h.date))].sort();
// Build data matrix
const data = heatmap.map(h => [
dates.indexOf(h.date),
workcenters.indexOf(h.workcenter),
h.ou_pct
]);
charts.heatmap.setOption({
tooltip: {
position: 'top',
formatter: function(params) {
return `${workcenters[params.value[1]]}<br/>${dates[params.value[0]]}<br/>OU%: <b>${params.value[2]}%</b>`;
}
},
xAxis: {
type: 'category',
data: dates,
splitArea: { show: true },
axisLabel: { fontSize: 9, rotate: 45 }
},
yAxis: {
type: 'category',
data: workcenters,
splitArea: { show: true },
axisLabel: { fontSize: 9 }
},
visualMap: {
min: 0,
max: 100,
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: 0,
inRange: {
color: ['#ef4444', '#f59e0b', '#22c55e']
}
},
series: [{
type: 'heatmap',
data: data,
label: { show: false },
emphasis: {
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' }
}
}],
grid: { left: 100, right: 20, top: 10, bottom: 60 }
});
}
// ============================================================
// Hierarchical Table
// ============================================================
function renderDetailTable(data) {
const tbody = document.getElementById('detailTableBody');
if (!data || data.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="10">
<div class="placeholder">
<div class="placeholder-icon">&#128269;</div>
<div class="placeholder-text">無符合條件的資料</div>
</div>
</td>
</tr>
`;
return;
}
// Build hierarchy
const hierarchy = buildHierarchy(data);
// Render rows
tbody.innerHTML = '';
hierarchy.forEach(wc => {
// Workcenter level
const wcRow = createRow(wc, 0, `wc_${wc.workcenter}`);
tbody.appendChild(wcRow);
// Family level
if (hierarchyState[`wc_${wc.workcenter}`]) {
wc.families.forEach(fam => {
const famRow = createRow(fam, 1, `fam_${wc.workcenter}_${fam.family}`);
famRow.dataset.parent = `wc_${wc.workcenter}`;
tbody.appendChild(famRow);
// Resource level
if (hierarchyState[`fam_${wc.workcenter}_${fam.family}`]) {
fam.resources.forEach(res => {
const resRow = createRow(res, 2);
resRow.dataset.parent = `fam_${wc.workcenter}_${fam.family}`;
tbody.appendChild(resRow);
});
}
});
}
});
}
function buildHierarchy(data) {
const wcMap = {};
data.forEach(item => {
const wc = item.workcenter;
const fam = item.family;
const wcSeq = item.workcenter_seq ?? 999;
if (!wcMap[wc]) {
wcMap[wc] = {
workcenter: wc,
name: wc,
sequence: wcSeq,
families: [],
familyMap: {},
ou_pct: 0, availability_pct: 0, prd_hours: 0, prd_pct: 0,
sby_hours: 0, sby_pct: 0, udt_hours: 0, udt_pct: 0,
sdt_hours: 0, sdt_pct: 0, egt_hours: 0, egt_pct: 0,
nst_hours: 0, nst_pct: 0, machine_count: 0
};
}
if (!wcMap[wc].familyMap[fam]) {
wcMap[wc].familyMap[fam] = {
family: fam,
name: fam,
resources: [],
ou_pct: 0, availability_pct: 0, prd_hours: 0, prd_pct: 0,
sby_hours: 0, sby_pct: 0, udt_hours: 0, udt_pct: 0,
sdt_hours: 0, sdt_pct: 0, egt_hours: 0, egt_pct: 0,
nst_hours: 0, nst_pct: 0, machine_count: 0
};
wcMap[wc].families.push(wcMap[wc].familyMap[fam]);
}
// Add resource
wcMap[wc].familyMap[fam].resources.push({
name: item.resource,
...item
});
// Aggregate to family
const famObj = wcMap[wc].familyMap[fam];
famObj.prd_hours += item.prd_hours;
famObj.sby_hours += item.sby_hours;
famObj.udt_hours += item.udt_hours;
famObj.sdt_hours += item.sdt_hours;
famObj.egt_hours += item.egt_hours;
famObj.nst_hours += item.nst_hours;
famObj.machine_count += 1;
// Aggregate to workcenter
wcMap[wc].prd_hours += item.prd_hours;
wcMap[wc].sby_hours += item.sby_hours;
wcMap[wc].udt_hours += item.udt_hours;
wcMap[wc].sdt_hours += item.sdt_hours;
wcMap[wc].egt_hours += item.egt_hours;
wcMap[wc].nst_hours += item.nst_hours;
wcMap[wc].machine_count += 1;
});
// Calculate OU% and percentages
Object.values(wcMap).forEach(wc => {
calcPercentages(wc);
wc.families.forEach(fam => {
calcPercentages(fam);
});
});
// Sort by workcenter sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
return Object.values(wcMap).sort((a, b) => a.sequence - b.sequence);
}
function calcPercentages(obj) {
Object.assign(obj, buildResourceKpiFromHours(obj));
}
function createRow(item, level, rowId) {
const tr = document.createElement('tr');
tr.className = `row-level-${level}`;
if (rowId) tr.dataset.rowId = rowId;
const indentClass = level > 0 ? `indent-${level}` : '';
const hasChildren = level < 2 && (item.families?.length > 0 || item.resources?.length > 0);
const isExpanded = rowId ? hierarchyState[rowId] : false;
const expandBtn = hasChildren
? `<button class="expand-btn ${isExpanded ? 'expanded' : ''}" onclick="toggleRow('${rowId}')">&#9654;</button>`
: '<span style="display:inline-block;width:24px;"></span>';
tr.innerHTML = `
<td class="${indentClass}">${expandBtn}${item.name}</td>
<td><b>${item.ou_pct}%</b></td>
<td><b>${item.availability_pct}%</b></td>
<td class="status-prd">${formatHoursPct(item.prd_hours, item.prd_pct)}</td>
<td class="status-sby">${formatHoursPct(item.sby_hours, item.sby_pct)}</td>
<td class="status-udt">${formatHoursPct(item.udt_hours, item.udt_pct)}</td>
<td class="status-sdt">${formatHoursPct(item.sdt_hours, item.sdt_pct)}</td>
<td class="status-egt">${formatHoursPct(item.egt_hours, item.egt_pct)}</td>
<td class="status-nst">${formatHoursPct(item.nst_hours, item.nst_pct)}</td>
<td>${item.machine_count}</td>
`;
return tr;
}
function formatHoursPct(hours, pct) {
return `${Math.round(hours * 10) / 10}h (${pct}%)`;
}
// Make toggleRow global
window.toggleRow = function(rowId) {
hierarchyState[rowId] = !hierarchyState[rowId];
renderDetailTable(detailData);
};
function toggleAllRows(expand) {
if (!detailData) return;
const hierarchy = buildHierarchy(detailData);
hierarchy.forEach(wc => {
hierarchyState[`wc_${wc.workcenter}`] = expand;
wc.families.forEach(fam => {
hierarchyState[`fam_${wc.workcenter}_${fam.family}`] = expand;
});
});
renderDetailTable(detailData);
}
// ============================================================
// Export
// ============================================================
function exportCsv() {
if (!startDateInput.value || !endDateInput.value) {
Toast.warning('請先設定查詢條件');
return;
}
const queryString = buildQueryString();
const url = `/api/resource/history/export?${queryString}`;
// Create download link
const a = document.createElement('a');
a.href = url;
a.download = `resource_history_${startDateInput.value}_to_${endDateInput.value}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
Toast.success('CSV 匯出中...');
}
// ============================================================
// Loading
// ============================================================
function showLoading() {
loadingOverlay.classList.remove('hidden');
}
function hideLoading() {
loadingOverlay.classList.add('hidden');
}
Object.assign(window, {
init,
setDefaultDates,
formatDate,
setupEventListeners,
initCharts,
loadFilterOptions,
populateMultiSelect,
setupMultiSelectDropdowns,
updateSelectedWorkcenterGroups,
updateSelectedFamilies,
updateMultiSelectText,
buildQueryString,
executeQuery,
updateKpiCards,
formatHours,
updateTrendChart,
updateStackedChart,
updateComparisonChart,
updateHeatmapChart,
renderDetailTable,
buildHierarchy,
calcPercentages,
createRow,
formatHoursPct,
toggleAllRows,
exportCsv,
showLoading,
hideLoading,
});
// ============================================================
// Start
// ============================================================
init();
})();

View File

@@ -0,0 +1,853 @@
import { ensureMesApiAvailable } from '../core/api.js';
import { getPageContract } from '../core/field-contracts.js';
import { buildResourceKpiFromHours } from '../core/compute.js';
import { groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText } from '../core/table-tree.js';
ensureMesApiAvailable();
window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText };
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
window.__FIELD_CONTRACTS__['resource_status:matrix_summary'] = getPageContract('resource_status', 'matrix_summary');
let allEquipment = [];
let workcenterGroups = [];
let matrixFilter = null; // { workcenter_group, status }
let matrixHierarchyState = {}; // Track expanded/collapsed state for matrix rows
// ============================================================
// Hierarchical Matrix Functions
// ============================================================
function buildMatrixHierarchy(equipment) {
// Build hierarchy: workcenter_group -> resourcefamily -> equipment
const groupMap = {};
equipment.forEach(eq => {
const group = eq.WORKCENTER_GROUP || 'UNKNOWN';
const family = eq.RESOURCEFAMILYNAME || 'UNKNOWN';
const status = eq.EQUIPMENTASSETSSTATUS || 'OTHER';
const groupSeq = eq.WORKCENTER_GROUP_SEQ ?? 999;
// Initialize group
if (!groupMap[group]) {
groupMap[group] = {
name: group,
sequence: groupSeq,
families: {},
counts: { total: 0, PRD: 0, SBY: 0, UDT: 0, SDT: 0, EGT: 0, NST: 0, OTHER: 0 }
};
}
// Initialize family
if (!groupMap[group].families[family]) {
groupMap[group].families[family] = {
name: family,
equipment: [],
counts: { total: 0, PRD: 0, SBY: 0, UDT: 0, SDT: 0, EGT: 0, NST: 0, OTHER: 0 }
};
}
// Add equipment to family
groupMap[group].families[family].equipment.push(eq);
// Map status to count key
let statusKey = 'OTHER';
if (['PRD'].includes(status)) statusKey = 'PRD';
else if (['SBY'].includes(status)) statusKey = 'SBY';
else if (['UDT', 'PM', 'BKD'].includes(status)) statusKey = 'UDT';
else if (['SDT'].includes(status)) statusKey = 'SDT';
else if (['EGT', 'ENG'].includes(status)) statusKey = 'EGT';
else if (['NST', 'OFF'].includes(status)) statusKey = 'NST';
// Update counts
groupMap[group].counts.total++;
groupMap[group].counts[statusKey]++;
groupMap[group].families[family].counts.total++;
groupMap[group].families[family].counts[statusKey]++;
});
// Convert to array structure
// Sort groups by sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
// Sort families by total count descending
const hierarchy = Object.values(groupMap).map(g => ({
...g,
families: Object.values(g.families).sort((a, b) => b.counts.total - a.counts.total)
})).sort((a, b) => a.sequence - b.sequence);
return hierarchy;
}
function toggleMatrixRow(rowId) {
matrixHierarchyState[rowId] = !matrixHierarchyState[rowId];
renderMatrixHierarchy();
}
function toggleAllMatrixRows(expand) {
const hierarchy = buildMatrixHierarchy(allEquipment);
hierarchy.forEach(group => {
matrixHierarchyState[`grp_${group.name}`] = expand;
group.families.forEach(fam => {
matrixHierarchyState[`fam_${group.name}_${fam.name}`] = expand;
});
});
renderMatrixHierarchy();
}
function renderMatrixHierarchy() {
const container = document.getElementById('matrixContainer');
const hierarchy = buildMatrixHierarchy(allEquipment);
if (hierarchy.length === 0) {
container.innerHTML = '<div class="empty-state">無資料</div>';
return;
}
let html = `
<table class="matrix-table">
<thead>
<tr>
<th>工站群組 / 型號 / 機台</th>
<th>總數</th>
<th>PRD</th>
<th>SBY</th>
<th>UDT</th>
<th>SDT</th>
<th>EGT</th>
<th>NST</th>
<th>OTHER</th>
<th>OU%</th>
</tr>
</thead>
<tbody>
`;
hierarchy.forEach(group => {
const grpId = `grp_${group.name}`;
const isGroupExpanded = matrixHierarchyState[grpId];
const hasChildren = group.families.length > 0;
// Calculate OU%
const avail = group.counts.PRD + group.counts.SBY + group.counts.UDT + group.counts.SDT + group.counts.EGT;
const ou = avail > 0 ? ((group.counts.PRD / avail) * 100).toFixed(1) : 0;
const ouClass = ou >= 80 ? 'high' : (ou >= 50 ? 'medium' : 'low');
// Group row (Level 0)
const expandBtn = hasChildren
? `<button class="expand-btn ${isGroupExpanded ? 'expanded' : ''}" onclick="toggleMatrixRow('${grpId}')">▶</button>`
: '<span class="expand-placeholder"></span>';
// Helper to check if this cell is selected (supports all levels)
const isSelected = (wg, st, fam = null, res = null) => {
if (!matrixFilter) return false;
if (matrixFilter.workcenter_group !== wg) return false;
if (matrixFilter.status !== st) return false;
if (fam !== null && matrixFilter.family !== fam) return false;
if (res !== null && matrixFilter.resource !== res) return false;
// Match level: if matrixFilter has family but we're checking group level, no match
if (matrixFilter.family && fam === null) return false;
if (matrixFilter.resource && res === null) return false;
return true;
};
const grpName = group.name;
html += `
<tr class="row-level-0">
<td><span class="row-name">${expandBtn}${group.name}</span></td>
<td class="col-total">${group.counts.total}</td>
<td class="col-prd clickable ${group.counts.PRD === 0 ? 'zero' : ''} ${isSelected(grpName, 'PRD') ? 'selected' : ''}" data-wg="${grpName}" data-status="PRD" onclick="filterByMatrixCell('${grpName}', 'PRD')">${group.counts.PRD}</td>
<td class="col-sby clickable ${group.counts.SBY === 0 ? 'zero' : ''} ${isSelected(grpName, 'SBY') ? 'selected' : ''}" data-wg="${grpName}" data-status="SBY" onclick="filterByMatrixCell('${grpName}', 'SBY')">${group.counts.SBY}</td>
<td class="col-udt clickable ${group.counts.UDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'UDT') ? 'selected' : ''}" data-wg="${grpName}" data-status="UDT" onclick="filterByMatrixCell('${grpName}', 'UDT')">${group.counts.UDT}</td>
<td class="col-sdt clickable ${group.counts.SDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'SDT') ? 'selected' : ''}" data-wg="${grpName}" data-status="SDT" onclick="filterByMatrixCell('${grpName}', 'SDT')">${group.counts.SDT}</td>
<td class="col-egt clickable ${group.counts.EGT === 0 ? 'zero' : ''} ${isSelected(grpName, 'EGT') ? 'selected' : ''}" data-wg="${grpName}" data-status="EGT" onclick="filterByMatrixCell('${grpName}', 'EGT')">${group.counts.EGT}</td>
<td class="col-nst clickable ${group.counts.NST === 0 ? 'zero' : ''} ${isSelected(grpName, 'NST') ? 'selected' : ''}" data-wg="${grpName}" data-status="NST" onclick="filterByMatrixCell('${grpName}', 'NST')">${group.counts.NST}</td>
<td class="col-other clickable ${group.counts.OTHER === 0 ? 'zero' : ''} ${isSelected(grpName, 'OTHER') ? 'selected' : ''}" data-wg="${grpName}" data-status="OTHER" onclick="filterByMatrixCell('${grpName}', 'OTHER')">${group.counts.OTHER}</td>
<td><span class="ou-badge ${ouClass}">${ou}%</span></td>
</tr>
`;
// Family rows (Level 1)
if (isGroupExpanded) {
group.families.forEach(fam => {
const famId = `fam_${group.name}_${fam.name}`;
const isFamExpanded = matrixHierarchyState[famId];
const hasEquipment = fam.equipment.length > 0;
const famAvail = fam.counts.PRD + fam.counts.SBY + fam.counts.UDT + fam.counts.SDT + fam.counts.EGT;
const famOu = famAvail > 0 ? ((fam.counts.PRD / famAvail) * 100).toFixed(1) : 0;
const famOuClass = famOu >= 80 ? 'high' : (famOu >= 50 ? 'medium' : 'low');
const famExpandBtn = hasEquipment
? `<button class="expand-btn ${isFamExpanded ? 'expanded' : ''}" onclick="toggleMatrixRow('${famId}')">▶</button>`
: '<span class="expand-placeholder"></span>';
const famName = fam.name;
const escFamName = famName.replace(/'/g, "\\'");
html += `
<tr class="row-level-1 indent-1">
<td><span class="row-name">${famExpandBtn}${fam.name}</span></td>
<td class="col-total">${fam.counts.total}</td>
<td class="col-prd clickable ${fam.counts.PRD === 0 ? 'zero' : ''} ${isSelected(grpName, 'PRD', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="PRD" onclick="filterByMatrixCell('${grpName}', 'PRD', '${escFamName}')">${fam.counts.PRD}</td>
<td class="col-sby clickable ${fam.counts.SBY === 0 ? 'zero' : ''} ${isSelected(grpName, 'SBY', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="SBY" onclick="filterByMatrixCell('${grpName}', 'SBY', '${escFamName}')">${fam.counts.SBY}</td>
<td class="col-udt clickable ${fam.counts.UDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'UDT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="UDT" onclick="filterByMatrixCell('${grpName}', 'UDT', '${escFamName}')">${fam.counts.UDT}</td>
<td class="col-sdt clickable ${fam.counts.SDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'SDT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="SDT" onclick="filterByMatrixCell('${grpName}', 'SDT', '${escFamName}')">${fam.counts.SDT}</td>
<td class="col-egt clickable ${fam.counts.EGT === 0 ? 'zero' : ''} ${isSelected(grpName, 'EGT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="EGT" onclick="filterByMatrixCell('${grpName}', 'EGT', '${escFamName}')">${fam.counts.EGT}</td>
<td class="col-nst clickable ${fam.counts.NST === 0 ? 'zero' : ''} ${isSelected(grpName, 'NST', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="NST" onclick="filterByMatrixCell('${grpName}', 'NST', '${escFamName}')">${fam.counts.NST}</td>
<td class="col-other clickable ${fam.counts.OTHER === 0 ? 'zero' : ''} ${isSelected(grpName, 'OTHER', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="OTHER" onclick="filterByMatrixCell('${grpName}', 'OTHER', '${escFamName}')">${fam.counts.OTHER}</td>
<td><span class="ou-badge ${famOuClass}">${famOu}%</span></td>
</tr>
`;
// Equipment rows (Level 2)
if (isFamExpanded) {
fam.equipment.forEach(eq => {
const status = eq.EQUIPMENTASSETSSTATUS || '--';
const statusCat = (eq.STATUS_CATEGORY || 'OTHER').toLowerCase();
const resId = eq.RESOURCEID || '';
const resName = eq.RESOURCENAME || eq.RESOURCEID || '--';
const escResId = resId.replace(/'/g, "\\'");
// Determine status category key for this equipment
let eqStatusKey = 'OTHER';
if (['PRD'].includes(status)) eqStatusKey = 'PRD';
else if (['SBY'].includes(status)) eqStatusKey = 'SBY';
else if (['UDT', 'PM', 'BKD'].includes(status)) eqStatusKey = 'UDT';
else if (['SDT'].includes(status)) eqStatusKey = 'SDT';
else if (['EGT', 'ENG'].includes(status)) eqStatusKey = 'EGT';
else if (['NST', 'OFF'].includes(status)) eqStatusKey = 'NST';
const isEqSelected = isSelected(grpName, eqStatusKey, famName, resId);
html += `
<tr class="row-level-2 indent-2 clickable-row ${isEqSelected ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-res="${resId}" onclick="filterByMatrixCell('${grpName}', '${eqStatusKey}', '${escFamName}', '${escResId}')">
<td><span class="row-name"><span class="expand-placeholder"></span>${resName}</span></td>
<td>1</td>
<td class="col-prd ${status !== 'PRD' ? 'zero' : ''}">${status === 'PRD' ? '●' : '-'}</td>
<td class="col-sby ${status !== 'SBY' ? 'zero' : ''}">${status === 'SBY' ? '●' : '-'}</td>
<td class="col-udt ${!['UDT', 'PM', 'BKD'].includes(status) ? 'zero' : ''}">${['UDT', 'PM', 'BKD'].includes(status) ? '●' : '-'}</td>
<td class="col-sdt ${status !== 'SDT' ? 'zero' : ''}">${status === 'SDT' ? '●' : '-'}</td>
<td class="col-egt ${!['EGT', 'ENG'].includes(status) ? 'zero' : ''}">${['EGT', 'ENG'].includes(status) ? '●' : '-'}</td>
<td class="col-nst ${!['NST', 'OFF'].includes(status) ? 'zero' : ''}">${['NST', 'OFF'].includes(status) ? '●' : '-'}</td>
<td class="col-other">${!['PRD', 'SBY', 'UDT', 'PM', 'BKD', 'SDT', 'EGT', 'ENG', 'NST', 'OFF'].includes(status) ? '●' : '-'}</td>
<td><span class="eq-status ${statusCat}">${status}</span></td>
</tr>
`;
});
}
});
}
});
html += '</tbody></table>';
container.innerHTML = html;
}
function toggleFilter(checkbox, id) {
const label = document.getElementById(id);
if (checkbox.checked) {
label.classList.add('active');
} else {
label.classList.remove('active');
}
loadData();
}
function getFilters() {
const params = new URLSearchParams();
const group = document.getElementById('filterGroup').value;
if (group) params.append('workcenter_groups', group);
if (document.querySelector('#chkProduction input').checked) {
params.append('is_production', 'true');
}
if (document.querySelector('#chkKey input').checked) {
params.append('is_key', 'true');
}
if (document.querySelector('#chkMonitor input').checked) {
params.append('is_monitor', 'true');
}
return params.toString();
}
async function loadOptions() {
try {
const result = await MesApi.get('/api/resource/status/options', { silent: true });
if (result.success) {
const select = document.getElementById('filterGroup');
workcenterGroups = result.data.workcenter_groups || [];
workcenterGroups.forEach(group => {
const opt = document.createElement('option');
opt.value = group;
opt.textContent = group;
select.appendChild(opt);
});
}
} catch (e) {
console.error('載入選項失敗:', e);
}
}
async function loadSummary() {
try {
const queryString = getFilters();
const endpoint = queryString
? `/api/resource/status/summary?${queryString}`
: '/api/resource/status/summary';
const result = await MesApi.get(endpoint, { silent: true });
if (result.success) {
const d = result.data;
const total = d.total_count || 0;
const status = d.by_status || {};
// Get individual status counts
const prd = status.PRD || 0;
const sby = status.SBY || 0;
const udt = status.UDT || 0;
const sdt = status.SDT || 0;
const egt = status.EGT || 0;
const nst = status.NST || 0;
// Calculate percentage denominator (includes NST)
const totalStatus = prd + sby + udt + sdt + egt + nst;
// Update OU% and AVAIL%
const hasOuPct = d.ou_pct !== null && d.ou_pct !== undefined;
const hasAvailPct = d.availability_pct !== null && d.availability_pct !== undefined;
document.getElementById('ouPct').textContent = hasOuPct ? `${d.ou_pct}%` : '--';
document.getElementById('availabilityPct').textContent = hasAvailPct ? `${d.availability_pct}%` : '--';
// Update status cards with count and percentage
document.getElementById('prdCount').textContent = prd;
document.getElementById('prdPct').textContent = totalStatus ? `生產 (${((prd/totalStatus)*100).toFixed(1)}%)` : '生產';
document.getElementById('sbyCount').textContent = sby;
document.getElementById('sbyPct').textContent = totalStatus ? `待機 (${((sby/totalStatus)*100).toFixed(1)}%)` : '待機';
document.getElementById('udtCount').textContent = udt;
document.getElementById('udtPct').textContent = totalStatus ? `非計畫停機 (${((udt/totalStatus)*100).toFixed(1)}%)` : '非計畫停機';
document.getElementById('sdtCount').textContent = sdt;
document.getElementById('sdtPct').textContent = totalStatus ? `計畫停機 (${((sdt/totalStatus)*100).toFixed(1)}%)` : '計畫停機';
document.getElementById('egtCount').textContent = egt;
document.getElementById('egtPct').textContent = totalStatus ? `工程 (${((egt/totalStatus)*100).toFixed(1)}%)` : '工程';
document.getElementById('nstCount').textContent = nst;
document.getElementById('nstPct').textContent = totalStatus ? `未排程 (${((nst/totalStatus)*100).toFixed(1)}%)` : '未排程';
// Update JOB count (equipment with active maintenance/repair job)
const jobCount = d.with_active_job || 0;
document.getElementById('jobCount').textContent = jobCount;
// Update total count
document.getElementById('totalCount').textContent = total;
}
} catch (e) {
console.error('載入摘要失敗:', e);
}
}
function loadMatrix() {
// Matrix is now rendered from allEquipment data using hierarchy
// This function is called after loadEquipment populates allEquipment
renderMatrixHierarchy();
}
async function loadEquipment() {
const container = document.getElementById('equipmentContainer');
// Clear matrix filter when reloading data
matrixFilter = null;
document.getElementById('matrixFilterIndicator').classList.remove('active');
try {
const queryString = getFilters();
const endpoint = queryString
? `/api/resource/status?${queryString}`
: '/api/resource/status';
const result = await MesApi.get(endpoint, { silent: true });
if (result.success && result.data.length > 0) {
allEquipment = result.data;
document.getElementById('equipmentCount').textContent = result.count;
renderEquipmentList(allEquipment);
} else {
allEquipment = [];
document.getElementById('equipmentCount').textContent = 0;
container.innerHTML = '<div class="empty-state">無符合條件的設備</div>';
}
} catch (e) {
console.error('載入設備失敗:', e);
container.innerHTML = '<div class="empty-state">載入失敗</div>';
}
}
// ============================================================
// Floating Tooltip Functions
// ============================================================
let currentTooltipData = null;
function showTooltip(event, type, data) {
event.stopPropagation();
const tooltip = document.getElementById('floatingTooltip');
const titleEl = document.getElementById('tooltipTitle');
const contentEl = document.getElementById('tooltipContent');
// Set content based on type
if (type === 'lot') {
titleEl.textContent = '在製批次明細';
contentEl.innerHTML = renderLotContent(data);
} else if (type === 'job') {
titleEl.textContent = 'JOB 單詳細資訊';
contentEl.innerHTML = renderJobContent(data);
}
// Position the tooltip
tooltip.classList.add('show');
// Get dimensions
const rect = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Calculate initial position near the click
let x = event.clientX + 10;
let y = event.clientY + 10;
// Adjust if overflowing right
if (x + rect.width > viewportWidth - 20) {
x = event.clientX - rect.width - 10;
}
// Adjust if overflowing bottom
if (y + rect.height > viewportHeight - 20) {
y = viewportHeight - rect.height - 20;
}
// Ensure not off-screen left or top
x = Math.max(10, x);
y = Math.max(10, y);
tooltip.style.left = x + 'px';
tooltip.style.top = y + 'px';
currentTooltipData = { type, data };
}
function hideTooltip() {
const tooltip = document.getElementById('floatingTooltip');
tooltip.classList.remove('show');
currentTooltipData = null;
}
// Close tooltip when clicking outside
document.addEventListener('click', (e) => {
const tooltip = document.getElementById('floatingTooltip');
if (tooltip && !tooltip.contains(e.target) && !e.target.classList.contains('info-trigger')) {
hideTooltip();
}
});
// Helper functions to show specific tooltip types
function showLotTooltip(event, resourceId) {
const eq = allEquipment.find(e => e.RESOURCEID === resourceId);
if (eq && eq.LOT_DETAILS) {
showTooltip(event, 'lot', eq.LOT_DETAILS);
}
}
function showJobTooltip(event, resourceId) {
const eq = allEquipment.find(e => e.RESOURCEID === resourceId);
if (eq && eq.JOBORDER) {
showTooltip(event, 'job', eq);
}
}
function renderLotContent(lotDetails) {
if (!lotDetails || lotDetails.length === 0) return '<div style="color: #94a3b8;">無批次資料</div>';
let html = '<div class="lot-tooltip-content">';
lotDetails.forEach(lot => {
const trackinTime = lot.LOTTRACKINTIME ? new Date(lot.LOTTRACKINTIME).toLocaleString('zh-TW') : '--';
const qty = lot.LOTTRACKINQTY_PCS != null ? lot.LOTTRACKINQTY_PCS.toLocaleString() : '--';
html += `
<div class="lot-item">
<div class="lot-item-header">${lot.RUNCARDLOTID || '--'}</div>
<div class="lot-item-row">
<div class="lot-item-field"><span class="lot-item-label">數量:</span><span class="lot-item-value">${qty} pcs</span></div>
<div class="lot-item-field"><span class="lot-item-label">TrackIn:</span><span class="lot-item-value">${trackinTime}</span></div>
<div class="lot-item-field"><span class="lot-item-label">操作員:</span><span class="lot-item-value">${lot.LOTTRACKINEMPLOYEE || '--'}</span></div>
</div>
</div>
`;
});
html += '</div>';
return html;
}
function renderJobContent(eq) {
const formatDate = (dateStr) => {
if (!dateStr) return '--';
try {
return new Date(dateStr).toLocaleString('zh-TW');
} catch {
return dateStr;
}
};
const field = (label, value, isHighlight = false) => {
const valueClass = isHighlight ? 'highlight' : '';
return `
<div class="job-detail-field">
<span class="job-detail-label">${label}</span>
<span class="job-detail-value ${valueClass}">${value || '--'}</span>
</div>
`;
};
return `
<div class="job-detail-grid">
${field('JOBORDER', eq.JOBORDER, true)}
${field('JOBSTATUS', eq.JOBSTATUS, true)}
${field('JOBMODEL', eq.JOBMODEL)}
${field('JOBSTAGE', eq.JOBSTAGE)}
${field('JOBID', eq.JOBID)}
${field('建立時間', formatDate(eq.CREATEDATE))}
${field('建立人員', eq.CREATEUSERNAME || eq.CREATEUSER)}
${field('技術員', eq.TECHNICIANUSERNAME || eq.TECHNICIANUSER)}
${field('症狀碼', eq.SYMPTOMCODE)}
${field('原因碼', eq.CAUSECODE)}
${field('維修碼', eq.REPAIRCODE)}
</div>
`;
}
function renderEquipmentList(equipment) {
const container = document.getElementById('equipmentContainer');
if (equipment.length === 0) {
container.innerHTML = '<div class="empty-state">無符合條件的設備</div>';
return;
}
let html = '<div class="equipment-grid">';
equipment.forEach((eq) => {
const statusCat = (eq.STATUS_CATEGORY || 'OTHER').toLowerCase();
const statusDisplay = getStatusDisplay(eq.EQUIPMENTASSETSSTATUS, eq.STATUS_CATEGORY);
const resourceId = eq.RESOURCEID || '';
const escapedResourceId = resourceId.replace(/'/g, "\\'");
// Build LOT info with click trigger
let lotHtml = '';
if (eq.LOT_COUNT > 0) {
lotHtml = `<span class="info-trigger" onclick="showLotTooltip(event, '${escapedResourceId}')" title="點擊查看批次詳情">📦 ${eq.LOT_COUNT} 批</span>`;
}
// Build JOB info with click trigger
let jobHtml = '';
if (eq.JOBORDER) {
jobHtml = `<span class="info-trigger" onclick="showJobTooltip(event, '${escapedResourceId}')" title="點擊查看JOB詳情">📋 ${eq.JOBORDER}</span>`;
}
html += `
<div class="equipment-card status-${statusCat}">
<div class="eq-header">
<div class="eq-name">${eq.RESOURCENAME || eq.RESOURCEID || '--'}</div>
<span class="eq-status ${statusCat}">${statusDisplay}</span>
</div>
<div class="eq-info">
<span title="工站">📍 ${eq.WORKCENTERNAME || '--'}</span>
<span title="群組">🏭 ${eq.WORKCENTER_GROUP || '--'}</span>
<span title="家族">🔧 ${eq.RESOURCEFAMILYNAME || '--'}</span>
<span title="區域">🏢 ${eq.LOCATIONNAME || '--'}</span>
${lotHtml}
${jobHtml}
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
function filterByMatrixCell(workcenterGroup, status, family = null, resource = null) {
// Toggle off if clicking same cell (exact match including family and resource)
if (matrixFilter &&
matrixFilter.workcenter_group === workcenterGroup &&
matrixFilter.status === status &&
matrixFilter.family === family &&
matrixFilter.resource === resource) {
clearMatrixFilter();
return;
}
matrixFilter = {
workcenter_group: workcenterGroup,
status: status,
family: family,
resource: resource
};
// Update selected cell highlighting for group and family level cells
document.querySelectorAll('.matrix-table td.clickable').forEach(cell => {
cell.classList.remove('selected');
const cellWg = cell.dataset.wg;
const cellStatus = cell.dataset.status;
const cellFam = cell.dataset.fam;
// Match based on level
if (cellWg === workcenterGroup && cellStatus === status) {
if (family === null && resource === null && !cellFam) {
// Group level match
cell.classList.add('selected');
} else if (family !== null && cellFam === family && resource === null) {
// Family level match
cell.classList.add('selected');
}
}
});
// Update selected row highlighting for equipment level
document.querySelectorAll('.matrix-table tr.clickable-row').forEach(row => {
row.classList.remove('selected');
if (resource !== null && row.dataset.res === resource) {
row.classList.add('selected');
}
});
// Show filter indicator with hierarchical label
const statusLabels = {
'PRD': '生產中',
'SBY': '待機',
'UDT': '非計畫停機',
'SDT': '計畫停機',
'EGT': '工程',
'NST': '未排程',
'OTHER': '其他'
};
let filterLabel = workcenterGroup;
if (family) filterLabel += ` / ${family}`;
if (resource) {
// Find resource name from allEquipment
const eqInfo = allEquipment.find(e => e.RESOURCEID === resource);
const resName = eqInfo ? (eqInfo.RESOURCENAME || resource) : resource;
filterLabel += ` / ${resName}`;
}
filterLabel += ` - ${statusLabels[status] || status}`;
document.getElementById('matrixFilterText').textContent = filterLabel;
document.getElementById('matrixFilterIndicator').classList.add('active');
// Filter and render equipment list
// Use same grouping logic as buildMatrixHierarchy
const filtered = allEquipment.filter(eq => {
// Match workcenter group
const eqGroup = eq.WORKCENTER_GROUP || 'UNKNOWN';
if (eqGroup !== workcenterGroup) return false;
// Match family if specified
if (family !== null) {
const eqFamily = eq.RESOURCEFAMILYNAME || 'UNKNOWN';
if (eqFamily !== family) return false;
}
// Match resource if specified
if (resource !== null) {
if (eq.RESOURCEID !== resource) return false;
}
// Match status based on EQUIPMENTASSETSSTATUS (same logic as matrix calculation)
const eqStatus = eq.EQUIPMENTASSETSSTATUS || '';
// Map equipment status to matrix status category (same as buildMatrixHierarchy)
let eqStatusKey = 'OTHER';
if (['PRD'].includes(eqStatus)) eqStatusKey = 'PRD';
else if (['SBY'].includes(eqStatus)) eqStatusKey = 'SBY';
else if (['UDT', 'PM', 'BKD'].includes(eqStatus)) eqStatusKey = 'UDT';
else if (['SDT'].includes(eqStatus)) eqStatusKey = 'SDT';
else if (['EGT', 'ENG'].includes(eqStatus)) eqStatusKey = 'EGT';
else if (['NST', 'OFF'].includes(eqStatus)) eqStatusKey = 'NST';
return eqStatusKey === status;
});
document.getElementById('equipmentCount').textContent = filtered.length;
renderEquipmentList(filtered);
}
function clearMatrixFilter() {
matrixFilter = null;
// Remove selected highlighting from cells
document.querySelectorAll('.matrix-table td.clickable').forEach(cell => {
cell.classList.remove('selected');
});
// Remove selected highlighting from rows
document.querySelectorAll('.matrix-table tr.clickable-row').forEach(row => {
row.classList.remove('selected');
});
// Hide filter indicator
document.getElementById('matrixFilterIndicator').classList.remove('active');
// Show all equipment
document.getElementById('equipmentCount').textContent = allEquipment.length;
renderEquipmentList(allEquipment);
}
function getStatusDisplay(status, category) {
const statusMap = {
'PRD': '生產中',
'SBY': '待機',
'UDT': '非計畫停機',
'SDT': '計畫停機',
'EGT': '工程',
'NST': '未排程'
};
if (status && statusMap[status]) {
return statusMap[status];
}
const catMap = {
'PRODUCTIVE': '生產中',
'STANDBY': '待機',
'DOWN': '停機',
'ENGINEERING': '工程',
'NOT_SCHEDULED': '未排程',
'INACTIVE': '停用'
};
return catMap[category] || status || '--';
}
async function checkCacheStatus() {
try {
const data = await MesApi.get('/health', {
silent: true,
retries: 0,
timeout: 15000
});
const dot = document.getElementById('cacheDot');
const status = document.getElementById('cacheStatus');
const resCache = data.resource_cache || {};
const eqCache = data.equipment_status_cache || {};
// 使用 resource_cache 的數量(過濾後的設備數)
if (resCache.enabled && resCache.loaded) {
dot.className = 'cache-dot';
status.textContent = `快取正常 (${resCache.count} 筆)`;
} else if (resCache.enabled) {
dot.className = 'cache-dot loading';
status.textContent = '快取載入中...';
} else {
dot.className = 'cache-dot error';
status.textContent = '快取未啟用';
}
// 使用 equipment_status_cache 的更新時間(即時狀態更新時間)
if (eqCache.updated_at) {
document.getElementById('lastUpdate').textContent =
`更新: ${new Date(eqCache.updated_at).toLocaleString('zh-TW')}`;
}
} catch (e) {
document.getElementById('cacheDot').className = 'cache-dot error';
document.getElementById('cacheStatus').textContent = '無法連線';
}
}
async function loadData() {
const btn = document.getElementById('btnRefresh');
btn.disabled = true;
try {
// loadSummary can run in parallel
// loadEquipment must complete before loadMatrix (matrix uses allEquipment data)
await Promise.all([
loadSummary(),
loadEquipment()
]);
// Now render the matrix from the loaded equipment data
loadMatrix();
} finally {
btn.disabled = false;
}
}
// ============================================================
// Auto-refresh
// ============================================================
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
let refreshTimer = null;
function startAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
}
console.log('[Resource Status] Auto-refresh started, interval:', REFRESH_INTERVAL / 1000, 'seconds');
refreshTimer = setInterval(() => {
if (!document.hidden) {
console.log('[Resource Status] Auto-refresh triggered at', new Date().toLocaleTimeString());
checkCacheStatus();
loadData();
} else {
console.log('[Resource Status] Auto-refresh skipped (tab hidden)');
}
}, REFRESH_INTERVAL);
}
// Handle page visibility - refresh when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
console.log('[Resource Status] Tab became visible, refreshing...');
checkCacheStatus();
loadData();
startAutoRefresh();
}
});
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
await loadOptions();
await checkCacheStatus();
await loadData();
// Start auto-refresh
startAutoRefresh();
});
Object.assign(window, {
buildMatrixHierarchy,
toggleMatrixRow,
toggleAllMatrixRows,
renderMatrixHierarchy,
toggleFilter,
getFilters,
loadOptions,
loadSummary,
loadMatrix,
loadEquipment,
showTooltip,
hideTooltip,
showLotTooltip,
showJobTooltip,
renderLotContent,
renderJobContent,
renderEquipmentList,
filterByMatrixCell,
clearMatrixFilter,
getStatusDisplay,
checkCacheStatus,
loadData,
startAutoRefresh,
});

236
frontend/src/tables/main.js Normal file
View File

@@ -0,0 +1,236 @@
import { ensureMesApiAvailable } from '../core/api.js';
import { getPageContract } from '../core/field-contracts.js';
import { buildResourceKpiFromHours } from '../core/compute.js';
import { groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText } from '../core/table-tree.js';
ensureMesApiAvailable();
window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText };
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
window.__FIELD_CONTRACTS__['tables:result_table'] = getPageContract('tables', 'result_table');
let currentTable = null;
let currentDisplayName = null;
let currentTimeField = null;
let currentColumns = [];
let currentFilters = {};
function toFilterInputId(column) {
return `filter_${encodeURIComponent(safeText(column))}`;
}
function toJsSingleQuoted(value) {
return safeText(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
async function loadTableData(tableName, displayName, timeField) {
// Mark current selected table
document.querySelectorAll('.table-card').forEach(card => {
card.classList.remove('active');
});
event.currentTarget.classList.add('active');
currentTable = tableName;
currentDisplayName = displayName;
currentTimeField = timeField || null;
currentFilters = {};
const viewer = document.getElementById('dataViewer');
const title = document.getElementById('viewerTitle');
const content = document.getElementById('tableContent');
const statsContainer = document.getElementById('statsContainer');
viewer.classList.add('active');
title.textContent = `正在載入: ${displayName}`;
content.innerHTML = '<div class="loading">正在載入欄位資訊...</div>';
statsContainer.innerHTML = '';
viewer.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const data = await MesApi.post('/api/get_table_columns', { table_name: tableName });
if (data.error) {
content.innerHTML = `<div class="error">${escapeHtml(data.error)}</div>`;
return;
}
currentColumns = data.columns;
title.textContent = `${displayName} (${currentColumns.length} 欄位)`;
renderFilterControls();
} catch (error) {
content.innerHTML = `<div class="error">請求失敗: ${escapeHtml(error.message)}</div>`;
}
}
function renderFilterControls() {
const statsContainer = document.getElementById('statsContainer');
const content = document.getElementById('tableContent');
statsContainer.innerHTML = `
<div class="stats">
<div class="stat-item">
<div class="stat-label">表名</div>
<div class="stat-value" style="font-size: 14px;">${escapeHtml(currentTable)}</div>
</div>
<div class="stat-item">
<div class="stat-label">欄位數</div>
<div class="stat-value">${currentColumns.length}</div>
</div>
<span class="filter-hint">在下方輸入框填入篩選條件 (模糊匹配)</span>
<button class="query-btn" onclick="executeQuery()">查詢</button>
<button class="clear-btn" onclick="clearFilters()">清除篩選</button>
</div>
<div id="activeFilters" class="active-filters"></div>
`;
let html = '<table><thead>';
html += '<tr>';
currentColumns.forEach(col => {
html += `<th>${escapeHtml(col)}</th>`;
});
html += '</tr>';
html += '<tr class="filter-row">';
currentColumns.forEach(col => {
const filterId = toFilterInputId(col);
const jsCol = toJsSingleQuoted(col);
html += `<th><input type="text" id="${filterId}" placeholder="篩選..." onkeypress="handleFilterKeypress(event)" onchange="updateFilter('${jsCol}', this.value)"></th>`;
});
html += '</tr>';
html += '</thead><tbody id="dataBody">';
html += '<tr><td colspan="' + currentColumns.length + '" style="text-align: center; padding: 40px; color: #666;">請輸入篩選條件後點擊「查詢」,或直接點擊「查詢」載入最後 1000 筆資料</td></tr>';
html += '</tbody></table>';
content.innerHTML = html;
}
function updateFilter(column, value) {
if (value && value.trim()) {
currentFilters[column] = value.trim();
} else {
delete currentFilters[column];
}
renderActiveFilters();
}
function renderActiveFilters() {
const container = document.getElementById('activeFilters');
if (!container) return;
const filterKeys = Object.keys(currentFilters);
if (filterKeys.length === 0) {
container.innerHTML = '';
return;
}
let html = '';
filterKeys.forEach(col => {
html += `<span class="filter-tag">${escapeHtml(col)}: ${escapeHtml(currentFilters[col])} <span class="remove" onclick="removeFilter('${toJsSingleQuoted(col)}')">&times;</span></span>`;
});
container.innerHTML = html;
}
function removeFilter(column) {
delete currentFilters[column];
const input = document.getElementById(toFilterInputId(column));
if (input) input.value = '';
renderActiveFilters();
}
function clearFilters() {
currentFilters = {};
currentColumns.forEach(col => {
const input = document.getElementById(toFilterInputId(col));
if (input) input.value = '';
});
renderActiveFilters();
}
function handleFilterKeypress(event) {
if (event.key === 'Enter') {
executeQuery();
}
}
async function executeQuery() {
const title = document.getElementById('viewerTitle');
const tbody = document.getElementById('dataBody');
currentFilters = {};
currentColumns.forEach(col => {
const input = document.getElementById(toFilterInputId(col));
if (input && input.value.trim()) {
currentFilters[col] = input.value.trim();
}
});
renderActiveFilters();
title.textContent = `正在查詢: ${currentDisplayName}`;
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="loading">正在查詢資料...</td></tr>`;
try {
const data = await MesApi.post('/api/query_table', {
table_name: currentTable,
limit: 1000,
time_field: currentTimeField,
filters: Object.keys(currentFilters).length > 0 ? currentFilters : null
});
if (data.error) {
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="error">${escapeHtml(data.error)}</td></tr>`;
return;
}
const filterCount = Object.keys(currentFilters).length;
const filterText = filterCount > 0 ? ` [${filterCount} 個篩選]` : '';
title.textContent = `${currentDisplayName} (${data.row_count} 筆)${filterText}`;
if (data.data.length === 0) {
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" style="text-align: center; padding: 40px; color: #999;">查無資料</td></tr>`;
return;
}
let html = '';
data.data.forEach(row => {
html += '<tr>';
currentColumns.forEach(col => {
const value = row[col];
if (value === null || value === undefined) {
html += '<td><i style="color: #999;">NULL</i></td>';
} else {
html += `<td>${escapeHtml(safeText(value))}</td>`;
}
});
html += '</tr>';
});
tbody.innerHTML = html;
} catch (error) {
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="error">請求失敗: ${escapeHtml(error.message)}</td></tr>`;
}
}
function closeViewer() {
document.getElementById('dataViewer').classList.remove('active');
document.querySelectorAll('.table-card').forEach(card => {
card.classList.remove('active');
});
currentTable = null;
currentColumns = [];
currentFilters = {};
}
Object.assign(window, {
loadTableData,
renderFilterControls,
updateFilter,
renderActiveFilters,
removeFilter,
clearFilters,
handleFilterKeypress,
executeQuery,
closeViewer,
});

View File

@@ -0,0 +1,844 @@
import { ensureMesApiAvailable } from '../core/api.js';
import {
debounce,
fetchWipAutocompleteItems,
} from '../core/autocomplete.js';
ensureMesApiAvailable();
(function initWipDetailPage() {
// ============================================================
// State Management
// ============================================================
const state = {
workcenter: '',
data: null,
packages: [],
page: 1,
pageSize: 100,
filters: {
package: '',
type: '',
workorder: '',
lotid: ''
},
isLoading: false,
refreshTimer: null,
REFRESH_INTERVAL: 10 * 60 * 1000, // 10 minutes
};
// WIP Status filter (separate from other filters)
let activeStatusFilter = null; // null | 'run' | 'queue' | 'quality-hold' | 'non-quality-hold'
// AbortController for cancelling in-flight requests
let tableAbortController = null; // For loadTableOnly()
let loadAllAbortController = null; // For loadAllData()
// ============================================================
// Utility Functions
// ============================================================
function formatNumber(num) {
if (num === null || num === undefined || num === '-') return '-';
return num.toLocaleString('zh-TW');
}
function updateElementWithTransition(elementId, newValue) {
const el = document.getElementById(elementId);
const oldValue = el.textContent;
const formattedNew = formatNumber(newValue);
if (oldValue !== formattedNew) {
el.textContent = formattedNew;
el.classList.add('updated');
setTimeout(() => el.classList.remove('updated'), 500);
}
}
function getUrlParam(name) {
const params = new URLSearchParams(window.location.search);
return params.get(name) || '';
}
// ============================================================
// API Functions (using MesApi)
// ============================================================
const API_TIMEOUT = 60000; // 60 seconds timeout
async function fetchPackages() {
const result = await MesApi.get('/api/wip/meta/packages', { silent: true, timeout: API_TIMEOUT });
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch packages');
}
async function fetchDetail(signal = null) {
const params = {
page: state.page,
page_size: state.pageSize
};
if (state.filters.package) {
params.package = state.filters.package;
}
if (state.filters.type) {
params.type = state.filters.type;
}
if (activeStatusFilter) {
// Handle hold type filters
if (activeStatusFilter === 'quality-hold') {
params.status = 'HOLD';
params.hold_type = 'quality';
} else if (activeStatusFilter === 'non-quality-hold') {
params.status = 'HOLD';
params.hold_type = 'non-quality';
} else {
// Convert to API status format (RUN/QUEUE)
params.status = activeStatusFilter.toUpperCase();
}
}
if (state.filters.workorder) {
params.workorder = state.filters.workorder;
}
if (state.filters.lotid) {
params.lotid = state.filters.lotid;
}
const result = await MesApi.get(`/api/wip/detail/${encodeURIComponent(state.workcenter)}`, {
params,
timeout: API_TIMEOUT,
signal
});
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch detail');
}
async function fetchWorkcenters() {
const result = await MesApi.get('/api/wip/meta/workcenters', { silent: true, timeout: API_TIMEOUT });
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch workcenters');
}
async function searchAutocompleteItems(type, query) {
return fetchWipAutocompleteItems({
searchType: type,
query,
filters: {
workorder: document.getElementById('filterWorkorder').value,
lotid: document.getElementById('filterLotid').value,
package: document.getElementById('filterPackage').value,
type: document.getElementById('filterType').value,
},
request: (url, options) => MesApi.get(url, options),
});
}
// ============================================================
// Render Functions
// ============================================================
function renderSummary(summary) {
if (!summary) return;
updateElementWithTransition('totalLots', summary.totalLots);
updateElementWithTransition('runLots', summary.runLots);
updateElementWithTransition('queueLots', summary.queueLots);
updateElementWithTransition('qualityHoldLots', summary.qualityHoldLots);
updateElementWithTransition('nonQualityHoldLots', summary.nonQualityHoldLots);
}
function renderTable(data) {
const container = document.getElementById('tableContainer');
if (!data || !data.lots || data.lots.length === 0) {
container.innerHTML = '<div class="placeholder">No data available</div>';
document.getElementById('tableInfo').textContent = 'No data';
document.getElementById('pagination').style.display = 'none';
return;
}
const specs = data.specs || [];
let html = '<table><thead><tr>';
// Fixed columns
html += '<th class="fixed-col">LOT ID</th>';
html += '<th class="fixed-col">Equipment</th>';
html += '<th class="fixed-col">WIP Status</th>';
html += '<th class="fixed-col">Package</th>';
// Spec columns
specs.forEach(spec => {
html += `<th class="spec-col">${spec}</th>`;
});
html += '</tr></thead><tbody>';
data.lots.forEach(lot => {
html += '<tr>';
// Fixed columns - LOT ID is clickable
const lotIdDisplay = lot.lotId
? `<span class="lot-id-link" onclick="showLotDetail('${lot.lotId}')">${lot.lotId}</span>`
: '-';
html += `<td class="fixed-col">${lotIdDisplay}</td>`;
html += `<td class="fixed-col">${lot.equipment || '<span style="color: var(--muted);">-</span>'}</td>`;
// WIP Status with color and hold reason
const statusClass = `wip-status-${(lot.wipStatus || 'queue').toLowerCase()}`;
let statusText = lot.wipStatus || 'QUEUE';
if (lot.wipStatus === 'HOLD' && lot.holdReason) {
statusText = `HOLD (${lot.holdReason})`;
}
html += `<td class="fixed-col ${statusClass}">${statusText}</td>`;
html += `<td class="fixed-col">${lot.package || '-'}</td>`;
// Spec columns - show QTY in matching spec column
specs.forEach(spec => {
if (lot.spec === spec) {
html += `<td class="spec-cell has-data">${formatNumber(lot.qty)}</td>`;
} else {
html += '<td class="spec-cell"></td>';
}
});
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
// Update info
const pagination = data.pagination;
const start = (pagination.page - 1) * pagination.page_size + 1;
const end = Math.min(pagination.page * pagination.page_size, pagination.total_count);
document.getElementById('tableInfo').textContent =
`Showing ${start} - ${end} of ${formatNumber(pagination.total_count)}`;
// Update pagination
if (pagination.total_pages > 1) {
document.getElementById('pagination').style.display = 'flex';
document.getElementById('pageInfo').textContent =
`Page ${pagination.page} / ${pagination.total_pages}`;
document.getElementById('btnPrev').disabled = pagination.page <= 1;
document.getElementById('btnNext').disabled = pagination.page >= pagination.total_pages;
} else {
document.getElementById('pagination').style.display = 'none';
}
// Update last update time
if (data.sys_date) {
document.getElementById('lastUpdate').textContent = `Last Update: ${data.sys_date}`;
}
}
function populatePackageFilter(packages) {
const select = document.getElementById('filterPackage');
const currentValue = select.value;
select.innerHTML = '<option value="">All</option>';
packages.forEach(pkg => {
const option = document.createElement('option');
option.value = pkg.name;
option.textContent = `${pkg.name} (${pkg.lot_count})`;
select.appendChild(option);
});
select.value = currentValue;
}
// ============================================================
// Data Loading
// ============================================================
async function loadAllData(showOverlay = true) {
// Cancel any in-flight request to prevent connection pile-up
if (loadAllAbortController) {
loadAllAbortController.abort();
console.log('[WIP Detail] Previous request cancelled');
}
loadAllAbortController = new AbortController();
const signal = loadAllAbortController.signal;
state.isLoading = true;
if (showOverlay) {
document.getElementById('loadingOverlay').style.display = 'flex';
}
// Show refresh indicator
document.getElementById('refreshIndicator').classList.add('active');
document.getElementById('refreshError').classList.remove('active');
document.getElementById('refreshSuccess').classList.remove('active');
try {
// Load packages for filter (non-blocking - don't fail if this times out)
if (state.packages.length === 0) {
try {
state.packages = await fetchPackages();
populatePackageFilter(state.packages);
} catch (pkgError) {
console.warn('Failed to load packages filter:', pkgError);
}
}
// Load detail data (main data - this is critical)
state.data = await fetchDetail(signal);
renderSummary(state.data.summary);
renderTable(state.data);
// Show success indicator
document.getElementById('refreshSuccess').classList.add('active');
setTimeout(() => {
document.getElementById('refreshSuccess').classList.remove('active');
}, 1500);
} catch (error) {
// Ignore abort errors (expected when user triggers new request)
if (error.name === 'AbortError') {
console.log('[WIP Detail] Request cancelled (new request started)');
return;
}
console.error('Data load failed:', error);
document.getElementById('refreshError').classList.add('active');
} finally {
state.isLoading = false;
document.getElementById('loadingOverlay').style.display = 'none';
document.getElementById('refreshIndicator').classList.remove('active');
}
}
// ============================================================
// Autocomplete Functions
// ============================================================
function showDropdown(dropdownId, items, onSelect) {
const dropdown = document.getElementById(dropdownId);
if (!items || items.length === 0) {
dropdown.innerHTML = '<div class="autocomplete-empty">No results</div>';
dropdown.classList.add('show');
return;
}
dropdown.innerHTML = items.map(item =>
`<div class="autocomplete-item" data-value="${item}">${item}</div>`
).join('');
dropdown.classList.add('show');
dropdown.querySelectorAll('.autocomplete-item').forEach(el => {
el.addEventListener('click', () => {
onSelect(el.dataset.value);
dropdown.classList.remove('show');
});
});
}
function hideDropdown(dropdownId) {
document.getElementById(dropdownId).classList.remove('show');
}
function showLoading(dropdownId) {
const dropdown = document.getElementById(dropdownId);
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
dropdown.classList.add('show');
}
function setupAutocomplete(inputId, dropdownId, searchType) {
const input = document.getElementById(inputId);
const doSearch = debounce(async (query) => {
if (query.length < 2) {
hideDropdown(dropdownId);
return;
}
showLoading(dropdownId);
try {
const items = await searchAutocompleteItems(searchType, query);
showDropdown(dropdownId, items, (value) => {
input.value = value;
});
} catch (e) {
hideDropdown(dropdownId);
}
}, 300);
input.addEventListener('input', (e) => {
doSearch(e.target.value);
});
input.addEventListener('focus', (e) => {
if (e.target.value.length >= 2) {
doSearch(e.target.value);
}
});
// Hide dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest(`#${inputId}`) && !e.target.closest(`#${dropdownId}`)) {
hideDropdown(dropdownId);
}
});
}
// ============================================================
// Status Filter Toggle (Clickable Cards)
// ============================================================
function toggleStatusFilter(status) {
if (activeStatusFilter === status) {
// Clicking the same card again removes the filter
activeStatusFilter = null;
} else {
// Apply new filter
activeStatusFilter = status;
}
// Update card styles
updateCardStyles();
// Update table title
updateTableTitle();
// Reset to page 1 and reload table only (no isLoading guard)
state.page = 1;
loadTableOnly();
}
async function loadTableOnly() {
// Cancel any in-flight request to prevent pile-up
if (tableAbortController) {
tableAbortController.abort();
}
tableAbortController = new AbortController();
// Show loading in table container
const container = document.getElementById('tableContainer');
container.innerHTML = '<div class="placeholder">Loading...</div>';
// Show refresh indicator
document.getElementById('refreshIndicator').classList.add('active');
try {
state.data = await fetchDetail(tableAbortController.signal);
renderSummary(state.data.summary);
renderTable(state.data);
// Show success indicator
document.getElementById('refreshSuccess').classList.add('active');
setTimeout(() => {
document.getElementById('refreshSuccess').classList.remove('active');
}, 1500);
} catch (error) {
// Ignore abort errors (expected when user clicks quickly)
if (error.name === 'AbortError') {
console.log('[WIP Detail] Table request cancelled (new filter selected)');
return;
}
console.error('Table load failed:', error);
container.innerHTML = '<div class="placeholder">Error loading data</div>';
document.getElementById('refreshError').classList.add('active');
} finally {
document.getElementById('refreshIndicator').classList.remove('active');
}
}
function updateCardStyles() {
const row = document.getElementById('summaryRow');
const statusCards = document.querySelectorAll('.summary-card.status-run, .summary-card.status-queue, .summary-card.status-quality-hold, .summary-card.status-non-quality-hold');
// Remove active from all status cards
statusCards.forEach(card => {
card.classList.remove('active');
});
if (activeStatusFilter) {
// Add filtering class to row (dims non-active cards)
row.classList.add('filtering');
// Add active to the selected card
const activeCard = document.querySelector(`.summary-card.status-${activeStatusFilter}`);
if (activeCard) {
activeCard.classList.add('active');
}
} else {
// Remove filtering class
row.classList.remove('filtering');
}
}
function updateTableTitle() {
const titleEl = document.querySelector('.table-title');
const baseTitle = 'Lot Details';
if (activeStatusFilter) {
let statusLabel;
if (activeStatusFilter === 'quality-hold') {
statusLabel = '品質異常 Hold';
} else if (activeStatusFilter === 'non-quality-hold') {
statusLabel = '非品質異常 Hold';
} else {
statusLabel = activeStatusFilter.toUpperCase();
}
titleEl.textContent = `${baseTitle} - ${statusLabel} Only`;
} else {
titleEl.textContent = baseTitle;
}
}
// ============================================================
// Filter & Pagination
// ============================================================
function applyFilters() {
state.filters.workorder = document.getElementById('filterWorkorder').value.trim();
state.filters.lotid = document.getElementById('filterLotid').value.trim();
state.filters.package = document.getElementById('filterPackage').value.trim();
state.filters.type = document.getElementById('filterType').value.trim();
state.page = 1;
loadAllData(false);
}
function clearFilters() {
document.getElementById('filterWorkorder').value = '';
document.getElementById('filterLotid').value = '';
document.getElementById('filterPackage').value = '';
document.getElementById('filterType').value = '';
state.filters = { package: '', type: '', workorder: '', lotid: '' };
// Also clear status filter
activeStatusFilter = null;
updateCardStyles();
updateTableTitle();
state.page = 1;
loadAllData(false);
}
function prevPage() {
if (state.page > 1) {
state.page--;
loadAllData(false);
}
}
function nextPage() {
if (state.data && state.page < state.data.pagination.total_pages) {
state.page++;
loadAllData(false);
}
}
// ============================================================
// Auto-refresh
// ============================================================
function startAutoRefresh() {
if (state.refreshTimer) {
clearInterval(state.refreshTimer);
}
state.refreshTimer = setInterval(() => {
if (!document.hidden) {
loadAllData(false);
}
}, state.REFRESH_INTERVAL);
}
function manualRefresh() {
startAutoRefresh();
loadAllData(false);
}
// ============================================================
// Lot Detail Functions
// ============================================================
let selectedLotId = null;
async function fetchLotDetail(lotId) {
const result = await MesApi.get(`/api/wip/lot/${encodeURIComponent(lotId)}`, {
timeout: API_TIMEOUT
});
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch lot detail');
}
async function showLotDetail(lotId) {
// Update selected state
selectedLotId = lotId;
// Highlight the selected row
document.querySelectorAll('.lot-id-link').forEach(el => {
el.classList.toggle('active', el.textContent === lotId);
});
// Show panel
const panel = document.getElementById('lotDetailPanel');
panel.classList.add('show');
// Update title
document.getElementById('lotDetailLotId').textContent = lotId;
// Show loading
document.getElementById('lotDetailContent').innerHTML = `
<div class="lot-detail-loading">
<span class="loading-spinner"></span>Loading...
</div>
`;
// Scroll to panel
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const data = await fetchLotDetail(lotId);
renderLotDetail(data);
} catch (error) {
console.error('Failed to load lot detail:', error);
document.getElementById('lotDetailContent').innerHTML = `
<div class="lot-detail-loading" style="color: var(--danger);">
載入失敗:${error.message || '未知錯誤'}
</div>
`;
}
}
function renderLotDetail(data) {
const labels = data.fieldLabels || {};
// Helper to format value
const formatValue = (value) => {
if (value === null || value === undefined || value === '') {
return '<span class="empty">-</span>';
}
if (typeof value === 'number') {
return formatNumber(value);
}
return value;
};
// Helper to create field HTML
const field = (key, customLabel = null) => {
const label = customLabel || labels[key] || key;
const value = data[key];
let valueClass = '';
// Special styling for WIP Status
if (key === 'wipStatus') {
valueClass = `status-${(value || '').toLowerCase()}`;
}
return `
<div class="lot-detail-field">
<span class="lot-detail-label">${label}</span>
<span class="lot-detail-value ${valueClass}">${formatValue(value)}</span>
</div>
`;
};
const html = `
<div class="lot-detail-grid">
<!-- Basic Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">基本資訊</div>
${field('lotId')}
${field('workorder')}
${field('wipStatus')}
${field('status')}
${field('qty')}
${field('qty2')}
${field('ageByDays')}
${field('priority')}
</div>
<!-- Product Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">產品資訊</div>
${field('product')}
${field('productLine')}
${field('packageLef')}
${field('pjType')}
${field('pjFunction')}
${field('bop')}
${field('dateCode')}
${field('produceRegion')}
</div>
<!-- Process Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">製程資訊</div>
${field('workcenterGroup')}
${field('workcenter')}
${field('spec')}
${field('specSequence')}
${field('workflow')}
${field('equipment')}
${field('equipmentCount')}
${field('location')}
</div>
<!-- Material Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">物料資訊</div>
${field('waferLotId')}
${field('waferPn')}
${field('waferLotPrefix')}
${field('leadframeName')}
${field('leadframeOption')}
${field('compoundName')}
${field('dieConsumption')}
${field('uts')}
</div>
<!-- Hold Info (if HOLD status) -->
${data.wipStatus === 'HOLD' || data.holdCount > 0 ? `
<div class="lot-detail-section">
<div class="lot-detail-section-title">Hold 資訊</div>
${field('holdReason')}
${field('holdCount')}
${field('holdEmp')}
${field('holdDept')}
${field('holdComment')}
${field('releaseTime')}
${field('releaseEmp')}
${field('releaseComment')}
</div>
` : ''}
<!-- NCR Info (if exists) -->
${data.ncrId ? `
<div class="lot-detail-section">
<div class="lot-detail-section-title">NCR 資訊</div>
${field('ncrId')}
${field('ncrDate')}
</div>
` : ''}
<!-- Comments -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">備註資訊</div>
${field('comment')}
${field('commentDate')}
${field('commentEmp')}
${field('futureHoldComment')}
</div>
<!-- Other Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">其他資訊</div>
${field('owner')}
${field('startDate')}
${field('tmttRemaining')}
${field('dataUpdateDate')}
</div>
</div>
`;
document.getElementById('lotDetailContent').innerHTML = html;
}
function closeLotDetail() {
const panel = document.getElementById('lotDetailPanel');
panel.classList.remove('show');
// Remove highlight from selected row
document.querySelectorAll('.lot-id-link').forEach(el => {
el.classList.remove('active');
});
selectedLotId = null;
}
// ============================================================
// Initialize
// ============================================================
async function init() {
// Setup autocomplete for WORKORDER, LOT ID, PACKAGE, and TYPE
setupAutocomplete('filterWorkorder', 'workorderDropdown', 'workorder');
setupAutocomplete('filterLotid', 'lotidDropdown', 'lotid');
setupAutocomplete('filterPackage', 'packageDropdown', 'package');
setupAutocomplete('filterType', 'typeDropdown', 'type');
// Allow Enter key to trigger filter
document.getElementById('filterWorkorder').addEventListener('keypress', (e) => {
if (e.key === 'Enter') applyFilters();
});
document.getElementById('filterLotid').addEventListener('keypress', (e) => {
if (e.key === 'Enter') applyFilters();
});
document.getElementById('filterPackage').addEventListener('keypress', (e) => {
if (e.key === 'Enter') applyFilters();
});
document.getElementById('filterType').addEventListener('keypress', (e) => {
if (e.key === 'Enter') applyFilters();
});
// Get workcenter from URL or use first available
state.workcenter = getUrlParam('workcenter');
// Get filters from URL params (passed from wip_overview)
const urlWorkorder = getUrlParam('workorder');
const urlLotid = getUrlParam('lotid');
const urlPackage = getUrlParam('package');
const urlType = getUrlParam('type');
if (urlWorkorder) {
state.filters.workorder = urlWorkorder;
document.getElementById('filterWorkorder').value = urlWorkorder;
}
if (urlLotid) {
state.filters.lotid = urlLotid;
document.getElementById('filterLotid').value = urlLotid;
}
if (urlPackage) {
state.filters.package = urlPackage;
document.getElementById('filterPackage').value = urlPackage;
}
if (urlType) {
state.filters.type = urlType;
document.getElementById('filterType').value = urlType;
}
if (!state.workcenter) {
// Fetch workcenters and use first one
try {
const workcenters = await fetchWorkcenters();
if (workcenters && workcenters.length > 0) {
state.workcenter = workcenters[0].name;
// Update URL without reload
window.history.replaceState({}, '', `/wip-detail?workcenter=${encodeURIComponent(state.workcenter)}`);
}
} catch (error) {
console.error('Failed to fetch workcenters:', error);
}
}
if (state.workcenter) {
document.getElementById('pageTitle').textContent = `WIP Detail - ${state.workcenter}`;
loadAllData(true);
startAutoRefresh();
// Handle page visibility (must be after workcenter is set)
document.addEventListener('visibilitychange', () => {
if (!document.hidden && state.workcenter) {
loadAllData(false);
startAutoRefresh();
}
});
} else {
document.getElementById('tableContainer').innerHTML =
'<div class="placeholder">No workcenter available</div>';
document.getElementById('loadingOverlay').style.display = 'none';
}
}
window.onload = init;
Object.assign(window, {
applyFilters,
clearFilters,
toggleStatusFilter,
prevPage,
nextPage,
manualRefresh,
showLotDetail,
closeLotDetail,
init
});
})();

View File

@@ -0,0 +1,829 @@
import { ensureMesApiAvailable } from '../core/api.js';
import {
debounce,
fetchWipAutocompleteItems,
} from '../core/autocomplete.js';
ensureMesApiAvailable();
(function initWipOverviewPage() {
// ============================================================
// State Management
// ============================================================
const state = {
summary: null,
matrix: null,
hold: null,
isLoading: false,
lastError: false,
refreshTimer: null,
REFRESH_INTERVAL: 10 * 60 * 1000, // 10 minutes
filters: {
workorder: '',
lotid: '',
package: '',
type: ''
}
};
// Status filter state (null = no filter, 'run'/'queue'/'hold' = filtered)
let activeStatusFilter = null;
// AbortController for cancelling in-flight requests
let matrixAbortController = null; // For loadMatrixOnly()
let loadAllAbortController = null; // For loadAllData()
// ============================================================
// Utility Functions
// ============================================================
function formatNumber(num) {
if (num === null || num === undefined || num === '-') return '-';
return num.toLocaleString('zh-TW');
}
function updateElementWithTransition(elementId, newValue) {
const el = document.getElementById(elementId);
const oldValue = el.textContent;
let formattedNew;
if (typeof newValue === 'number') {
formattedNew = formatNumber(newValue);
} else if (newValue === null || newValue === undefined) {
formattedNew = '-';
} else {
formattedNew = newValue;
}
if (oldValue !== formattedNew) {
el.textContent = formattedNew;
el.classList.add('updated');
setTimeout(() => el.classList.remove('updated'), 500);
}
}
function buildQueryParams() {
const params = {};
if (state.filters.workorder) {
params.workorder = state.filters.workorder;
}
if (state.filters.lotid) {
params.lotid = state.filters.lotid;
}
if (state.filters.package) {
params.package = state.filters.package;
}
if (state.filters.type) {
params.type = state.filters.type;
}
return params;
}
// ============================================================
// API Functions (using MesApi)
// ============================================================
const API_TIMEOUT = 60000; // 60 seconds timeout
async function fetchSummary(signal = null) {
const params = buildQueryParams();
const result = await MesApi.get('/api/wip/overview/summary', {
params,
timeout: API_TIMEOUT,
signal
});
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch summary');
}
async function fetchMatrix(signal = null) {
const params = buildQueryParams();
// Add status filter if active
if (activeStatusFilter) {
if (activeStatusFilter === 'quality-hold') {
params.status = 'HOLD';
params.hold_type = 'quality';
} else if (activeStatusFilter === 'non-quality-hold') {
params.status = 'HOLD';
params.hold_type = 'non-quality';
} else {
params.status = activeStatusFilter.toUpperCase();
}
}
const result = await MesApi.get('/api/wip/overview/matrix', {
params,
timeout: API_TIMEOUT,
signal
});
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch matrix');
}
async function fetchHold(signal = null) {
const params = buildQueryParams();
const result = await MesApi.get('/api/wip/overview/hold', {
params,
timeout: API_TIMEOUT,
signal
});
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch hold');
}
// ============================================================
// Autocomplete Functions
// ============================================================
async function searchAutocomplete(type, query) {
const loadingEl = document.getElementById(`${type}Loading`);
loadingEl.classList.add('active');
try {
return await fetchWipAutocompleteItems({
searchType: type,
query,
filters: {
workorder: document.getElementById('filterWorkorder').value,
lotid: document.getElementById('filterLotid').value,
package: document.getElementById('filterPackage').value,
type: document.getElementById('filterType').value,
},
request: (url, options) => MesApi.get(url, options),
});
} catch (error) {
console.error(`Search ${type} failed:`, error);
} finally {
loadingEl.classList.remove('active');
}
return [];
}
function showDropdown(type, items) {
const dropdown = document.getElementById(`${type}Dropdown`);
if (items.length === 0) {
dropdown.innerHTML = '<div class="autocomplete-item no-results">無符合結果</div>';
} else {
dropdown.innerHTML = items.map(item =>
`<div class="autocomplete-item" onclick="selectAutocomplete('${type}', '${item}')">${item}</div>`
).join('');
}
dropdown.classList.add('active');
}
function hideDropdown(type) {
const dropdown = document.getElementById(`${type}Dropdown`);
dropdown.classList.remove('active');
}
function selectAutocomplete(type, value) {
const input = document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`);
input.value = value;
hideDropdown(type);
}
// Setup autocomplete for inputs
function setupAutocomplete(type) {
const input = document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`);
const debouncedSearch = debounce(async (query) => {
if (query.length >= 2) {
const items = await searchAutocomplete(type, query);
showDropdown(type, items);
} else {
hideDropdown(type);
}
}, 300);
input.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
input.addEventListener('focus', async () => {
const query = input.value;
if (query.length >= 2) {
const items = await searchAutocomplete(type, query);
showDropdown(type, items);
}
});
input.addEventListener('blur', () => {
// Delay hide to allow click on dropdown
setTimeout(() => hideDropdown(type), 200);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
hideDropdown(type);
applyFilters();
}
});
}
// ============================================================
// Filter Functions
// ============================================================
function applyFilters() {
state.filters.workorder = document.getElementById('filterWorkorder').value.trim();
state.filters.lotid = document.getElementById('filterLotid').value.trim();
state.filters.package = document.getElementById('filterPackage').value.trim();
state.filters.type = document.getElementById('filterType').value.trim();
updateActiveFiltersDisplay();
loadAllData(false);
}
function clearFilters() {
document.getElementById('filterWorkorder').value = '';
document.getElementById('filterLotid').value = '';
document.getElementById('filterPackage').value = '';
document.getElementById('filterType').value = '';
state.filters.workorder = '';
state.filters.lotid = '';
state.filters.package = '';
state.filters.type = '';
updateActiveFiltersDisplay();
loadAllData(false);
}
function removeFilter(type) {
document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`).value = '';
state.filters[type] = '';
updateActiveFiltersDisplay();
loadAllData(false);
}
function updateActiveFiltersDisplay() {
const container = document.getElementById('activeFilters');
let html = '';
if (state.filters.workorder) {
html += `<span class="filter-tag">WO: ${state.filters.workorder} <span class="remove" onclick="removeFilter('workorder')">×</span></span>`;
}
if (state.filters.lotid) {
html += `<span class="filter-tag">Lot: ${state.filters.lotid} <span class="remove" onclick="removeFilter('lotid')">×</span></span>`;
}
if (state.filters.package) {
html += `<span class="filter-tag">Pkg: ${state.filters.package} <span class="remove" onclick="removeFilter('package')">×</span></span>`;
}
if (state.filters.type) {
html += `<span class="filter-tag">Type: ${state.filters.type} <span class="remove" onclick="removeFilter('type')">×</span></span>`;
}
container.innerHTML = html;
}
// ============================================================
// Render Functions
// ============================================================
function renderSummary(data) {
if (!data) return;
updateElementWithTransition('totalLots', data.totalLots);
updateElementWithTransition('totalQty', data.totalQtyPcs);
const ws = data.byWipStatus || {};
const runLots = ws.run?.lots;
const runQty = ws.run?.qtyPcs;
const queueLots = ws.queue?.lots;
const queueQty = ws.queue?.qtyPcs;
const qualityHoldLots = ws.qualityHold?.lots;
const qualityHoldQty = ws.qualityHold?.qtyPcs;
const nonQualityHoldLots = ws.nonQualityHold?.lots;
const nonQualityHoldQty = ws.nonQualityHold?.qtyPcs;
updateElementWithTransition(
'runLots',
runLots === null || runLots === undefined ? '-' : `${formatNumber(runLots)} lots`
);
updateElementWithTransition(
'runQty',
runQty === null || runQty === undefined ? '-' : formatNumber(runQty)
);
updateElementWithTransition(
'queueLots',
queueLots === null || queueLots === undefined ? '-' : `${formatNumber(queueLots)} lots`
);
updateElementWithTransition(
'queueQty',
queueQty === null || queueQty === undefined ? '-' : formatNumber(queueQty)
);
updateElementWithTransition(
'qualityHoldLots',
qualityHoldLots === null || qualityHoldLots === undefined ? '-' : `${formatNumber(qualityHoldLots)} lots`
);
updateElementWithTransition(
'qualityHoldQty',
qualityHoldQty === null || qualityHoldQty === undefined ? '-' : formatNumber(qualityHoldQty)
);
updateElementWithTransition(
'nonQualityHoldLots',
nonQualityHoldLots === null || nonQualityHoldLots === undefined ? '-' : `${formatNumber(nonQualityHoldLots)} lots`
);
updateElementWithTransition(
'nonQualityHoldQty',
nonQualityHoldQty === null || nonQualityHoldQty === undefined ? '-' : formatNumber(nonQualityHoldQty)
);
if (data.dataUpdateDate) {
document.getElementById('lastUpdate').textContent = `Last Update: ${data.dataUpdateDate}`;
}
}
// ============================================================
// Status Filter Functions
// ============================================================
function toggleStatusFilter(status) {
if (activeStatusFilter === status) {
// Deactivate filter
activeStatusFilter = null;
} else {
// Activate new filter
activeStatusFilter = status;
}
updateCardStyles();
updateMatrixTitle();
loadMatrixOnly();
}
function updateCardStyles() {
const row = document.querySelector('.wip-status-row');
document.querySelectorAll('.wip-status-card').forEach(card => {
card.classList.remove('active');
});
if (activeStatusFilter) {
row.classList.add('filtering');
const activeCard = document.querySelector(`.wip-status-card.${activeStatusFilter}`);
if (activeCard) {
activeCard.classList.add('active');
}
} else {
row.classList.remove('filtering');
}
}
function updateMatrixTitle() {
const titleEl = document.querySelector('.card-title');
if (!titleEl) return;
const baseTitle = 'Workcenter x Package Matrix (QTY)';
if (activeStatusFilter) {
let statusLabel;
if (activeStatusFilter === 'quality-hold') {
statusLabel = '品質異常 Hold';
} else if (activeStatusFilter === 'non-quality-hold') {
statusLabel = '非品質異常 Hold';
} else {
statusLabel = activeStatusFilter.toUpperCase();
}
titleEl.textContent = `${baseTitle} - ${statusLabel} Only`;
} else {
titleEl.textContent = baseTitle;
}
}
async function loadMatrixOnly() {
// Cancel any in-flight matrix request to prevent pile-up
if (matrixAbortController) {
matrixAbortController.abort();
}
matrixAbortController = new AbortController();
const container = document.getElementById('matrixContainer');
container.innerHTML = '<div class="placeholder">Loading...</div>';
try {
const matrix = await fetchMatrix(matrixAbortController.signal);
state.matrix = matrix;
renderMatrix(matrix);
} catch (error) {
// Ignore abort errors (expected when user clicks quickly)
if (error.name === 'AbortError') {
console.log('[WIP Overview] Matrix request cancelled (new filter selected)');
return;
}
console.error('[WIP Overview] Matrix load failed:', error);
container.innerHTML = '<div class="placeholder">Error loading data</div>';
}
}
function renderMatrix(data) {
const container = document.getElementById('matrixContainer');
if (!data || !data.workcenters || data.workcenters.length === 0) {
container.innerHTML = '<div class="placeholder">No data available</div>';
return;
}
// Limit packages to top 15 for display
const displayPackages = data.packages.slice(0, 15);
let html = '<table class="matrix-table"><thead><tr>';
html += '<th>Workcenter</th>';
displayPackages.forEach(pkg => {
html += `<th>${pkg}</th>`;
});
html += '<th class="total-col">Total</th>';
html += '</tr></thead><tbody>';
// Data rows
data.workcenters.forEach(wc => {
html += '<tr>';
html += `<td class="clickable" onclick="navigateToDetail('${wc.replace(/'/g, "\\'")}')">${wc}</td>`;
displayPackages.forEach(pkg => {
const qty = data.matrix[wc]?.[pkg] || 0;
html += `<td>${qty ? formatNumber(qty) : '-'}</td>`;
});
html += `<td class="total-col">${formatNumber(data.workcenter_totals[wc] || 0)}</td>`;
html += '</tr>';
});
// Total row
html += '<tr class="total-row">';
html += '<td>Total</td>';
displayPackages.forEach(pkg => {
html += `<td>${formatNumber(data.package_totals[pkg] || 0)}</td>`;
});
html += `<td class="total-col">${formatNumber(data.grand_total || 0)}</td>`;
html += '</tr>';
html += '</tbody></table>';
container.innerHTML = html;
}
// ============================================================
// Pareto Chart Functions
// ============================================================
let paretoCharts = {
quality: null,
nonQuality: null
};
// Task 2.1: Split hold data by type
function splitHoldByType(data) {
if (!data || !data.items) {
return { quality: [], nonQuality: [] };
}
const quality = data.items.filter(item => item.holdType === 'quality');
const nonQuality = data.items.filter(item => item.holdType !== 'quality');
return { quality, nonQuality };
}
// Task 2.2: Prepare Pareto data (sort by QTY desc, calculate cumulative %)
function prepareParetoData(items) {
if (!items || items.length === 0) {
return { reasons: [], qtys: [], lots: [], cumulative: [], totalQty: 0 };
}
// Sort by QTY descending
const sorted = [...items].sort((a, b) => (b.qty || 0) - (a.qty || 0));
const reasons = sorted.map(item => item.reason || '未知');
const qtys = sorted.map(item => item.qty || 0);
const lots = sorted.map(item => item.lots || 0);
const totalQty = qtys.reduce((sum, q) => sum + q, 0);
// Calculate cumulative percentage
const cumulative = [];
let runningSum = 0;
qtys.forEach(qty => {
runningSum += qty;
cumulative.push(totalQty > 0 ? Math.round((runningSum / totalQty) * 100) : 0);
});
return { reasons, qtys, lots, cumulative, totalQty, items: sorted };
}
// Task 3.1: Initialize Pareto charts
function initParetoCharts() {
const qualityEl = document.getElementById('qualityParetoChart');
const nonQualityEl = document.getElementById('nonQualityParetoChart');
if (qualityEl && !paretoCharts.quality) {
paretoCharts.quality = echarts.init(qualityEl);
}
if (nonQualityEl && !paretoCharts.nonQuality) {
paretoCharts.nonQuality = echarts.init(nonQualityEl);
}
}
// Task 3.2: Render Pareto chart with ECharts
function renderParetoChart(chart, paretoData, colorTheme) {
if (!chart) return;
const barColor = colorTheme === 'quality' ? '#ef4444' : '#f97316';
const lineColor = colorTheme === 'quality' ? '#991B1B' : '#9A3412';
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function(params) {
const reason = params[0].name;
const qty = params[0].value;
const cumPct = params[1] ? params[1].value : 0;
return `<strong>${reason}</strong><br/>QTY: ${formatNumber(qty)}<br/>累計: ${cumPct}%`;
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: paretoData.reasons,
axisLabel: {
rotate: 30,
interval: 0,
fontSize: 10,
formatter: function(value) {
return value.length > 12 ? value.slice(0, 12) + '...' : value;
}
},
axisTick: { alignWithLabel: true }
},
yAxis: [
{
type: 'value',
name: 'QTY',
position: 'left',
axisLabel: {
formatter: function(val) {
return val >= 1000 ? (val / 1000).toFixed(0) + 'k' : val;
}
}
},
{
type: 'value',
name: '累計%',
position: 'right',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%' }
}
],
series: [
{
name: 'QTY',
type: 'bar',
data: paretoData.qtys,
itemStyle: { color: barColor },
emphasis: {
itemStyle: { color: barColor, opacity: 0.8 }
}
},
{
name: '累計%',
type: 'line',
yAxisIndex: 1,
data: paretoData.cumulative,
symbol: 'circle',
symbolSize: 6,
lineStyle: { color: lineColor, width: 2 },
itemStyle: { color: lineColor }
}
]
};
chart.setOption(option);
// Task 3.3: Add click event for drill-down
chart.off('click'); // Remove existing handlers
chart.on('click', function(params) {
if (params.componentType === 'series' && params.seriesType === 'bar') {
const reason = paretoData.reasons[params.dataIndex];
if (reason && reason !== '未知') {
window.location.href = `/hold-detail?reason=${encodeURIComponent(reason)}`;
}
}
});
}
// Task 4.1 & 4.2: Render Pareto table with drill-down links
function renderParetoTable(containerId, paretoData) {
const container = document.getElementById(containerId);
if (!container) return;
if (!paretoData.items || paretoData.items.length === 0) {
container.innerHTML = '';
return;
}
let html = '<table class="pareto-table"><thead><tr>';
html += '<th>Hold Reason</th>';
html += '<th>Lots</th>';
html += '<th>QTY</th>';
html += '<th>累計%</th>';
html += '</tr></thead><tbody>';
paretoData.items.forEach((item, idx) => {
const reason = item.reason || '未知';
const reasonLink = item.reason
? `<a href="/hold-detail?reason=${encodeURIComponent(item.reason)}" class="reason-link">${reason}</a>`
: reason;
html += '<tr>';
html += `<td>${reasonLink}</td>`;
html += `<td>${formatNumber(item.lots)}</td>`;
html += `<td>${formatNumber(item.qty)}</td>`;
html += `<td class="cumulative">${paretoData.cumulative[idx]}%</td>`;
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
}
// Task 3.4: Handle no data state
function showParetoNoData(type, show) {
const chartEl = document.getElementById(`${type}ParetoChart`);
const noDataEl = document.getElementById(`${type}ParetoNoData`);
if (chartEl) chartEl.style.display = show ? 'none' : 'block';
if (noDataEl) noDataEl.style.display = show ? 'flex' : 'none';
}
// Main render function for Hold data
function renderHold(data) {
initParetoCharts();
const { quality, nonQuality } = splitHoldByType(data);
const qualityData = prepareParetoData(quality);
const nonQualityData = prepareParetoData(nonQuality);
// Update counts in header
document.getElementById('qualityHoldCount').textContent = `${quality.length}`;
document.getElementById('nonQualityHoldCount').textContent = `${nonQuality.length}`;
// Quality Pareto
if (quality.length > 0) {
showParetoNoData('quality', false);
renderParetoChart(paretoCharts.quality, qualityData, 'quality');
renderParetoTable('qualityParetoTable', qualityData);
} else {
showParetoNoData('quality', true);
if (paretoCharts.quality) paretoCharts.quality.clear();
document.getElementById('qualityParetoTable').innerHTML = '';
}
// Non-Quality Pareto
if (nonQuality.length > 0) {
showParetoNoData('nonQuality', false);
renderParetoChart(paretoCharts.nonQuality, nonQualityData, 'non-quality');
renderParetoTable('nonQualityParetoTable', nonQualityData);
} else {
showParetoNoData('nonQuality', true);
if (paretoCharts.nonQuality) paretoCharts.nonQuality.clear();
document.getElementById('nonQualityParetoTable').innerHTML = '';
}
}
// Task 5.3: Window resize handler for charts
window.addEventListener('resize', function() {
if (paretoCharts.quality) paretoCharts.quality.resize();
if (paretoCharts.nonQuality) paretoCharts.nonQuality.resize();
});
// ============================================================
// Navigation
// ============================================================
function navigateToDetail(workcenter) {
const params = new URLSearchParams();
params.append('workcenter', workcenter);
if (state.filters.workorder) params.append('workorder', state.filters.workorder);
if (state.filters.lotid) params.append('lotid', state.filters.lotid);
if (state.filters.package) params.append('package', state.filters.package);
if (state.filters.type) params.append('type', state.filters.type);
window.location.href = `/wip-detail?${params.toString()}`;
}
// ============================================================
// Data Loading
// ============================================================
async function loadAllData(showOverlay = true) {
// Cancel any in-flight request to prevent connection pile-up
if (loadAllAbortController) {
loadAllAbortController.abort();
console.log('[WIP Overview] Previous request cancelled');
}
loadAllAbortController = new AbortController();
const signal = loadAllAbortController.signal;
state.isLoading = true;
console.log('[WIP Overview] Loading data...', showOverlay ? '(with overlay)' : '(background)');
if (showOverlay) {
document.getElementById('loadingOverlay').style.display = 'flex';
}
// Show refresh indicator
document.getElementById('refreshIndicator').classList.add('active');
document.getElementById('refreshError').classList.remove('active');
document.getElementById('refreshSuccess').classList.remove('active');
try {
const startTime = performance.now();
const [summary, matrix, hold] = await Promise.all([
fetchSummary(signal),
fetchMatrix(signal),
fetchHold(signal)
]);
const elapsed = Math.round(performance.now() - startTime);
state.summary = summary;
state.matrix = matrix;
state.hold = hold;
state.lastError = false;
renderSummary(summary);
renderMatrix(matrix);
renderHold(hold);
console.log(`[WIP Overview] Data loaded successfully in ${elapsed}ms`);
// Show success indicator
document.getElementById('refreshSuccess').classList.add('active');
setTimeout(() => {
document.getElementById('refreshSuccess').classList.remove('active');
}, 1500);
} catch (error) {
// Ignore abort errors (expected when user triggers new request)
if (error.name === 'AbortError') {
console.log('[WIP Overview] Request cancelled (new request started)');
return;
}
console.error('[WIP Overview] Data load failed:', error);
state.lastError = true;
document.getElementById('refreshError').classList.add('active');
} finally {
state.isLoading = false;
document.getElementById('loadingOverlay').style.display = 'none';
document.getElementById('refreshIndicator').classList.remove('active');
}
}
// ============================================================
// Auto-refresh
// ============================================================
function startAutoRefresh() {
if (state.refreshTimer) {
clearInterval(state.refreshTimer);
}
console.log('[WIP Overview] Auto-refresh started, interval:', state.REFRESH_INTERVAL / 1000, 'seconds');
state.refreshTimer = setInterval(() => {
if (!document.hidden) {
console.log('[WIP Overview] Auto-refresh triggered at', new Date().toLocaleTimeString());
loadAllData(false); // Don't show overlay for auto-refresh
} else {
console.log('[WIP Overview] Auto-refresh skipped (tab hidden)');
}
}, state.REFRESH_INTERVAL);
}
function manualRefresh() {
// Reset timer on manual refresh
startAutoRefresh();
loadAllData(false);
}
// Handle page visibility
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
// Page became visible - refresh immediately
loadAllData(false);
startAutoRefresh();
}
});
// ============================================================
// Initialize
// ============================================================
window.onload = function() {
setupAutocomplete('workorder');
setupAutocomplete('lotid');
setupAutocomplete('package');
setupAutocomplete('type');
loadAllData(true);
startAutoRefresh();
};
Object.assign(window, {
applyFilters,
clearFilters,
toggleStatusFilter,
selectAutocomplete,
removeFilter,
navigateToDetail,
manualRefresh,
loadAllData,
startAutoRefresh
});
})();

View File

@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildWipAutocompleteParams,
fetchWipAutocompleteItems,
} from '../src/core/autocomplete.js';
test('buildWipAutocompleteParams keeps cross-filters except active field', () => {
const params = buildWipAutocompleteParams('lotid', 'L123', {
workorder: 'WO1',
lotid: 'L999',
package: 'PKG-A',
type: 'QFN'
});
assert.equal(params.field, 'lotid');
assert.equal(params.q, 'L123');
assert.equal(params.workorder, 'WO1');
assert.equal(params.package, 'PKG-A');
assert.equal(params.type, 'QFN');
assert.equal(Object.prototype.hasOwnProperty.call(params, 'lotid'), false);
});
test('buildWipAutocompleteParams returns null for short query', () => {
const params = buildWipAutocompleteParams('workorder', 'a', {});
assert.equal(params, null);
});
test('fetchWipAutocompleteItems maps successful API response', async () => {
const items = await fetchWipAutocompleteItems({
searchType: 'workorder',
query: 'WO',
filters: {},
request: async () => ({
success: true,
data: {
items: ['WO1', 'WO2']
}
})
});
assert.deepEqual(items, ['WO1', 'WO2']);
});
test('fetchWipAutocompleteItems swallows API errors and returns empty list', async () => {
const items = await fetchWipAutocompleteItems({
searchType: 'workorder',
query: 'WO',
filters: {},
request: async () => {
throw new Error('network down');
}
});
assert.deepEqual(items, []);
});

29
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
export default defineConfig({
publicDir: false,
build: {
outDir: '../src/mes_dashboard/static/dist',
emptyOutDir: false,
sourcemap: false,
rollupOptions: {
input: {
portal: resolve(__dirname, 'src/portal/main.js'),
'wip-overview': resolve(__dirname, 'src/wip-overview/main.js'),
'wip-detail': resolve(__dirname, 'src/wip-detail/main.js'),
'hold-detail': resolve(__dirname, 'src/hold-detail/main.js'),
'resource-status': resolve(__dirname, 'src/resource-status/main.js'),
'resource-history': resolve(__dirname, 'src/resource-history/main.js'),
'job-query': resolve(__dirname, 'src/job-query/main.js'),
'excel-query': resolve(__dirname, 'src/excel-query/main.js'),
tables: resolve(__dirname, 'src/tables/main.js')
},
output: {
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js',
assetFileNames: '[name][extname]'
}
}
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,614 @@
{
"version": "2.6",
"children": [
{
"type": "frame",
"id": "GIoPU",
"x": 950,
"y": 0,
"name": "WIP Overview - Integrated",
"width": 1200,
"fill": "#F5F7FA",
"layout": "vertical",
"gap": 16,
"padding": 20,
"children": [
{
"type": "frame",
"id": "N2qxA",
"name": "header",
"width": "fill_container",
"height": 64,
"fill": {
"type": "gradient",
"gradientType": "linear",
"enabled": true,
"rotation": 135,
"size": {
"height": 1
},
"colors": [
{
"color": "#667eea",
"position": 0
},
{
"color": "#764ba2",
"position": 1
}
]
},
"cornerRadius": 10,
"padding": [
0,
22
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "7h8YC",
"name": "headerTitle",
"fill": "#FFFFFF",
"content": "WIP Overview Dashboard",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "600"
},
{
"type": "frame",
"id": "JyskI",
"name": "headerRight",
"gap": 16,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "8rtgc",
"name": "lastUpdate",
"fill": "rgba(255,255,255,0.8)",
"content": "Last Update: 2026-01-27 14:30",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "normal"
},
{
"type": "frame",
"id": "ZH0PW",
"name": "refreshBtn",
"fill": "rgba(255,255,255,0.2)",
"cornerRadius": 8,
"padding": [
9,
20
],
"children": [
{
"type": "text",
"id": "wlrMh",
"name": "refreshText",
"fill": "#FFFFFF",
"content": "重新整理",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "aYXjP",
"name": "Summary Section",
"width": "fill_container",
"layout": "vertical",
"gap": 12,
"children": [
{
"type": "frame",
"id": "pFof4",
"name": "kpiRow",
"width": "fill_container",
"gap": 14,
"children": [
{
"type": "frame",
"id": "0kWPh",
"name": "kpi1",
"width": "fill_container",
"fill": "#FFFFFF",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#E2E6EF"
},
"layout": "vertical",
"gap": 6,
"padding": [
16,
20
],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "DTtUq",
"name": "kpi1Label",
"fill": "#666666",
"content": "Total Lots",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
},
{
"type": "text",
"id": "vdmq8",
"name": "kpi1Value",
"fill": "#667eea",
"content": "1,234",
"fontFamily": "Inter",
"fontSize": 28,
"fontWeight": "700"
}
]
},
{
"type": "frame",
"id": "wEupl",
"name": "kpi2",
"width": "fill_container",
"fill": "#FFFFFF",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#E2E6EF"
},
"layout": "vertical",
"gap": 6,
"padding": [
16,
20
],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "59OHd",
"name": "kpi2Label",
"fill": "#666666",
"content": "Total QTY",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
},
{
"type": "text",
"id": "YkPVl",
"name": "kpi2Value",
"fill": "#667eea",
"content": "56,789",
"fontFamily": "Inter",
"fontSize": 28,
"fontWeight": "700"
}
]
}
]
},
{
"type": "frame",
"id": "g65nT",
"name": "wipStatusRow",
"width": "fill_container",
"gap": 14,
"children": [
{
"type": "frame",
"id": "sbKdU",
"name": "runCard",
"width": "fill_container",
"fill": "#F0FDF4",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 2,
"fill": "#22C55E"
},
"layout": "vertical",
"gap": 8,
"padding": [
16,
20
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "EQzBo",
"name": "runLeft",
"width": "fill_container",
"gap": 10,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "rectangle",
"cornerRadius": 5,
"id": "m7Prk",
"name": "runDot",
"fill": "#22C55E",
"width": 10,
"height": 10
},
{
"type": "text",
"id": "1DMEu",
"name": "runLabel",
"fill": "#166534",
"content": "RUN",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "ZVtRH",
"name": "runRight",
"width": "fill_container",
"gap": 24,
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "OLwma",
"name": "runLots",
"fill": "#0D0D0D",
"content": "500 lots",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
},
{
"type": "text",
"id": "OI5f5",
"name": "runQty",
"fill": "#166534",
"content": "30,000 pcs",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
}
]
}
]
},
{
"type": "frame",
"id": "uibRH",
"name": "queueCard",
"width": "fill_container",
"fill": "#FFFBEB",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 2,
"fill": "#F59E0B"
},
"layout": "vertical",
"gap": 8,
"padding": [
16,
20
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "xeGDP",
"name": "queueLeft",
"width": "fill_container",
"gap": 10,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "rectangle",
"cornerRadius": 5,
"id": "KuAgl",
"name": "queueDot",
"fill": "#F59E0B",
"width": 10,
"height": 10
},
{
"type": "text",
"id": "TsD9B",
"name": "queueLabel",
"fill": "#92400E",
"content": "QUEUE",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "41Db3",
"name": "queueRight",
"width": "fill_container",
"gap": 24,
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "dtaqd",
"name": "queueLots",
"fill": "#0D0D0D",
"content": "634 lots",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
},
{
"type": "text",
"id": "BVusD",
"name": "queueQty",
"fill": "#92400E",
"content": "21,789 pcs",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
}
]
}
]
},
{
"type": "frame",
"id": "Y5gLu",
"name": "holdCard",
"width": "fill_container",
"fill": "#FEF2F2",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 2,
"fill": "#EF4444"
},
"layout": "vertical",
"gap": 8,
"padding": [
16,
20
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "juHZC",
"name": "holdLeft",
"width": "fill_container",
"gap": 10,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "rectangle",
"cornerRadius": 5,
"id": "FW9Vv",
"name": "holdDot",
"fill": "#EF4444",
"width": 10,
"height": 10
},
{
"type": "text",
"id": "gEojA",
"name": "holdLabel",
"fill": "#991B1B",
"content": "HOLD",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "3imiS",
"name": "holdRight",
"width": "fill_container",
"gap": 24,
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "AlTi3",
"name": "holdLots",
"fill": "#0D0D0D",
"content": "100 lots",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
},
{
"type": "text",
"id": "oKc0i",
"name": "holdQty",
"fill": "#991B1B",
"content": "5,000 pcs",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700"
}
]
}
]
}
]
}
]
},
{
"type": "frame",
"id": "uRXyA",
"name": "Content Grid",
"width": "fill_container",
"gap": 16,
"children": [
{
"type": "frame",
"id": "7HMip",
"name": "matrixCard",
"width": "fill_container",
"fill": "#FFFFFF",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#E2E6EF"
},
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "pxsYm",
"name": "matrixHeader",
"width": "fill_container",
"fill": "#FAFBFC",
"stroke": {
"align": "inside",
"thickness": {
"bottom": 1
},
"fill": "#E2E6EF"
},
"padding": [
14,
20
],
"children": [
{
"type": "text",
"id": "JhSDl",
"name": "matrixTitle",
"fill": "#222222",
"content": "Workcenter x Package Matrix (QTY)",
"fontFamily": "Inter",
"fontSize": 15,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "4hQZP",
"name": "matrixBody",
"width": "fill_container",
"height": 200,
"layout": "vertical",
"padding": 16,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "lH6Yr",
"name": "matrixPlaceholder",
"fill": "#999999",
"content": "[ Matrix Table ]",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
}
]
}
]
},
{
"type": "frame",
"id": "FOIFS",
"name": "holdSummaryCard",
"width": 320,
"fill": "#FFFFFF",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#E2E6EF"
},
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "uikVi",
"name": "holdSummaryHeader",
"width": "fill_container",
"fill": "#FAFBFC",
"stroke": {
"align": "inside",
"thickness": {
"bottom": 1
},
"fill": "#E2E6EF"
},
"padding": [
14,
20
],
"children": [
{
"type": "text",
"id": "VBWBv",
"name": "holdSummaryTitle",
"fill": "#222222",
"content": "Hold Summary",
"fontFamily": "Inter",
"fontSize": 15,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "cFEPm",
"name": "holdSummaryBody",
"width": "fill_container",
"height": 200,
"layout": "vertical",
"padding": 16,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "s7sa1",
"name": "holdSummaryPlaceholder",
"fill": "#999999",
"content": "[ Hold Table ]",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
}
]
}
]
}
]
}
]
}
]
}

38
gunicorn.conf.py Normal file
View File

@@ -0,0 +1,38 @@
import os
bind = os.getenv("GUNICORN_BIND", "0.0.0.0:8080")
workers = int(os.getenv("GUNICORN_WORKERS", "2")) # 2 workers for redundancy
threads = int(os.getenv("GUNICORN_THREADS", "4"))
worker_class = "gthread"
# Timeout settings - critical for dashboard stability
timeout = 65 # Worker timeout: must be > call_timeout (55s)
graceful_timeout = 30 # Graceful shutdown timeout (enough for thread cleanup)
keepalive = 5 # Keep-alive connections timeout
# Worker lifecycle management - prevent state accumulation
max_requests = 1000 # Restart worker after N requests
max_requests_jitter = 100 # Random jitter to prevent simultaneous restarts
# ============================================================
# Worker Lifecycle Hooks
# ============================================================
def worker_exit(server, worker):
"""Clean up background threads and database connections when worker exits."""
# Stop background sync threads first
try:
from mes_dashboard.services.realtime_equipment_cache import (
stop_equipment_status_sync_worker
)
stop_equipment_status_sync_worker()
except Exception as e:
server.log.warning(f"Error stopping equipment sync worker: {e}")
# Then dispose database connections
try:
from mes_dashboard.core.database import dispose_engine
dispose_engine()
except Exception as e:
server.log.warning(f"Error disposing database engine: {e}")

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-07

View File

@@ -0,0 +1,79 @@
## Context
`DashBoard_vite` 已完成第一批根目錄重構,但仍有部分頁面維持大量 inline script、部分計算在後端實作且缺乏前後一致性驗證、欄位命名規則未全面治理。`DashBoard/` 目前仍作為結構與行為參考來源。此變更目標是完成最終遷移:以 `DashBoard_vite` 根目錄作為唯一開發/部署主體,並建立可持續的前端模組化、欄位契約、快取可觀測性與遷移門檻。
## Goals / Non-Goals
**Goals:**
- 完成 root cutover執行與維護流程完全以 `DashBoard_vite` 為主。
- 將主要頁面前端腳本模組化至 Vite 管理,降低單檔模板複雜度。
- 將可前端化的展示/聚合計算前移,並建立與既有輸出一致性驗證。
- 建立 UI/API/Export 欄位契約與自動檢核機制。
- 強化分層快取的健康指標與退化觀測。
- 制定遷移驗收門檻、灰度與回退方案。
**Non-Goals:**
- 不重寫所有頁面的視覺設計。
- 不更換資料來源Oracle schema 與核心資料表不變)。
- 不改成前後端雙對外服務架構(維持單一 port
## Decisions
1. Canonical root ownership
- Decision: `DashBoard_vite` 為唯一可執行主工程;`DashBoard/` 僅保留為對照基準直到遷移結案。
- Why: 避免規格、程式碼、部署分散在不同根目錄。
- Alternative: 長期雙根並行;放棄,因維運成本與錯誤率高。
2. Page-by-page Vite modularization
- Decision: 以頁面為單位建立 Vite entry先抽共用 coreAPI、toast、table/tree、field contract再遷移頁面。
- Why: 風險可控,便於逐頁回歸驗證。
- Alternative: 一次性 SPA rewrite放棄風險高且不符合保持既有邏輯要求。
3. Compute-shift contract with parity checks
- Decision: 後端保留原始資料查詢與必要彙整,前端承接展示層聚合/格式化;每個前移計算需有 parity fixture。
- Why: 提升前端互動效率,同時避免行為偏移。
- Alternative: 全留後端;放棄,無法達成前移目標。
4. Field contract registry
- Decision: 建立欄位契約註冊檔UI label / API key / export header / semantic type頁面與匯出共用。
- Why: 消除欄位語義不一致與下載對不上畫面的問題。
- Alternative: 分頁分散維護;放棄,長期不可控。
5. Cache observability first-class
- Decision: 延續 L1 memory + L2 Redis新增命中率、資料新鮮度、降級狀態指標並在 health/deep-health 可見。
- Why: 快取是效能與穩定核心,需可觀測才能穩定運維。
- Alternative: 僅保留功能快取不加觀測;放棄,故障定位成本高。
## Risks / Trade-offs
- [Risk] 模組化拆分期間,舊 inline 與新 module 並存造成行為差異 → Mitigation: 對每頁保留 feature flag 或 fallback逐頁切換。
- [Risk] 前移計算造成數值差異(四捨五入、分母定義) → Mitigation: 建立固定測試資料與 snapshot 比對,未通過不得切換。
- [Risk] 欄位契約改名影響下游報表流程 → Mitigation: 提供 alias 過渡期與變更公告。
- [Risk] Redis/Oracle 不可用時測試訊號雜訊高 → Mitigation: 分離 unit/fallback 與 integration pipelines。
## Migration Plan
1. Baseline freeze
- 凍結基線 API payload、頁面主要互動、匯出欄位產生對照清單。
2. Cutover preparation
- 補齊根目錄執行文件、CI 與腳本,確保不再依賴 `DashBoard/`
3. Modularization waves
- Wave A: Portal、resource history、job query。
- Wave B: resource status、excel query、tables。
- 每波完成後執行頁面回歸與欄位一致性檢核。
4. Compute-shift waves
- 先移動展示層聚合與圖表資料整理,再評估進一步前移。
- 每項前移需 parity 測試與效能比較。
5. Final cutover and cleanup
- 滿足驗收門檻後將 `DashBoard/` 標記為 archived reference 或移除。
- 完成回退文件與操作手冊更新。
## Open Questions
- `DashBoard/` 在結案後保留多久(短期備援或立即封存)?
- 哪一頁的前移計算業務優先級最高resource_history vs job_query
- 是否要求在 cutover 前補齊端對端自動化下載欄位比對?

View File

@@ -0,0 +1,32 @@
## Why
目前已完成第一批根目錄重構,但仍存在「部分頁面與邏輯尚未完整遷移」的階段性狀態。需要建立完整遷移提案,將 `DashBoard_vite` 根目錄收斂為唯一開發與運行主體,並完成前端模組化與欄位契約治理,避免長期雙結構維運風險。
## What Changes
- 完成從參考結構到根目錄主工程的全面切換,消除對 `DashBoard/` 作為執行依賴。
- 以 Vite 完整模組化 Portal 與主要業務頁面前端腳本,逐步移除大型 inline scripts。
- 在不改變既有業務流程前提下,將可前端化的展示/聚合計算由後端移至前端。
- 建立 UI/API/Export 欄位契約機制,對報表與查詢頁進行一致性治理。
- 擴充快取與運維可觀測性,明確 Redis 與記憶體快取的行為、指標與退化策略。
- 建立完整遷移驗收與回退規則,作為 cutover 與後續清理依據。
## Capabilities
### New Capabilities
- `root-cutover-finalization`: 定義並完成根目錄主工程最終切換與遺留結構去依賴。
- `full-vite-page-modularization`: 完成主要頁面腳本的 Vite 模組化與資產輸出治理。
- `frontend-compute-shift`: 將展示層可前端化計算從後端搬移到前端,保持行為一致。
- `field-contract-governance`: 建立並執行欄位契約UI label / API key / export header一致性規範。
- `cache-observability-hardening`: 強化分層快取策略與健康指標,明確失效與退化行為。
- `migration-gates-and-rollout`: 定義完整遷移的驗收門檻、灰度與回退流程。
### Modified Capabilities
- None.
## Impact
- Affected code: root `src/`, `frontend/`, `scripts/`, `tests/`, `docs/`
- Runtime/deploy: Conda + Node(Vite) build pipeline、Flask/Gunicorn 單一對外 port 模式。
- APIs/pages: Portal、resource status、resource history、job query、excel query、tables 等頁面腳本與欄位輸出。
- Ops: Redis 快取、記憶體快取、health/deep health 指標與告警解讀。

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Layered Cache SHALL Expose Operational State
The route cache implementation SHALL expose layered cache operational state, including mode, freshness, and degradation status.
#### Scenario: Redis unavailable degradation state
- **WHEN** Redis is unavailable
- **THEN** health endpoints MUST indicate degraded cache mode while keeping L1 memory cache active
### Requirement: Cache Telemetry MUST be Queryable for Operations
The system MUST provide cache telemetry suitable for operations diagnostics.
#### Scenario: Telemetry inspection
- **WHEN** operators request deep health status
- **THEN** cache-related metrics/state SHALL be present and interpretable for troubleshooting

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Field Contract Registry SHALL Define UI/API/Export Mapping
The system SHALL maintain a field contract registry mapping UI labels, API keys, export headers, and semantic types.
#### Scenario: Contract lookup for page rendering
- **WHEN** a page renders table headers and values
- **THEN** it MUST resolve display labels and keys through the shared field contract definitions
#### Scenario: Contract lookup for export
- **WHEN** export headers are generated
- **THEN** header names MUST follow the same semantic mapping used by the page contract
### Requirement: Consistency Checks MUST Detect Contract Drift
The system MUST provide automated checks that detect mismatches between UI, API response keys, and export field definitions.
#### Scenario: Drift detection failure
- **WHEN** a page or export changes a field name without updating the contract
- **THEN** consistency checks MUST report a failing result before release

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Display-Layer Computation SHALL be Shifted to Frontend Safely
The system SHALL move eligible display-layer computations from backend to frontend while preserving existing business behavior.
#### Scenario: Equivalent metric output
- **WHEN** frontend-computed metrics are produced for a supported page
- **THEN** output values MUST match baseline backend results within defined rounding rules
### Requirement: Compute Shift MUST be Verifiable by Parity Fixtures
Each migrated computation MUST have parity fixtures comparing baseline and migrated outputs.
#### Scenario: Parity test gating
- **WHEN** a compute-shifted module is changed
- **THEN** parity checks MUST run and fail the migration gate if output differs beyond tolerance

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Major Pages SHALL be Managed by Vite Modules
The system SHALL provide Vite-managed module entries for major portal pages, replacing inline scripts in a phased manner.
#### Scenario: Portal module loading
- **WHEN** the portal page is rendered
- **THEN** it MUST load its behavior from a Vite-built module asset when available
#### Scenario: Page module fallback
- **WHEN** a required Vite asset is unavailable
- **THEN** the system MUST keep page behavior functional through explicit fallback logic
### Requirement: Build Pipeline SHALL Produce Backend-Served Assets
Vite build output MUST be emitted into backend static paths and served by Flask/Gunicorn on the same origin.
#### Scenario: Build artifact placement
- **WHEN** frontend build is executed
- **THEN** generated JS/CSS files SHALL be written to the configured backend static dist directory

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Migration Gates SHALL Define Cutover Readiness
The system SHALL define explicit migration gates for functional parity, build integrity, and operational health before final cutover.
#### Scenario: Gate evaluation before cutover
- **WHEN** release is prepared for final cutover
- **THEN** all required migration gates MUST pass or cutover SHALL be blocked
### Requirement: Rollout and Rollback Procedures MUST be Actionable
The system SHALL document actionable rollout and rollback procedures for root migration.
#### Scenario: Rollback execution
- **WHEN** post-cutover validation fails critical checks
- **THEN** operators MUST be able to execute documented rollback steps to restore previous stable behavior

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Root Project SHALL be the Single Execution Target
The system SHALL run all application startup, test, and deployment workflows from `DashBoard_vite` root without requiring nested `DashBoard/` paths.
#### Scenario: Root startup script execution
- **WHEN** an operator runs start/deploy scripts from `DashBoard_vite` root
- **THEN** all referenced source/config/script paths MUST resolve inside root project structure
#### Scenario: Root test execution
- **WHEN** CI or local developer runs test commands from root
- **THEN** tests SHALL execute against root source tree and root config files
### Requirement: Reference Directory MUST Remain Non-Authoritative
`DashBoard/` SHALL be treated as reference-only and MUST NOT be required for production runtime.
#### Scenario: Runtime independence
- **WHEN** root application is started in an environment without `DashBoard/`
- **THEN** the application MUST remain functional for the defined migration scope

View File

@@ -0,0 +1,42 @@
## 1. Root Cutover Finalization
- [x] 1.1 Inventory all remaining runtime/test/deploy references to nested `DashBoard/` paths.
- [x] 1.2 Remove or replace nested-path dependencies so root scripts and app startup are self-contained.
- [x] 1.3 Define and execute root-only smoke startup checks.
## 2. Vite Full Page Modularization
- [x] 2.1 Create/standardize Vite entries for Portal, Resource Status, Resource History, Job Query, Excel Query, and Tables.
- [x] 2.2 Extract shared frontend core modules (API wrappers, table/tree helpers, field contract helpers).
- [x] 2.3 Replace targeted inline scripts with module bootstraps while preserving fallback behavior.
- [x] 2.4 Update template asset resolution to support per-page Vite bundles.
## 3. Frontend Compute Shift
- [x] 3.1 Identify display-layer computations eligible for frontend migration and document parity rules.
- [x] 3.2 Migrate selected calculations page by page with deterministic helper functions.
- [x] 3.3 Add parity fixtures/tests comparing baseline backend vs migrated frontend outputs.
## 4. Field Contract Governance
- [x] 4.1 Introduce shared field contract registry for UI/API/Export mapping.
- [x] 4.2 Apply the registry to Job Query and Resource History completely (including headers and semantic types).
- [x] 4.3 Extend consistency checks to additional pages and exports.
## 5. Cache Observability Hardening
- [x] 5.1 Expand cache telemetry fields in health/deep-health outputs.
- [x] 5.2 Add explicit degraded-mode visibility when Redis is unavailable.
- [x] 5.3 Validate cache behavior and telemetry under L1-only and L1+L2 modes.
## 6. Migration Gates and Rollout
- [x] 6.1 Define gate checklist for cutover readiness (tests, parity, build, health).
- [x] 6.2 Document rollout steps and operator runbook for the final cutover.
- [x] 6.3 Document rollback procedure and rehearse rollback validation.
## 7. Validation and Documentation
- [x] 7.1 Run focused unit/integration checks in root project and record evidence.
- [x] 7.2 Record known environment-dependent gaps (Oracle/Redis) and mitigation plan.
- [x] 7.3 Update README/docs to declare final root-first workflow and migration status.

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-07

View File

@@ -0,0 +1,64 @@
## Context
現有程式碼主體在 `DashBoard/` 子目錄OpenSpec/opsx 在 `DashBoard_vite` 根目錄,造成需求追蹤、實作與驗證分離。重構目標是以 `DashBoard/` 作為參考來源,將可執行專案落到根目錄,並在維持單一對外服務埠前提下導入 Vite 前端建置與模組化。
## Goals / Non-Goals
**Goals:**
-`DashBoard_vite` 根目錄建立可運行工程,與 OpenSpec artifacts 同層。
- 維持 Flask/Gunicorn 單一對外 port前端資產由 Flask static 提供。
- 導覽改為抽屜分組,保持既有頁面與 drill-down 操作語意。
- 導入分層快取L1 memory + L2 Redis取代 NoOp 預設。
- 建立畫面欄位、API key、下載欄位的一致性規範。
**Non-Goals:**
- 不在第一階段重寫所有頁面 UI。
- 不更動核心商業資料來源Oracle schema 與主要 SQL 邏輯)。
- 不在第一階段導入多服務或多 port 架構。
## Decisions
1. Root-first migration根目錄主工程
- Decision: 以 `DashBoard/` 為參考,將執行入口、`src/``scripts/`、前端建置等移到 `DashBoard_vite` 根目錄。
- Rationale: 使 OpenSpec 與可執行程式在同一工作根,避免流程分裂。
- Alternative considered: 繼續在 `DashBoard/` 開發,放棄;因與使用者要求衝突。
2. Single-port Vite integration
- Decision: 使用 Vite build 輸出到 Flask static僅在開發時可選擇 Vite dev server不作對外正式服務。
- Rationale: 保持現行部署模型與防火牆策略,降低切換風險。
- Alternative considered: 分離前後端雙服務;放棄以符合單一 port 約束。
3. Layered route cache
- Decision: 路由層快取採用 L1 memory TTL + L2 Redis JSONRedis 不可用時仍有 L1。
- Rationale: 改善響應速度與穩定性,避免 NoOp 導致的快取失效。
- Alternative considered: Redis-only放棄以避免 Redis 異常時退化過大。
4. Navigation IA by drawers
- Decision: 將 portal 導覽分為「報表類、查詢類、開發工具類」抽屜,頁面內容維持原路由/iframe lazy load。
- Rationale: 降低使用者認知負擔,同時避免一次性替換頁面內邏輯。
- Alternative considered: 直接改成 SPA router放棄以降低第一階段風險。
5. Field contract normalization
- Decision: 建立欄位契約字典UI label / API key / export header並先修正已知不一致。
- Rationale: 避免匯出與畫面解讀差異造成誤用。
- Alternative considered: 每頁分散維護;放棄因長期不可維護。
## Risks / Trade-offs
- [Risk] 根目錄遷移時檔案基線混亂(舊目錄與新目錄並存) → Mitigation: 明確標註 `DashBoard/` 為 reference新增 root 驗證與遷移清單。
- [Risk] Redis/Oracle 在本機測試環境不可用導致測試波動 → Mitigation: 分離「單元測試通過」與「環境依賴測試」兩條驗證報告。
- [Risk] Portal 抽屜調整影響既有 E2E selector → Mitigation: 保留原 tab class/data-target先兼容再逐步更新測試。
- [Risk] 欄位命名調整影響下游檔案流程 → Mitigation: 提供別名過渡期與欄位映射文件。
## Migration Plan
1. 建立根目錄主工程骨架(參照 `DashBoard/`),保留 `DashBoard/` 作為對照來源。
2. 導入 Vite build 流程並接入 `deploy/start` 腳本。
3. 套用 portal 抽屜導覽與快取 backend 重構。
4. 執行欄位一致性第一批修正job query / resource history
5. 補齊根目錄測試與操作文件,確認單一 port 運作。
## Open Questions
- 根目錄最終是否保留 `DashBoard/` 作為長期參考,或在完成驗收後移除?
- 第二階段前端運算前移的優先頁面順序(`resource_history` vs `job_query`)是否有業務優先級?

View File

@@ -0,0 +1,29 @@
## Why
目前可執行程式碼位於 `DashBoard/` 子目錄,與 `DashBoard_vite` 根目錄的 OpenSpec/opsx 工作流分離,導致規格、實作與驗證不在同一專案根。需要以 `DashBoard` 為參考,將重構主體統一到 `DashBoard_vite` 根目錄,並同時導入 Vite 以改善前端可維護性與體驗。
## What Changes
-`DashBoard_vite` 根目錄建立可執行的重構專案骨架,參照既有 `DashBoard` 功能與路由。
- 維持 Flask/Gunicorn 單一對外 port導入 Vite 作為前端建置工具build artifact 由 Flask 提供)。
- 導覽由平鋪 tab 重構為功能抽屜(報表類、查詢類、開發工具類),保持既有業務操作路徑。
- 快取策略改為可運作的分層快取L1 記憶體 + L2 Redis不再使用 NoOp 做為預設。
- 建立前端顯示欄位與下載欄位的一致性規範,先修正已知不一致案例。
## Capabilities
### New Capabilities
- `root-project-restructure`: 以 `DashBoard` 為參考,將可運行的重構工程落在 `DashBoard_vite` 根目錄。
- `vite-single-port-integration`: Vite 建置結果整合進 Flask static維持單一 server/port 對外。
- `portal-drawer-navigation`: Portal 導覽改為抽屜分類且維持原頁面邏輯。
- `layered-route-cache`: 路由層快取改為 L1 memory + L2 Redis 的可用實作。
- `field-name-consistency`: 統一畫面欄位、API key 與匯出欄位命名/語義。
### Modified Capabilities
- None.
## Impact
- Affected codebase root: `DashBoard_vite`(新主工程落點)
- Reference baseline: `DashBoard/`(保留作比對與遷移來源)
- Affected systems: Flask app factory, templates, frontend build pipeline, deployment/start scripts, cache layer, export SQL/headers

View File

@@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: UI and Export Fields SHALL Have a Consistent Contract
The system SHALL define and apply a consistent contract among UI column labels, API keys, and export headers for report/query pages.
#### Scenario: Job query export naming consistency
- **WHEN** job query exports include cause/repair/symptom values
- **THEN** exported field names SHALL reflect semantic value type consistently (e.g., code name vs status name)
#### Scenario: Resource history field alignment
- **WHEN** resource history detail table shows KPI columns
- **THEN** columns required by export semantics (including Availability%) SHALL be present or explicitly mapped

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Route Cache SHALL Use Layered Storage
The route cache SHALL use L1 in-memory TTL cache and L2 Redis JSON cache when Redis is available.
#### Scenario: L1 cache hit
- **WHEN** a cached key exists in L1 and is unexpired
- **THEN** the API response SHALL be returned from memory without querying Redis
#### Scenario: L2 fallback
- **WHEN** a cached key is missing in L1 but exists in Redis
- **THEN** the value SHALL be returned and warmed into L1
### Requirement: Cache SHALL Degrade Gracefully Without Redis
The route cache SHALL remain functional with L1 cache when Redis is unavailable.
#### Scenario: Redis unavailable at startup
- **WHEN** Redis health check fails during app initialization
- **THEN** route cache operations SHALL continue using L1 cache without application failure

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers
The portal SHALL group navigation entries into functional drawers: reports, queries, and development tools.
#### Scenario: Drawer grouping visibility
- **WHEN** users open the portal
- **THEN** report pages and query pages SHALL appear in separate drawer groups
### Requirement: Existing Page Behavior SHALL Remain Compatible
The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames.
#### Scenario: Route continuity
- **WHEN** a user selects an existing page entry from the new drawer
- **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior

View File

@@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: Root Directory SHALL be the Primary Executable Project
The system SHALL treat `DashBoard_vite` root directory as the primary executable project, while `DashBoard/` remains reference-only during migration.
#### Scenario: Running app from root
- **WHEN** a developer runs project scripts from `DashBoard_vite` root
- **THEN** the application startup flow SHALL resolve code and config from root project files
#### Scenario: Reference directory preserved
- **WHEN** migration is in progress
- **THEN** `DashBoard/` SHALL remain available for structure comparison and behavior verification

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Frontend Build SHALL Use Vite With Flask Static Output
The system SHALL use Vite to build frontend assets and output artifacts into Flask static directories served by the backend.
#### Scenario: Build asset generation
- **WHEN** frontend build is executed
- **THEN** Vite SHALL generate portal-related JS/CSS artifacts into the backend static output path
### Requirement: Deployment SHALL Preserve Single External Port
The system SHALL preserve single-port external serving through Flask/Gunicorn.
#### Scenario: Production serving mode
- **WHEN** the system runs in deployment mode
- **THEN** frontend assets SHALL be served through Flask on the same external port as API/page routes

View File

@@ -0,0 +1,26 @@
## 1. Root Migration Baseline
- [x] 1.1 Build root project baseline in `DashBoard_vite` by referencing `DashBoard/` structure while preserving `DashBoard/` as comparison source.
- [x] 1.2 Ensure root-level Python entry/config/scripts can run without depending on nested `DashBoard/` paths.
- [x] 1.3 Update root README and environment setup notes to make root-first workflow explicit.
## 2. Vite + Single-Port Integration
- [x] 2.1 Add root frontend Vite project and configure build output to backend static assets.
- [x] 2.2 Integrate frontend build into deploy/start scripts with fallback behavior when npm build is unavailable.
- [x] 2.3 Verify root app serves Vite-built assets through Flask on the same external port.
## 3. Portal Navigation Refactor
- [x] 3.1 Refactor root portal navigation to drawer groups (reports/queries/dev-tools) while keeping existing route targets.
- [x] 3.2 Keep lazy-load frame behavior and health popup behavior compatible after navigation refactor.
## 4. Cache and Field Contract Updates
- [x] 4.1 Replace default NoOp route cache in root app with layered cache backend (L1 memory + optional Redis).
- [x] 4.2 Align known field-name inconsistencies between UI and export (job query and resource history first batch).
## 5. Validation and Documentation
- [x] 5.1 Run focused root tests for app factory/cache/query modules and record results.
- [x] 5.2 Document residual environment-dependent test gaps (Oracle/Redis dependent cases) and next actions.

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-07

View File

@@ -0,0 +1,50 @@
## Context
目前主要報表頁多已採 `frontend_asset(...) + Vite module + inline fallback` 模式,但 `hold_detail` 仍停留在純 inline script。這造成
- 例外頁面無法受益於共用模組治理與 build pipeline。
- 動態表格字串拼接保留 XSS 風險。
- 長期維護出現「主流程已模組化、單頁特例未遷移」的不一致。
## Goals / Non-Goals
**Goals:**
-`hold_detail` 與其他報表頁採同一套 Vite 載入模式。
- 保留既有功能語意(篩選、分頁、刷新、導航)與 MesApi 呼叫契約。
- 將高風險動態輸出改為 escape-safe 渲染。
- 加上模板整合測試覆蓋 module/fallback 分支。
**Non-Goals:**
- 不改後端資料模型與查詢邏輯。
- 不重設 UI 視覺樣式與互動流程。
- 不移除 fallback本次仍保留回退能力
## Decisions
### Decision 1: 以「抽取 inline script 到 Vite entry」完成遷移
- 選擇:新增 `frontend/src/hold-detail/main.js`,以既有邏輯為基礎遷移,模板改為 module 優先、fallback 次之。
- 理由:最小風險完成頁面納管,避免一次性重寫行為。
### Decision 2: 保持全域 handler 相容
- 選擇module 內維持 `window` 介面供既有 `onclick` 使用。
- 理由:降低模板 DOM 大改成本,優先保證 parity。
### Decision 3: 在 module 與 fallback 皆補 escape 防護
- 選擇:對 workcenter/package/lot 資料動態輸出加入 escape/quoted-string 保護。
- 理由:避免 fallback 成為安全漏洞旁路。
## Risks / Trade-offs
- [Risk] 複製遷移過程遺漏函式導致 runtime error → Mitigation: build + template test 覆蓋 module 路徑。
- [Risk] fallback 與 module 雙軌造成維護成本 → Mitigation: 保持語意對齊並在後續階段評估移除 fallback。
- [Risk] escape 導致個別顯示格式變化 → Mitigation: 僅防注入,不改原欄位值與排序/篩選語意。
## Migration Plan
1. 增加 `hold-detail` Vite entry 與 module 檔案。
2. 調整 `hold_detail.html` scripts block 為 module/fallback 雙軌。
3. 補強 module + fallback 的動態輸出 escape。
4. build 與 pytest 驗證,更新 tasks。
## Open Questions
- 是否在下一階段移除 `hold_detail` fallback inline script以降低雙路徑維運成本。

View File

@@ -0,0 +1,26 @@
## Why
`hold_detail` 目前仍是大型 inline script尚未納入 Vite 模組治理,且動態 HTML 字串拼接存在潛在注入風險。為了完成報表頁一致的現代化架構與安全基線,需要將該頁補齊至與其餘主要頁面相同的模組化與防護水位。
## What Changes
- 新增 `hold-detail` Vite entry 並由模板透過 `frontend_asset(...)` 優先載入 module。
- 保留現有 inline script 作為 asset 缺失時 fallback維持既有操作語意不變。
-`hold_detail` 的動態表格/篩選渲染改為 escape-safe 輸出,避免不受信字串直接注入 DOM。
- 補充模板整合測試,驗證 `hold_detail` 的 module/fallback 路徑。
## Capabilities
### New Capabilities
- None.
### Modified Capabilities
- `full-vite-page-modularization`: 擴展 major page 模組化覆蓋到 hold-detail 報表頁。
- `field-contract-governance`: 將動態渲染安全契約擴展到 hold-detail 報表內容。
- `report-effects-parity`: 明確要求 hold-detail 的篩選、分頁、分佈互動在遷移後維持等效。
## Impact
- Affected code: `frontend/src/`, `frontend/vite.config.js`, `src/mes_dashboard/templates/hold_detail.html`, `tests/test_template_integration.py`
- APIs/routes: `/hold-detail`, `/api/wip/hold-detail/*`(僅前端調用與渲染方式調整,不更動後端契約)。
- Runtime behavior: 單一 port 與既有 MesApi/retry 行為不變。

View File

@@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: Hold Detail Dynamic Rendering MUST Sanitize Untrusted Values
Dynamic table and distribution rendering in hold-detail SHALL sanitize untrusted text before injecting into HTML attributes or content.
#### Scenario: Hold reason distribution contains HTML-like payload
- **WHEN** workcenter/package/lot fields include HTML-like text from upstream data
- **THEN** the hold-detail page MUST render escaped text and MUST NOT execute embedded markup or scripts

View File

@@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: Hold Detail Page SHALL Be Served by a Vite Module
The system SHALL provide a dedicated Vite entry bundle for the hold-detail report page.
#### Scenario: Hold-detail module asset exists
- **WHEN** `/hold-detail` is rendered and `hold-detail.js` exists in static dist
- **THEN** the page MUST load behavior from the Vite module entry
#### Scenario: Hold-detail module asset missing
- **WHEN** `/hold-detail` is rendered and the module asset is unavailable
- **THEN** the page MUST remain operational through explicit inline fallback logic

View File

@@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: Hold Detail Interaction Semantics SHALL Remain Equivalent After Modularization
Migrating hold-detail to a Vite module SHALL preserve existing filter, pagination, and refresh behavior.
#### Scenario: User applies filters and paginates on hold-detail
- **WHEN** users toggle age/workcenter/package filters and navigate pages
- **THEN** returned lots, distribution highlights, and pagination state MUST remain behaviorally equivalent to baseline inline behavior

View File

@@ -0,0 +1,17 @@
## 1. Hold Detail Vite Modularization
- [x] 1.1 Add `hold-detail` entry to Vite build configuration.
- [x] 1.2 Create `frontend/src/hold-detail/main.js` by migrating existing page script while preserving behavior.
- [x] 1.3 Update `hold_detail.html` to prefer `frontend_asset('hold-detail.js')` with inline fallback retention.
## 2. Security and Parity Hardening
- [x] 2.1 Sanitize dynamic HTML/attribute interpolation in hold-detail module rendering paths.
- [x] 2.2 Apply equivalent sanitization in inline fallback logic to avoid security bypass.
- [x] 2.3 Preserve legacy global handler compatibility for existing inline event hooks.
## 3. Validation
- [x] 3.1 Build frontend and verify `hold-detail.js` output in static dist.
- [x] 3.2 Extend template integration tests for hold-detail module/fallback rendering.
- [x] 3.3 Run focused pytest suite for template/frontend regressions.

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-07

View File

@@ -0,0 +1,101 @@
## Context
目前根目錄 `DashBoard_vite` 已完成單一埠 Vite 整合與主要頁面模組化,但運行層仍有三類風險:
1. 韌性參數未完全生效(例如 DB pool 參數在設定層存在、engine 層未完全採用)。
2. 故障語意未完全標準化pool 耗盡/熔斷開啟/降級回應仍有泛化 500
3. 效能優化尚未形成一致策略(快取資料結構與全量 merge 路徑可再降低 CPU 與記憶體負載)。
本設計在不改變業務邏輯與頁面流程前提下,推進 P0/P1/P2
- P0穩定性與退避
- P1查詢效率與資料結構
- P2運維一致性與自癒
約束條件:
- `resource`(設備主檔)與 `wip`(即時狀態)維持全表快取,因資料規模可接受且可換取查詢一致性與延遲穩定。
- Vite 架構持續以「前端可複用元件 + 前端運算前移」為核心方向。
## Goals / Non-Goals
**Goals:**
- 讓 DB pool / timeout / circuit breaker 形成可配置且可驗證的穩定性基線。
- 在 pool 耗盡與服務降級時提供可辨識錯誤碼、HTTP 狀態與前端退避策略。
- 保留全表快取前提下,優化快取資料形狀與索引路徑,降低每次請求全量合併成本。
- 對齊 conda + systemd + watchdog 運行模型,讓 worker 自癒與重啟流程可操作、可觀測。
- 持續擴大前端運算前移範圍,並以 parity 驗證保證結果一致。
**Non-Goals:**
- 不改變既有頁面資訊架構、分頁/鑽取邏輯與核心業務規則。
- 不將 `resource/wip` 改為分片快取或拆分多來源讀取。
- 不引入多埠部署或拆分為前後端不同網域。
- 不在本次變更中重寫所有歷史 SQL 或全面替換資料來源。
## Decisions
### Decision 1: 以「配置即行為」收斂 DB 連線與保護策略P0
- 決策:`database.py` 的 engine 建立必須直接採用 settings/.env 的 pool 與 timeout 參數,並在 `/health/deep` 輸出實際生效值。
- 原因:目前存在設定值與實際 engine 參數可能分離,導致調參無效。
- 替代方案:
- 保留硬編碼參數,僅調整 `.env.example`(拒絕,無法保證生效)。
- 完全改為每環境不同程式碼分支(拒絕,維運成本高)。
### Decision 2: 標準化「退避可判讀」錯誤語意P0
- 決策:新增/明確化 pool exhausted、circuit open、service degraded 的錯誤碼與 HTTP 映射,並在前端 `MesApi` 依狀態碼與錯誤碼進行退避。
- 原因:泛化 500 導致前端無法做差異化重試與提示。
- 替代方案:
- 維持所有 5xx 同一重試邏輯(拒絕,會加劇擁塞)。
- 僅靠文字訊息判斷(拒絕,不穩定且難國際化)。
### Decision 3: 在「全表快取不變」前提下做索引化與增量化P1
- 決策:保留 `resource/wip` 全表快取資料來源,但額外建立 process/redis 層索引(如 RESOURCEID → record index與預聚合中間結果減少每請求全量 merge。
- 原因:資料量雖不大,但高併發下重複全量轉換與合併會累積 CPU 成本。
- 替代方案:
- 改為分片快取(拒絕,破壞已確認的資料一致性策略)。
- 完全回 Oracle 即時計算(拒絕,增加 DB 壓力與延遲波動)。
### Decision 4: 前端運算前移採「可驗證前移」策略P1
- 決策:優先前移展示層聚合/比率/圖表資料整理,並為每個前移計算建立 parity fixture 與容差規則。
- 原因:符合 Vite 架構目的,減輕後端負擔,同時避免靜默偏差。
- 替代方案:
- 一次性大量前移(拒絕,驗證風險高)。
- 完全不前移(拒絕,無法達成改造目標)。
### Decision 5: 運維流程統一以 conda + systemd + watchdogP2
- 決策:部署與監控路徑統一到 conda 環境systemd 服務模板、啟停腳本、watchdog PID/flag 路徑統一;加入自癒與告警門檻。
- 原因:避免 `venv`/`conda` 混用造成重啟失效或定位困難。
- 替代方案:
- 保持雙系統共存(拒絕,長期不一致風險高)。
## Risks / Trade-offs
- [Risk] 調整錯誤碼與狀態碼可能影響既有前端假設 → Mitigation先以向後相容 envelope 保留既有 `success/error` 結構,再新增標準化 code/meta 欄位。
- [Risk] 啟用 circuit breaker 後短時間內可能增加 503 可見度 → Mitigation設定合理門檻與 recovery timeout並提供管理頁可觀測狀態與手動恢復流程。
- [Risk] 新索引/預聚合增加記憶體占用 → Mitigation設 TTL、大小監控與健康檢查輸出必要時可透過配置關閉特定索引層。
- [Risk] 前端運算前移可能出現精度差異 → Mitigation定義 rounding/tolerance 並在 CI gate 執行 parity 測試。
- [Risk] systemd 與腳本改動可能影響部署流程 → Mitigation提供 rollout/rollback 演練步驟與 smoke check。
## Migration Plan
1. P0 先行(穩定性)
- 讓 DB pool/call timeout/circuit breaker 參數化且生效。
- 新增 pool exhausted 與 degraded 錯誤語意;前端 `MesApi` 加入對應退避策略。
- 補充 health/deep 與 admin status 的可觀測欄位。
2. P1 續行(效率)
- 保留 `resource/wip` 全表快取資料源。
- 加入索引化/預聚合路徑與增量更新鉤子,降低全量 merge 次數。
- 擴充前端 compute-shift補 parity fixtures。
3. P2 收斂(運維)
- 統一 conda + systemd + watchdog 服務定義與文件。
- 設定 worker 自癒與告警門檻重啟頻率、pool 飽和、降級持續時間)。
- 完成壓測與重啟演練 gate 後放行。
4. Rollback
- 任一 gate 失敗即回退到前一穩定版本(腳本 + artifacts + 服務模板)。
- 保留向後相容錯誤回應欄位以降低回退期間前端風險。
## Open Questions
- pool exhausted 的最終 HTTP 語意是否固定為 `503`(含 `Retry-After`)或在部分查詢端點使用 `429`
- 告警通道是否先落地在 log + health gate或直接接既有監控平台若有
- 前端計算容差的全域預設值是否統一(如 1e-6 / 小數 1 位),或按指標分類?

View File

@@ -0,0 +1,39 @@
## Why
目前根目錄遷移與 Vite 架構已完成可用性與功能對齊,但「穩定性、退避、自癒、查詢效率」仍未被完整定義為可驗收的規格。現在需要在不改變既有業務邏輯的前提下,將運行韌性與前端運算前移策略正式化,避免 cutover 後在高負載或故障情境下出現不一致行為。
## What Changes
- 以三階段推進非破壞式優化:
- P0先救穩定讓 DB pool 參數真正生效、在生產基線啟用 circuit breaker、補齊 pool exhausted 的專用錯誤語意與前後端退避行為。
- P1再拚效率重整快取資料結構與查詢路徑索引化/增量化),降低每次請求的全量 merge 成本。
- P2運維收斂統一 conda + systemd 執行模型,補齊 worker 自癒與告警門檻,讓 watchdog/restart 流程可操作且可觀測。
- 明確保留既有架構原則:
- `resource`(設備基礎資料)與 `wip`(線上即時狀況)維持全表快取策略,不改成分片或拆表快取。
- Vite 架構持續以「元件複用(圖表/查詢/抽屜)」與「運算前移至瀏覽器」為主軸,前端承接可前移的聚合與呈現計算。
## Capabilities
### New Capabilities
- `runtime-resilience-recovery`: 定義 DB pool 耗盡、worker 異常、服務降級時的標準退避、恢復與熱重啟流程。
- `conda-systemd-runtime-alignment`: 定義 conda 環境、systemd 服務、watchdog 與啟停腳本的一致部署契約與驗收門檻。
### Modified Capabilities
- `frontend-compute-shift`: 擴充前端運算前移邊界與 parity 驗證,確保前端計算結果與後端契約一致。
- `full-vite-page-modularization`: 強化跨頁可複用元件與共用核心模組(圖表、查詢、抽屜、欄位契約)的要求。
- `layered-route-cache`: 明確要求保留 `resource/wip` 全表快取,並在此基礎上優化索引與資料形狀。
- `cache-observability-hardening`: 擴充快取/連線池/熔斷器的可觀測欄位、降級訊號與告警閾值。
- `migration-gates-and-rollout`: 新增穩定性壓測、pool 壓力、worker 重啟演練等遷移門檻。
## Impact
- Affected code:
- Backend: `src/mes_dashboard/core/database.py`, `src/mes_dashboard/core/circuit_breaker.py`, `src/mes_dashboard/core/cache.py`, `src/mes_dashboard/routes/*.py`, `src/mes_dashboard/services/resource_cache.py`, `src/mes_dashboard/services/realtime_equipment_cache.py`
- Frontend: `frontend/src/core/*` 與各頁 entry 模組,持續抽取可複用圖表/查詢邏輯。
- Ops: `scripts/start_server.sh`, `scripts/worker_watchdog.py`, `deploy/mes-dashboard-watchdog.service`, `.env.example`, `README.md`
- API/behavior:
- 新增或標準化故障語意(含 pool exhausted / circuit open / degraded與對應退避策略。
- Dependencies/systems:
- 維持單一埠服務模型;持續使用 conda + gunicorn + redis + systemd/watchdog。
- Validation:
- 增加 resilience/performance 測試與 rollout gate驗證降級、恢復、快取一致性與前後端計算一致性。

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Health Endpoints SHALL Expose Pool Saturation and Degradation Reason Codes
Operational health endpoints MUST report connection pool saturation indicators and explicit degradation reason codes.
#### Scenario: Pool saturation observed
- **WHEN** checked-out connections and overflow approach configured limits
- **THEN** deep health output MUST expose saturation metrics and degraded reason classification
### Requirement: Degraded Responses MUST Be Correlatable Across API and Health Telemetry
Error responses for degraded states SHALL include stable codes that can be mapped to health telemetry and operational dashboards.
#### Scenario: Degraded API response correlation
- **WHEN** an API request fails due to circuit-open or pool-exhausted conditions
- **THEN** operators MUST be able to match the response code to current health telemetry state
### Requirement: Operational Alert Thresholds SHALL Be Explicitly Defined
The system MUST define alert thresholds for sustained degraded state, repeated worker recovery, and abnormal retry pressure.
#### Scenario: Sustained degradation threshold exceeded
- **WHEN** degraded status persists beyond configured duration
- **THEN** the monitoring contract MUST classify the service as alert-worthy with actionable context

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Production Service Runtime SHALL Use Conda-Aligned Execution Paths
Service units and operational scripts MUST run with a consistent conda-managed Python runtime.
#### Scenario: Service unit starts application
- **WHEN** systemd starts the dashboard service and watchdog
- **THEN** both processes MUST execute using the configured conda environment binaries and paths
### Requirement: Watchdog and Runtime Paths MUST Be Operationally Consistent
PID files, restart flag paths, state files, and worker control interfaces SHALL be consistent across scripts, environment variables, and systemd units.
#### Scenario: Watchdog handles restart flag
- **WHEN** a restart flag is written by admin control endpoints
- **THEN** watchdog MUST read the same configured path set and signal the correct Gunicorn master process
### Requirement: Deployment Documentation MUST Match Runtime Contract
Runbooks and deployment documentation MUST describe the same conda/systemd/watchdog contract used by the deployed system.
#### Scenario: Operator follows deployment runbook
- **WHEN** an operator performs deploy, health check, and rollback from documentation
- **THEN** documented commands and paths MUST work without requiring venv-specific assumptions

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Compute-Shifted Logic SHALL Be Exposed as Reusable Frontend Core Modules
Frontend-computed metrics and transformations MUST be implemented as reusable, testable modules instead of page-local inline logic.
#### Scenario: Multiple pages consume shared compute logic
- **WHEN** two or more pages require the same metric transformation or aggregation
- **THEN** they MUST import a shared frontend core module and produce consistent outputs
### Requirement: Frontend Compute Parity MUST Include Tolerance Contracts Per Metric
Parity verification SHALL define explicit tolerance and rounding contracts per migrated metric.
#### Scenario: Parity check for migrated metric
- **WHEN** migrated frontend computation is validated against baseline output
- **THEN** parity tests MUST evaluate the metric against its declared tolerance and fail when outside bounds
### Requirement: Compute Shift MUST Preserve Existing User-Facing Logic
Frontend compute migration MUST preserve existing filter semantics, drill-down behavior, and displayed totals.
#### Scenario: Existing dashboard interactions after compute shift
- **WHEN** users apply filters and navigate drill-down flows on migrated pages
- **THEN** interaction results MUST remain behaviorally equivalent to the pre-shift baseline

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Vite Page Modules SHALL Reuse Shared Chart and Query Building Blocks
Page entry modules MUST consume shared chart/query/drawer utilities for common behaviors.
#### Scenario: Common chart behavior across pages
- **WHEN** multiple report pages render equivalent chart interactions
- **THEN** the behavior MUST be provided by shared Vite modules rather than duplicated page-local implementations
### Requirement: Modularization MUST Preserve Established Navigation and Drill-Down Semantics
Refactoring into Vite modules SHALL not alter existing page transitions, independent tabs, and drill-down entry points.
#### Scenario: User follows existing drill-down path
- **WHEN** the user navigates from summary page to detail views
- **THEN** the resulting flow and parameter semantics MUST match the established baseline behavior
### Requirement: Module Boundaries SHALL Support Frontend Compute Expansion
Vite module structure MUST keep compute logic decoupled from DOM wiring so additional backend-to-frontend computation shifts can be added safely.
#### Scenario: Adding a new frontend-computed metric
- **WHEN** a new metric is migrated from backend to frontend
- **THEN** the metric logic MUST be integrated through shared compute modules without rewriting page routing structure

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Resource and WIP Full-Table Cache SHALL Remain the Authoritative Cached Dataset
The system MUST keep `resource` and `wip` full-table cache datasets as the canonical cached source for downstream route queries.
#### Scenario: Route query reads cached baseline
- **WHEN** an endpoint requires resource or wip data
- **THEN** it MUST read from the corresponding full-table cache baseline before applying derived filters or aggregations
### Requirement: Cache Access Paths SHALL Support Index-Based Lookup and Derived Views
The caching layer SHALL support index and derived-view access paths to reduce per-request full-table merge and transformation overhead.
#### Scenario: Lookup by key under concurrent load
- **WHEN** requests query by high-cardinality keys such as RESOURCEID
- **THEN** the system MUST serve lookups via indexed cache access instead of repeated full-array scans
### Requirement: Full-Table Cache Refresh MUST Support Incremental Derivation Updates
Derived cache indices and aggregates MUST be refreshed consistently when the underlying full-table cache version changes.
#### Scenario: Cache version update
- **WHEN** full-table cache is refreshed to a new version
- **THEN** dependent indices and derived views MUST be rebuilt or updated before being exposed for reads

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Migration Gates SHALL Include Runtime Resilience Validation
Cutover readiness gates MUST include resilience checks for pool exhaustion handling, circuit-breaker fail-fast behavior, and recovery flow.
#### Scenario: Resilience gate evaluation
- **WHEN** migration gates are executed before release
- **THEN** resilience tests MUST pass for degraded-response semantics and recovery path validation
### Requirement: Migration Gates SHALL Include Frontend Compute Parity Validation
Cutover readiness MUST include parity validation for metrics shifted from backend to frontend computation.
#### Scenario: Compute parity gate
- **WHEN** a release includes additional frontend-computed metrics
- **THEN** gate execution MUST verify parity fixtures and fail if tolerance contracts are violated
### Requirement: Rollout Procedure MUST Include Conda-Systemd-Watchdog Rehearsal
Rollout and rollback runbooks SHALL include an operational rehearsal for service start, watchdog-triggered reload, and post-restart health checks under the conda/systemd runtime contract.
#### Scenario: Pre-cutover rehearsal
- **WHEN** operators execute pre-cutover rehearsal
- **THEN** they MUST successfully complete conda-based start, worker reload, and health verification steps documented in the runbook

View File

@@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: Database Pool Runtime Configuration SHALL Be Enforced
The system SHALL apply database pool and timeout parameters from runtime configuration to the active SQLAlchemy engine used by request handling.
#### Scenario: Runtime pool configuration takes effect
- **WHEN** operators set pool and timeout values via environment configuration and start the service
- **THEN** the active engine MUST use those values for pool size, overflow, wait timeout, and query call timeout
### Requirement: Pool Exhaustion MUST Return Retry-Aware Degraded Responses
The system MUST return explicit degraded responses for connection pool exhaustion and include machine-readable metadata for retry/backoff behavior.
#### Scenario: Pool exhausted under load
- **WHEN** concurrent requests exceed available database connections and pool wait timeout is reached
- **THEN** the API MUST return a dedicated error code and retry guidance instead of a generic 500 failure
### Requirement: Runtime Degradation MUST Integrate Circuit Breaker State
Database-facing API behavior SHALL distinguish circuit-breaker-open degradation from transient query failures.
#### Scenario: Circuit breaker is open
- **WHEN** the circuit breaker transitions to OPEN state
- **THEN** database-backed endpoints MUST fail fast with a stable degradation response contract
### Requirement: Worker Recovery SHALL Support Hot Reload and Watchdog-Assisted Recovery
The runtime MUST support graceful worker hot reload and watchdog-triggered recovery without requiring a port change or full system reboot.
#### Scenario: Worker restart requested
- **WHEN** an authorized operator requests worker restart during degraded operation
- **THEN** the service MUST trigger graceful reload and preserve single-port availability

View File

@@ -0,0 +1,36 @@
## 1. P0 Runtime Resilience Baseline
- [x] 1.1 Make `database.py` read and enforce runtime pool/timeouts from settings/env instead of hardcoded constants.
- [x] 1.2 Add explicit degraded error mapping for pool exhaustion and circuit-open states (stable error codes + retry metadata).
- [x] 1.3 Update API response handling so degraded errors are returned consistently across WIP/Resource/Dashboard endpoints.
- [x] 1.4 Extend frontend `MesApi` retry/backoff policy to respect degraded error codes and avoid aggressive retries under pool exhaustion.
## 2. P0 Observability and Recovery Controls
- [x] 2.1 Extend `/health` and `/health/deep` payloads with pool configuration, saturation indicators, and degradation reason classification.
- [x] 2.2 Expose runtime-resilience diagnostics in admin status API for operations triage.
- [x] 2.3 Ensure hot-reload/restart controls preserve single-port availability and return actionable status for watchdog-driven recovery.
## 3. P1 Cache and Query Efficiency (Keep Full-Table Cache)
- [x] 3.1 Preserve `resource/wip` full-table cache as authoritative baseline while introducing indexed lookup helpers for high-frequency access paths.
- [x] 3.2 Reduce repeated full-array merge cost in resource status composition by using prebuilt lookup/index structures.
- [x] 3.3 Add cache version-coupled rebuild/update flow for derived indices and expose telemetry for index freshness.
## 4. P1 Frontend Compute Shift Expansion
- [x] 4.1 Refactor compute-heavy display transformations into reusable frontend core modules.
- [x] 4.2 Add parity fixtures/tests for newly shifted computations with explicit tolerance contracts.
- [x] 4.3 Ensure migrated pages preserve existing tab/drill-down behavior while consuming shared Vite modules.
## 5. P2 Conda/Systemd/Watchdog Runtime Alignment
- [x] 5.1 Align systemd service templates and runtime paths with conda-based execution model.
- [x] 5.2 Align startup/deploy scripts, watchdog config, and documentation to a single runtime contract.
- [x] 5.3 Define and document alert thresholds for sustained degraded state, restart churn, and retry pressure.
## 6. Validation and Migration Gates
- [x] 6.1 Add/extend tests for pool exhaustion semantics, circuit-breaker fail-fast behavior, and degraded response contracts.
- [x] 6.2 Add/extend tests for indexed cache access and frontend compute parity.
- [x] 6.3 Update migration gate/runbook docs to include resilience checks, conda-systemd rehearsal, and rollback verification.

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-07

View File

@@ -0,0 +1,65 @@
## Context
`DashBoard_vite` 已完成主體搬遷,但報表頁仍處於混合狀態:
- `resource-status``resource-history` 等頁面已有 Vite 版本,卻存在實際行為缺陷。
- `wip_overview``wip_detail` 仍以 inline script 為主,尚未納入 Vite entry 與共用模組治理。
- 部分頁面仍有直接字串拼接輸出與原生 `fetch` 路徑,無法完整承接既有 `MesApi` 的降級重試契約。
此變更是「遷移後硬化」階段:不改變既有業務操作語意,但將效果對齊、模組化覆蓋與前端複用一起完成。
## Goals / Non-Goals
**Goals:**
- 讓 WIP 報表頁進入 Vite entry 管理,並保留目前 tab/drill-down 與 `onclick` 操作語意。
- 修復已遷移模組中會影響報表可用性的缺陷初始化、KPI、矩陣選取、API 呼叫路徑)。
- 強化共用路徑escape、欄位契約、MesApi/backoff以支撐後續前端運算擴展。
- 用測試明確覆蓋「asset exists -> module」、「asset missing -> fallback」的模板行為。
**Non-Goals:**
- 不改動後端路由設計與單一 port 服務模型。
- 不重寫 UI 視覺風格或更動既有商業邏輯判斷規則。
- 不在本次引入新的大型前端框架(維持 Vanilla + Vite entry 模式)。
## Decisions
### Decision 1: 採用「模板雙軌載入」完成 WIP 遷移
- 選擇:在 `wip_overview.html``wip_detail.html` 加入 `frontend_asset()` module 載入,保留既有 inline script 作 fallback。
- 理由:可在不破壞現場可用性的前提下,讓 Vite bundle 成為預設執行路徑,符合先前頁面遷移模式。
- 替代方案:直接刪除 inline script。
- 未採用原因:回退能力不足,且無法快速比對 parity。
### Decision 2: 模組保持全域 handler 相容層
- 選擇Vite entry 內對舊有 `onclick` 所需函式維持 `window` 綁定,避免模板同步大改。
- 理由:降低一次性改動範圍,先確保行為完全對齊,再逐步收斂事件綁定方式。
- 替代方案:全面改為 addEventListener 並移除 inline `onclick`
- 未採用原因:本次目標是 parity hardening不是互動模型重寫。
### Decision 3: 前端 API 路徑統一走 MesApi
- 選擇JSON API 優先走 `MesApi.get/post`(或 core api bridge僅 blob/download 等必要場景保留原生 fetch。
- 理由:沿用既有降級錯誤碼與 retry/backoff 策略,避免 pool exhausted 時前端重試失控。
- 替代方案:維持頁面各自 `fetch`
- 未採用原因:會破壞 resilience contract一致性不足。
### Decision 4: 字串輸出與欄位命名同步納入治理
- 選擇:針對動態 HTML 內容補 escape並對照 field contract 驗證表格欄位與下載標頭語意一致。
- 理由:遷移期間常見 XSS/欄位漂移問題,必須和模組化同時收斂。
## Risks / Trade-offs
- [Risk] 大型 inline script 搬入 module 時可能出現作用域差異 → Mitigation: 先保留 fallback並針對 `window` handler 做顯式綁定。
- [Risk] 模組與 fallback 並存造成測試分支增加 → Mitigation: 以 template integration 測試固定兩條路徑行為。
- [Risk] escape 補強可能改變少數欄位原始顯示格式 → Mitigation: 僅針對 HTML 注入風險欄位處理,保留 NULL/日期等既有顯示語意。
- [Risk] 前端改走 MesApi 使錯誤提示型態改變 → Mitigation: 保持原錯誤訊息文案,僅替換底層請求路徑。
## Migration Plan
1. 先完成 OpenSpec task 分解與可執行順序。
2. 新增 WIP Vite entries更新 vite config模板加上 module/fallback 雙軌。
3. 修復 `resource-history``resource-status` 關鍵缺陷並補安全性修正。
4. Build + pytest 驗證,更新 task 勾選。
5. 交付變更摘要與剩餘風險,供後續 archive。
## Open Questions
- 是否需要在下一階段移除 WIP fallback inline script目前先保留作為回退機制
- 是否要擴充前端單元測試Vitest覆蓋更多 DOM 互動,而不只依賴後端模板整合測試。

View File

@@ -0,0 +1,28 @@
## Why
目前仍有部分報表頁維持大型 inline script且已遷移的 Vite 模組存在實際行為缺口(例如 KPI 0% 呈現、矩陣篩選選取、模組作用域匯出失敗)。這造成「舊版 Jinja 報表效果」與「新架構模組化」之間存在落差,無法完全發揮 Vite 在複用、可維護性與前端運算轉移的優勢。
## What Changes
- 將 WIP Overview / WIP Detail 的報表互動完整納入 Vite entry保留既有頁面操作語意與 drill-down 路徑。
- 修復已遷移頁面的核心行為缺陷Resource History 模組初始化、Resource Status KPI 與矩陣交互)。
- 統一報表前端 API 呼叫路徑,優先透過 `MesApi` 以承接既有 retry/backoff 與降級錯誤契約。
- 補強報表頁字串輸出安全與欄位契約一致性,確保畫面欄位、查詢結果與下載欄位名稱一致。
- 新增/調整模板整合驗證,確保 Vite 模組載入與 fallback 行為在報表頁完整覆蓋。
## Capabilities
### New Capabilities
- `report-effects-parity`: 定義舊版 Jinja 報表在新 Vite 架構下的效果對齊要求圖表、篩選、表格、KPI、互動與下載語意
### Modified Capabilities
- `full-vite-page-modularization`: 擴展到 WIP 報表頁完整模組化與 fallback 覆蓋。
- `frontend-compute-shift`: 擴大前端運算承載並修復前端計算與呈現邏輯缺陷。
- `field-contract-governance`: 強化欄位名稱與匯出標頭一致性及頁面渲染安全。
- `runtime-resilience-recovery`: 明確要求前端呼叫在降級/壓力情境下遵循退避契約。
## Impact
- Affected code: `frontend/src/`, `frontend/vite.config.js`, `src/mes_dashboard/templates/`, `tests/test_template_integration.py`
- Affected runtime behavior: 報表頁 JS 載入模式、矩陣/篩選互動、KPI 顯示與下載欄位對齊。
- Affected operations: 單一對外 port 架構不變,仍由 Flask/Gunicorn 提供頁面與 Vite build 輸出資產。

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Dynamic Report Rendering MUST Sanitize Untrusted Values
Dynamic table/list rendering in report and query pages SHALL sanitize untrusted text before injecting HTML.
#### Scenario: HTML-like payload in query result
- **WHEN** an API result field contains HTML-like text payload
- **THEN** the rendered page MUST display escaped text and MUST NOT execute embedded script content
### Requirement: UI Table and Download Headers SHALL Follow the Same Field Contract
Page table headers and exported file headers SHALL map to the same field contract definition for the same dataset.
#### Scenario: Header consistency check
- **WHEN** users view a report table and then export the corresponding data
- **THEN** header labels MUST remain semantically aligned and avoid conflicting naming for identical fields

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Frontend Compute Paths MUST Handle Zero and Boundary Values Correctly
Frontend-computed report metrics SHALL preserve valid zero values and boundary conditions in user-visible KPI and summary components.
#### Scenario: Zero-value KPI rendering
- **WHEN** OU% or availability metrics are computed as `0`
- **THEN** the page MUST render `0%` (or configured numeric format) instead of placeholder values
### Requirement: Hierarchical Filter Compute Logic SHALL Be Deterministic Across Levels
Frontend matrix/filter computations SHALL produce deterministic selection and filtering outcomes for group, family, and resource levels.
#### Scenario: Matrix selection at multiple hierarchy levels
- **WHEN** users toggle matrix cells across group, family, and resource rows
- **THEN** selected-state rendering and filtered equipment result sets MUST remain level-correct and reversible

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: WIP Report Pages SHALL Be Served by Vite Modules
The system SHALL provide Vite entry bundles for WIP overview and WIP detail pages, with template-level asset resolution.
#### Scenario: WIP module asset available
- **WHEN** the built asset exists in backend static dist
- **THEN** the page MUST load behavior from the corresponding Vite module entry
#### Scenario: WIP module asset unavailable
- **WHEN** the built asset is not present
- **THEN** the page MUST retain equivalent behavior through explicit inline fallback logic
### Requirement: Vite Modules MUST Preserve Legacy Handler Compatibility
Vite report modules SHALL expose required global handlers for existing inline entry points until event wiring is fully migrated.
#### Scenario: Inline-triggered handler compatibility
- **WHEN** a template control invokes existing global handler names
- **THEN** the migrated module MUST provide compatible callable handlers without runtime scope errors

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Report Effect Parity SHALL Be Preserved During Vite Migration
The system SHALL preserve existing Jinja-era report interactions when report pages are served by Vite modules.
#### Scenario: WIP overview interactions remain equivalent
- **WHEN** users operate WIP overview filters, KPI cards, chart refresh, and drill-down entry
- **THEN** the resulting state transitions and navigation parameters MUST remain behaviorally equivalent to the baseline page logic
#### Scenario: WIP detail interactions remain equivalent
- **WHEN** users operate WIP detail filters, pagination, lot detail popup, and back-to-overview transitions
- **THEN** the resulting data scope and interaction behavior MUST match baseline semantics
### Requirement: Report Visual Semantics MUST Remain Consistent
Report pages SHALL keep established status color semantics, KPI display rules, and table/chart synchronization behavior after migration.
#### Scenario: KPI and matrix state consistency
- **WHEN** metric values are zero or filters target specific matrix levels
- **THEN** KPI values and selected-state highlights MUST render correctly without collapsing valid zero values or losing selection state

View File

@@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: Report Frontend API Access SHALL Honor Degraded Retry Contracts
Report pages SHALL use retry-aware API access paths for JSON endpoints so degraded backend responses propagate retry metadata to UI behavior.
#### Scenario: Pool exhaustion or circuit-open response
- **WHEN** report API endpoints return degraded error codes with retry hints
- **THEN** frontend calls MUST flow through MesApi-compatible behavior and avoid aggressive uncontrolled retry loops

View File

@@ -0,0 +1,28 @@
## 1. OpenSpec Scope and Parity Baseline
- [x] 1.1 Confirm report parity target pages and interaction scope (WIP overview/detail, resource status/history, query pages).
- [x] 1.2 Capture concrete parity defects in current Vite modules (runtime errors, KPI/matrix mismatch, API path inconsistency).
## 2. WIP Pages Vite Modularization
- [x] 2.1 Add Vite entries for `wip-overview` and `wip-detail`.
- [x] 2.2 Update templates to load `frontend_asset(...)` module bundles with inline fallback retention.
- [x] 2.3 Preserve legacy global handler compatibility for existing inline-triggered actions.
## 3. Report Behavior and Compute Fixes
- [x] 3.1 Fix `resource-history` module initialization/export scope error.
- [x] 3.2 Fix `resource-status` matrix selection logic and KPI zero-value rendering parity.
- [x] 3.3 Align report JSON API calls to MesApi-compatible paths for degraded retry behavior.
## 4. Field Contract and Rendering Hardening
- [x] 4.1 Patch dynamic table/query rendering to escape untrusted values.
- [x] 4.2 Verify UI table headers and export header naming consistency for touched report flows.
- [x] 4.3 Fix missing report style tokens affecting visual consistency.
## 5. Validation and Regression Guard
- [x] 5.1 Build frontend bundles and ensure new entries are emitted into backend static dist.
- [x] 5.2 Extend/update template integration tests for WIP module/fallback behavior.
- [x] 5.3 Run focused pytest suite for template/frontend/report regressions and record outcomes.

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-07

View File

@@ -0,0 +1,67 @@
## Context
`DashBoard_vite` 已完成單一 port 的 Flask + Vite 架構整併並具備降級回應、circuit breaker、watchdog 熱重啟與多層快取。
目前主要缺口不是功能不存在,而是「運維可操作性」與「前端治理粒度」:
1. health/admin 雖有狀態,但缺少門檻與建議動作,值班時仍需人工判讀。
2. watchdog 僅保留最後一次重啟紀錄,無法直接判斷短時間 churn。
3. WIP overview/detail 仍有 autocomplete/filter 搜尋邏輯重複,後續擴展成本高。
4. README 需要明確反映最新架構契約與改善策略,避免文件落後於實作。
## Goals / Non-Goals
**Goals:**
- 提供可操作的韌性診斷輸出thresholds、churn、recovery recommendation
- 保持既有單 port 與手動重啟控制模型,不引入高風險自動重啟風暴。
- 抽離 WIP 頁面共用 autocomplete/filter 查詢邏輯到 Vite core降低重複。
- 新增對應測試與文件更新,讓 gate 與 README 可驗證。
**Non-Goals:**
- 不做整站 SPA rewrite。
- 不改動既有 drill-down 路徑與使用者操作語意。
- 不預設啟用「條件達成即自動重啟 worker」的強制策略。
## Decisions
1. 韌性診斷採「可觀測 + 建議」而非預設自動重啟
- Decision: 在 `/health``/health/deep``/admin/api/system-status``/admin/api/worker/status` 增加 thresholds/churn/recommendation。
- Rationale: 目前已具備 degraded response + backoff + admin restart先提升判讀與操作性避免未設防的自動重啟造成抖動。
- Alternative considered: 直接在 pool exhausted 時自動重啟 worker未採用因 root cause 多為慢查詢/瞬時壅塞,重啟治標不治本且有風暴風險。
2. watchdog state 擴充最近重啟歷史
- Decision: 在 state 檔保留 bounded restart history 並計算 churn summary。
- Rationale: 提供運維端可觀測的重啟密度訊號,支援告警與 runbook 決策。
- Alternative considered: 僅依日誌分析;未採用,因 API 需要機器可讀狀態。
3. WIP autocomplete/filter 抽共用核心模組
- Decision: 新增 `frontend/src/core/autocomplete.js`,由 `wip-overview` / `wip-detail` 共用。
- Rationale: 保留既有 API 與頁面互動語意,同時降低重複與 bug 修補成本。
- Alternative considered: 全量頁面元件化框架重寫;未採用,因超出本次風險與範圍。
4. README 架構契約同步
- Decision: 更新 README並提供 `README.mdj` 鏡像)記錄新的韌性診斷與前端共用模組策略。
- Rationale: 交付後文件應可直接支援運維與交接。
## Risks / Trade-offs
- [Risk] 韌性輸出欄位增加可能影響依賴固定 schema 的外部腳本
- Mitigation: 採向後相容擴充,不移除既有欄位。
- [Risk] 共用 autocomplete 模組抽離後可能引入搜尋參數差異
- Mitigation: 保持原有欄位映射與 cross-filter 規則,並補單元測試覆蓋。
- [Risk] restart history 持久化不當可能造成 state 膨脹
- Mitigation: 使用 bounded history固定上限與窗口彙總。
## Migration Plan
1. 實作 resilience diagnosticsthresholds/churn/recommendation與 watchdog state 擴充。
2. 更新 health/admin API 輸出並補測試。
3. 抽離前端 autocomplete 共用模組,更新 WIP 頁面引用並執行 Vite build。
4. 更新 README/README.mdj 與 runbook 對應段落。
5. 執行 focused pytest + frontend build 驗證,確認單 port 契約不變。
## Open Questions
- 是否在下一階段將 recommendation 與告警 webhookSlack/Teams直接整合
- 是否要把 restart churn 門檻與 UI 告警顏色標準化到 admin/performance 頁?

View File

@@ -0,0 +1,40 @@
## Why
Vite migration已完成主要功能遷移但目前仍有兩個可見風險一是運維端缺少「可操作」的韌性判斷僅有狀態缺少建議動作與重啟 churn 訊號);二是前端主要報表頁仍存在可抽離的重複互動邏輯,會增加後續維護成本。現在補齊這兩塊,可在不改變既有使用流程下提高穩定性與可演進性。
## What Changes
- 擴充 runtime resilience 診斷契約:在 health/admin payload 提供門檻設定、重啟 churn 與可行動建議。
- 強化 watchdog state保留最近重啟歷史支持 churn 計算與觀測。
- 將 WIP overview/detail 重複的 autocomplete/filter 查詢邏輯抽成共用 Vite core 模組。
- 增加前端核心模組與韌性診斷的測試覆蓋。
- 更新專案說明文件README反映最新架構、治理策略與操作準則。
## Capabilities
### New Capabilities
- None.
### Modified Capabilities
- `runtime-resilience-recovery`: 新增重啟 churn 與復原建議契約,讓降級狀態具備可操作的 runbook 訊號。
- `full-vite-page-modularization`: 新增 WIP 報表共用 autocomplete/filter building blocks 要求,降低頁面重複實作。
- `migration-gates-and-rollout`: 新增文件與前端治理 gate確保架構說明與實際部署契約一致。
## Impact
- Affected code:
- `src/mes_dashboard/routes/health_routes.py`
- `src/mes_dashboard/routes/admin_routes.py`
- `scripts/worker_watchdog.py`
- `frontend/src/core/`
- `frontend/src/wip-overview/main.js`
- `frontend/src/wip-detail/main.js`
- `tests/`
- `README.md`(以及使用者要求的 README.mdj
- APIs:
- `/health`
- `/health/deep`
- `/admin/api/system-status`
- `/admin/api/worker/status`
- Operational behavior:
- 保持單一 port 與既有手動重啟流程;新增觀測與建議,不預設啟用自動重啟風暴風險。

View File

@@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: WIP Modules SHALL Reuse Shared Autocomplete and Filter Query Utilities
WIP overview and WIP detail Vite entry modules SHALL use shared frontend core utilities for autocomplete request construction and cross-filter behavior.
#### Scenario: Cross-filter autocomplete parity across WIP pages
- **WHEN** users type in workorder/lot/package/type filters on either WIP overview or WIP detail pages
- **THEN** both pages MUST generate equivalent autocomplete request parameters and return behaviorally consistent dropdown results
#### Scenario: Shared utility change propagates across both pages
- **WHEN** autocomplete mapping rules are updated in the shared core module
- **THEN** both WIP overview and WIP detail modules MUST consume the updated behavior without duplicated page-local logic edits

View File

@@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: Migration Gates SHALL Enforce Architecture Documentation Consistency
Cutover governance MUST include verification that runtime architecture contracts documented for operators match implemented deployment and resilience behavior.
#### Scenario: Documentation gate before release
- **WHEN** release gates are executed for a migration or hardening change
- **THEN** project README artifacts MUST be updated to reflect current single-port runtime contract, resilience diagnostics, and frontend modularization strategy
#### Scenario: Gate fails on stale architecture contract
- **WHEN** implementation introduces resilience or module-governance changes but README architecture section remains outdated
- **THEN** release governance MUST treat the gate as failed until documentation is aligned

View File

@@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: Runtime Resilience Diagnostics MUST Expose Actionable Signals
The system MUST expose machine-readable resilience thresholds, restart-churn indicators, and operator action recommendations so degraded states can be triaged consistently.
#### Scenario: Health payload includes resilience diagnostics
- **WHEN** clients call `/health` or `/health/deep`
- **THEN** responses MUST include resilience thresholds and a recommendation field describing whether to observe, throttle, or trigger controlled worker recovery
#### Scenario: Admin status includes restart churn summary
- **WHEN** operators call `/admin/api/system-status` or `/admin/api/worker/status`
- **THEN** responses MUST include bounded restart history summary within a configured time window and indicate whether churn threshold is exceeded

View File

@@ -0,0 +1,23 @@
## 1. Runtime Resilience Diagnostics Hardening
- [x] 1.1 Add shared resilience threshold/recommendation helpers for health/admin payloads.
- [x] 1.2 Extend watchdog restart state to include bounded restart history and churn summary.
- [x] 1.3 Expose thresholds/churn/recommendation fields in `/health`, `/health/deep`, `/admin/api/system-status`, and `/admin/api/worker/status`.
## 2. Frontend WIP Module Reuse
- [x] 2.1 Add shared Vite core autocomplete/filter utility module.
- [x] 2.2 Refactor WIP overview/detail modules to consume shared autocomplete utilities while preserving behavior.
- [x] 2.3 Verify Vite build output remains valid for single-port backend delivery.
## 3. Validation Coverage
- [x] 3.1 Add backend tests for resilience diagnostics and restart churn telemetry contracts.
- [x] 3.2 Add frontend tests for shared autocomplete request parameter behavior.
- [x] 3.3 Run focused backend/frontend validation commands and record pass results.
## 4. Documentation Alignment
- [x] 4.1 Update `README.md` architecture/operations sections to reflect latest resilience and frontend-governance model.
- [x] 4.2 Add/update `README.mdj` to mirror latest architecture contract for your requested documentation path.
- [x] 4.3 Update migration runbook notes to include documentation-alignment gate.

20
openspec/config.yaml Normal file
View File

@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours

View File

@@ -0,0 +1,38 @@
## Purpose
Define stable requirements for cache-observability-hardening.
## Requirements
### Requirement: Layered Cache SHALL Expose Operational State
The route cache implementation SHALL expose layered cache operational state, including mode, freshness, and degradation status.
#### Scenario: Redis unavailable degradation state
- **WHEN** Redis is unavailable
- **THEN** health endpoints MUST indicate degraded cache mode while keeping L1 memory cache active
### Requirement: Cache Telemetry MUST be Queryable for Operations
The system MUST provide cache telemetry suitable for operations diagnostics.
#### Scenario: Telemetry inspection
- **WHEN** operators request deep health status
- **THEN** cache-related metrics/state SHALL be present and interpretable for troubleshooting
### Requirement: Health Endpoints SHALL Expose Pool Saturation and Degradation Reason Codes
Operational health endpoints MUST report connection pool saturation indicators and explicit degradation reason codes.
#### Scenario: Pool saturation observed
- **WHEN** checked-out connections and overflow approach configured limits
- **THEN** deep health output MUST expose saturation metrics and degraded reason classification
### Requirement: Degraded Responses MUST Be Correlatable Across API and Health Telemetry
Error responses for degraded states SHALL include stable codes that can be mapped to health telemetry and operational dashboards.
#### Scenario: Degraded API response correlation
- **WHEN** an API request fails due to circuit-open or pool-exhausted conditions
- **THEN** operators MUST be able to match the response code to current health telemetry state
### Requirement: Operational Alert Thresholds SHALL Be Explicitly Defined
The system MUST define alert thresholds for sustained degraded state, repeated worker recovery, and abnormal retry pressure.
#### Scenario: Sustained degradation threshold exceeded
- **WHEN** degraded status persists beyond configured duration
- **THEN** the monitoring contract MUST classify the service as alert-worthy with actionable context

View File

@@ -0,0 +1,26 @@
# conda-systemd-runtime-alignment Specification
## Purpose
TBD - created by archiving change stability-and-frontend-compute-shift. Update Purpose after archive.
## Requirements
### Requirement: Production Service Runtime SHALL Use Conda-Aligned Execution Paths
Service units and operational scripts MUST run with a consistent conda-managed Python runtime.
#### Scenario: Service unit starts application
- **WHEN** systemd starts the dashboard service and watchdog
- **THEN** both processes MUST execute using the configured conda environment binaries and paths
### Requirement: Watchdog and Runtime Paths MUST Be Operationally Consistent
PID files, restart flag paths, state files, and worker control interfaces SHALL be consistent across scripts, environment variables, and systemd units.
#### Scenario: Watchdog handles restart flag
- **WHEN** a restart flag is written by admin control endpoints
- **THEN** watchdog MUST read the same configured path set and signal the correct Gunicorn master process
### Requirement: Deployment Documentation MUST Match Runtime Contract
Runbooks and deployment documentation MUST describe the same conda/systemd/watchdog contract used by the deployed system.
#### Scenario: Operator follows deployment runbook
- **WHEN** an operator performs deploy, health check, and rollback from documentation
- **THEN** documented commands and paths MUST work without requiring venv-specific assumptions

Some files were not shown because too many files have changed in this diff Show More