Files
ewoooc/routes/system_routes.py
ogt 1b4f3a7bbe
Some checks failed
CD Pipeline / deploy (push) Failing after 59s
feat: EwoooC 初始化 — 完整專案推版至 Gitea
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml)
- 部署模式: rsync Python 檔案至 188 → docker restart (volume mount)
- Dockerfile/requirements 變動時自動重建 Docker image
- 部署通知: Telegram (開始/成功/失敗)
- 健康檢查: https://mo.wooo.work/health (最多 5 次重試)
- 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 01:21:13 +08:00

1159 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
系統管理路由模組
包含:設定頁面、日誌、備份、健康檢查、分類管理 API 等
"""
import os
import json
import time
import shutil
import zipfile
from datetime import datetime, timezone, timedelta
from flask import Blueprint, render_template, request, jsonify, send_from_directory, url_for
from auth import login_required
from sqlalchemy import text
from config import SYSTEM_VERSION, BASE_DIR, LOG_FILE_PATH, public_url
from database.manager import DatabaseManager
from services.logger_manager import SystemLogger
from utils.validators import safe_join
# 時區設定
TAIPEI_TZ = timezone(timedelta(hours=8))
# Logger
sys_log = SystemLogger("SystemRoutes").get_logger()
# Blueprint 定義
system_bp = Blueprint('system', __name__)
# ==========================================
# 分類管理相關
# ==========================================
CATEGORIES_JSON_PATH = os.path.join(BASE_DIR, 'data', 'categories.json')
def load_categories():
"""從 JSON 檔案載入分類列表"""
try:
with open(CATEGORIES_JSON_PATH, 'r', encoding='utf-8') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def save_categories(categories):
"""將分類列表儲存到 JSON 檔案"""
with open(CATEGORIES_JSON_PATH, 'w', encoding='utf-8') as f:
json.dump(categories, f, ensure_ascii=False, indent=4)
def load_scheduler_stats():
"""讀取排程統計資料"""
stats_path = os.path.join(BASE_DIR, 'data', 'scheduler_stats.json')
if os.path.exists(stats_path):
try:
with open(stats_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (IOError, json.JSONDecodeError):
return {}
return {}
# ==========================================
# 頁面路由
# ==========================================
@system_bp.route('/settings')
@login_required
def settings():
"""分類設定頁面"""
categories = load_categories()
return render_template('settings.html',
categories=categories,
public_url=public_url,
system_version=SYSTEM_VERSION)
@system_bp.route('/system_settings')
@login_required
def system_settings_page():
"""系統設定與匯入頁面"""
now_taipei = datetime.now(TAIPEI_TZ)
return render_template('system_settings.html',
system_version=SYSTEM_VERSION,
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'))
@system_bp.route('/logs')
@login_required
def show_logs():
"""系統日誌頁面"""
now_taipei = datetime.now(TAIPEI_TZ)
return render_template('logs.html',
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'))
# ==========================================
# 分類管理 API
# ==========================================
@system_bp.route('/api/categories', methods=['POST'])
@login_required
def add_category():
"""API: 新增分類"""
name = request.form.get('name')
url = request.form.get('url')
if not name or not url:
return jsonify({"status": "error", "message": "名稱和 URL 皆不可為空"}), 400
categories = load_categories()
new_id = int(time.time() * 1000) # 使用時間戳作為簡易唯一 ID
categories.append({'id': new_id, 'name': name, 'url': url})
save_categories(categories)
return jsonify({"status": "success", "message": "分類新增成功"})
@system_bp.route('/api/categories/<int:category_id>', methods=['PUT'])
@login_required
def update_category(category_id):
"""API: 更新分類"""
name = request.form.get('name')
url = request.form.get('url')
if not name or not url:
return jsonify({"status": "error", "message": "名稱和 URL 皆不可為空"}), 400
categories = load_categories()
category_found = False
for cat in categories:
if cat.get('id') == category_id:
cat['name'] = name
cat['url'] = url
category_found = True
break
if not category_found:
return jsonify({"status": "error", "message": "找不到指定的分類 ID"}), 404
save_categories(categories)
return jsonify({"status": "success", "message": "分類更新成功"})
@system_bp.route('/api/categories/<int:category_id>', methods=['DELETE'])
@login_required
def delete_category(category_id):
"""API: 刪除分類"""
categories = [cat for cat in load_categories() if cat.get('id') != category_id]
save_categories(categories)
return jsonify({"status": "success", "message": "分類刪除成功"})
@system_bp.route('/api/test_url', methods=['POST'])
@login_required
def test_url():
"""API: 測試網址是否有效"""
try:
data = request.get_json()
url = data.get('url')
if not url:
return jsonify({"status": "error", "message": "網址不能為空"}), 400
import requests
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
# 設定 10 秒超時,避免卡住
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
return jsonify({"status": "success", "message": f"連結有效 (Status: 200)"})
else:
return jsonify({"status": "warning", "message": f"連結回應異常 (Status: {response.status_code})"})
except Exception as e:
return jsonify({"status": "error", "message": f"連線失敗: {str(e)}"}), 500
# ==========================================
# 日誌 API
# ==========================================
@system_bp.route('/api/logs')
@login_required
def get_logs_api():
"""API: 取得系統日誌"""
if os.path.exists(LOG_FILE_PATH):
try:
with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f:
return jsonify({"logs": "".join(f.readlines()[-60:])})
except Exception as e:
sys_log.error(f"[Web] [Logs] 日誌 API 讀取異常 | Error: {e}")
return jsonify({"logs": "讀取日誌異常"})
return jsonify({"logs": "等待系統啟動中..."})
# ==========================================
# 健康檢查與監控
# ==========================================
@system_bp.route('/health')
def health_check():
"""
健康檢查端點,返回系統狀態
- 用於 Docker HEALTHCHECK
- 用於 Nginx upstream 健康檢查
- 用於負載均衡器健康檢查
"""
try:
# 檢查資料庫連線
db = DatabaseManager()
with db.get_session() as session:
session.execute(text("SELECT 1"))
return jsonify({
'status': 'healthy',
'timestamp': datetime.now(TAIPEI_TZ).isoformat(),
'version': SYSTEM_VERSION
}), 200
except Exception as e:
return jsonify({
'status': 'unhealthy',
'error': str(e),
'timestamp': datetime.now(TAIPEI_TZ).isoformat()
}), 503
@system_bp.route('/metrics')
def prometheus_metrics():
"""
Prometheus metrics endpoint - 暴露系統指標供 Prometheus 抓取
包含:資料庫大小、表記錄數、連線狀態等
"""
# 從 cache_service 導入慢查詢統計
from services.cache_service import get_slow_query_stats
try:
db = DatabaseManager()
metrics = []
# 1. 資料庫檔案大小
db_path = os.path.join(BASE_DIR, 'data', 'momo_database.db')
if os.path.exists(db_path):
db_size = os.path.getsize(db_path)
metrics.append(f'momo_database_size_bytes{{db="main"}} {db_size}')
# 2. WAL 檔案大小
wal_path = db_path + '-wal'
if os.path.exists(wal_path):
wal_size = os.path.getsize(wal_path)
metrics.append(f'momo_database_wal_size_bytes{{db="main"}} {wal_size}')
else:
metrics.append(f'momo_database_wal_size_bytes{{db="main"}} 0')
# 3. 資料表記錄數
with db.get_session() as session:
# Products 表
product_count = session.execute(text("SELECT COUNT(*) FROM products")).scalar() or 0
metrics.append(f'momo_table_rows{{table="products"}} {product_count}')
# PriceRecords 表
price_count = session.execute(text("SELECT COUNT(*) FROM price_records")).scalar() or 0
metrics.append(f'momo_table_rows{{table="price_records"}} {price_count}')
# MonthlySummaryAnalysis 表
try:
monthly_count = session.execute(text("SELECT COUNT(*) FROM monthly_summary_analysis")).scalar() or 0
metrics.append(f'momo_table_rows{{table="monthly_summary_analysis"}} {monthly_count}')
except:
metrics.append(f'momo_table_rows{{table="monthly_summary_analysis"}} 0')
# PromoProducts 表 (EDM)
try:
promo_count = session.execute(text("SELECT COUNT(*) FROM promo_products")).scalar() or 0
metrics.append(f'momo_table_rows{{table="promo_products"}} {promo_count}')
except:
metrics.append(f'momo_table_rows{{table="promo_products"}} 0')
# 4. 資料庫連線狀態
metrics.append('momo_database_up 1')
# 5. 今日新增商品數 (保持台北時區)
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
today_products = session.execute(
text("SELECT COUNT(*) FROM products WHERE created_at >= :today"),
{'today': today_start}
).scalar() or 0
metrics.append(f'momo_products_today_total {today_products}')
# 6. 今日價格變動記錄數
today_price_records = session.execute(
text("SELECT COUNT(*) FROM price_records WHERE timestamp >= :today"),
{'today': today_start}
).scalar() or 0
metrics.append(f'momo_price_records_today_total {today_price_records}')
# 7. 磁碟使用率
total, used, free = shutil.disk_usage(BASE_DIR)
metrics.append(f'momo_disk_total_bytes {total}')
metrics.append(f'momo_disk_used_bytes {used}')
metrics.append(f'momo_disk_free_bytes {free}')
# 8. 應用程式資訊
metrics.append(f'momo_app_info{{version="{SYSTEM_VERSION}"}} 1')
# 9. 慢查詢統計
slow_query_stats = get_slow_query_stats()
metrics.append(f'momo_query_total {slow_query_stats["total_queries"]}')
metrics.append(f'momo_query_slow_total {slow_query_stats["slow_queries"]}')
metrics.append(f'momo_query_very_slow_total {slow_query_stats["very_slow_queries"]}')
metrics.append(f'momo_query_time_total_ms {slow_query_stats["total_query_time_ms"]}')
# 10. 計算平均查詢時間
if slow_query_stats["total_queries"] > 0:
avg_query_time = slow_query_stats["total_query_time_ms"] / slow_query_stats["total_queries"]
metrics.append(f'momo_query_avg_time_ms {avg_query_time:.2f}')
else:
metrics.append('momo_query_avg_time_ms 0')
# 11. 慢查詢率 (百分比)
if slow_query_stats["total_queries"] > 0:
slow_rate = (slow_query_stats["slow_queries"] / slow_query_stats["total_queries"]) * 100
metrics.append(f'momo_query_slow_rate_percent {slow_rate:.2f}')
else:
metrics.append('momo_query_slow_rate_percent 0')
# 12. SQLite 連線池狀態 (使用 PRAGMA 查詢)
with db.get_session() as session:
try:
# 查詢 SQLite 頁面統計
page_count = session.execute(text("PRAGMA page_count")).scalar() or 0
page_size = session.execute(text("PRAGMA page_size")).scalar() or 4096
freelist_count = session.execute(text("PRAGMA freelist_count")).scalar() or 0
metrics.append(f'momo_sqlite_page_count {page_count}')
metrics.append(f'momo_sqlite_page_size {page_size}')
metrics.append(f'momo_sqlite_freelist_count {freelist_count}')
# 碎片率 (freelist / page_count * 100)
if page_count > 0:
fragmentation = (freelist_count / page_count) * 100
metrics.append(f'momo_sqlite_fragmentation_percent {fragmentation:.2f}')
else:
metrics.append('momo_sqlite_fragmentation_percent 0')
# 緩存命中率 (如果啟用)
try:
cache_stats = session.execute(text("PRAGMA cache_stats")).fetchone()
if cache_stats:
metrics.append(f'momo_sqlite_cache_hit {cache_stats[0] if cache_stats[0] else 0}')
metrics.append(f'momo_sqlite_cache_miss {cache_stats[1] if len(cache_stats) > 1 else 0}')
except:
pass
except Exception:
pass # PRAGMA 查詢失敗不影響其他指標
# 返回 Prometheus 格式
return '\n'.join(metrics) + '\n', 200, {'Content-Type': 'text/plain; charset=utf-8'}
except Exception as e:
# 資料庫連線失敗
return f'momo_database_up 0\nmomo_database_error{{error="{str(e)}"}} 1\n', 200, {'Content-Type': 'text/plain; charset=utf-8'}
# ==========================================
# 備份 API
# ==========================================
@system_bp.route('/api/backup', methods=['POST'])
@login_required
def trigger_backup():
"""API: 觸發系統完整備份"""
try:
sys_log.info("[System] [Backup] 開始執行系統完整備份...")
backup_dir = os.path.join(BASE_DIR, 'backups')
if not os.path.exists(backup_dir):
os.makedirs(backup_dir)
timestamp = datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')
zip_filename = f"momo_system_backup_{SYSTEM_VERSION}_{timestamp}.zip"
zip_filepath = os.path.join(backup_dir, zip_filename)
with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(BASE_DIR):
# 排除不必要的目錄
dirs[:] = [d for d in dirs if d not in ['backups', '__pycache__', 'venv', '.git', '.idea', '.vscode', 'node_modules']]
for file in files:
if file == zip_filename:
continue # 跳過正在寫入的檔案
if file.endswith('.pyc') or file.endswith('.DS_Store'):
continue
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, BASE_DIR)
zipf.write(file_path, arcname)
sys_log.info(f"[System] [Backup] 系統備份完成 | File: {zip_filename}")
# 回傳下載連結
download_url = url_for('system.download_backup', filename=zip_filename)
return jsonify({
"status": "success",
"message": f"備份成功!\n檔案已儲存為: {zip_filename}\n即將開始下載...",
"download_url": download_url
})
except Exception as e:
sys_log.error(f"[System] [Backup] 備份失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@system_bp.route('/api/backup/download/<path:filename>')
@login_required
def download_backup(filename):
"""
API: 下載備份檔案(已加入路徑遍歷防護)
"""
try:
backup_dir = os.path.join(BASE_DIR, 'backups')
# 使用 safe_join 驗證路徑,防止路徑遍歷攻擊
safe_path = safe_join(backup_dir, filename)
# 確保檔案存在
if not safe_path.exists():
sys_log.warning(f"[Security] 備份檔案不存在 | File: {filename}")
return jsonify({'error': '檔案不存在'}), 404
# 確保是檔案而非目錄
if not safe_path.is_file():
sys_log.warning(f"[Security] 嘗試下載非檔案路徑 | Path: {filename}")
return jsonify({'error': '非法路徑'}), 400
return send_from_directory(backup_dir, safe_path.name, as_attachment=True)
except ValueError as e:
# safe_join 偵測到路徑遍歷嘗試
sys_log.error(f"[Security] 路徑遍歷攻擊嘗試被阻擋 | Filename: {filename} | Error: {e}")
return jsonify({'error': '非法路徑'}), 400
except Exception as e:
sys_log.error(f"[System] 下載備份失敗 | Error: {e}")
return jsonify({'error': '下載失敗'}), 500
# ==========================================
# n8n 自動化運維 API (2026-01-25 新增)
# ==========================================
@system_bp.route('/api/system/cleanup', methods=['POST'])
def system_cleanup():
"""
API: 系統自動清理
- 用於 n8n 自動化運維
- 清理 Docker 資源、日誌、臨時檔案
"""
import subprocess
try:
data = request.get_json() or {}
actions = data.get('actions', ['docker_prune', 'log_rotate', 'temp_clean'])
level = data.get('level', 'warning')
results = []
# 1. Docker 清理
if 'docker_prune' in actions:
try:
# 清理未使用的映像、容器、網路
result = subprocess.run(
['docker', 'system', 'prune', '-af', '--filter', 'until=24h'],
capture_output=True, text=True, timeout=120
)
if result.returncode == 0:
# 解析釋放的空間
output = result.stdout
if 'Total reclaimed space:' in output:
space_line = [l for l in output.split('\n') if 'reclaimed' in l]
results.append(f"Docker 清理: {space_line[-1] if space_line else '完成'}")
else:
results.append("Docker 清理: 完成")
else:
results.append(f"Docker 清理: 失敗 - {result.stderr[:100]}")
except subprocess.TimeoutExpired:
results.append("Docker 清理: 超時")
except Exception as e:
results.append(f"Docker 清理: 錯誤 - {str(e)[:50]}")
# 2. 日誌輪轉
if 'log_rotate' in actions:
try:
log_dir = os.path.join(BASE_DIR, 'logs')
rotated = 0
for log_file in os.listdir(log_dir):
log_path = os.path.join(log_dir, log_file)
if os.path.isfile(log_path) and os.path.getsize(log_path) > 50 * 1024 * 1024: # > 50MB
# 重新命名舊日誌
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
os.rename(log_path, f"{log_path}.{timestamp}")
# 建立新的空日誌檔
open(log_path, 'w').close()
rotated += 1
results.append(f"日誌輪轉: 處理 {rotated} 個檔案")
except Exception as e:
results.append(f"日誌輪轉: 錯誤 - {str(e)[:50]}")
# 3. 臨時檔案清理
if 'temp_clean' in actions:
try:
temp_dir = os.path.join(BASE_DIR, 'data', 'temp')
if os.path.exists(temp_dir):
removed = 0
for item in os.listdir(temp_dir):
item_path = os.path.join(temp_dir, item)
if os.path.isfile(item_path):
# 只清理超過 1 天的檔案
if time.time() - os.path.getmtime(item_path) > 86400:
os.remove(item_path)
removed += 1
results.append(f"臨時檔案清理: 刪除 {removed} 個檔案")
else:
results.append("臨時檔案清理: 目錄不存在")
except Exception as e:
results.append(f"臨時檔案清理: 錯誤 - {str(e)[:50]}")
# 4. 如果是嚴重等級,執行更積極的清理
if level == 'critical':
try:
# 清理所有未使用的 Docker 資源(不限時間)
subprocess.run(['docker', 'system', 'prune', '-af'], timeout=180)
results.append("Critical 清理: Docker 全面清理完成")
except Exception as e:
results.append(f"Critical 清理: 失敗 - {str(e)[:50]}")
sys_log.info(f"[n8n] 系統清理完成 | Actions: {actions} | Results: {results}")
return jsonify({
'success': True,
'actions': actions,
'results': results,
'timestamp': datetime.now(TAIPEI_TZ).isoformat()
})
except Exception as e:
sys_log.error(f"[n8n] 系統清理失敗 | Error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@system_bp.route('/api/system/k8s/restart', methods=['POST'])
def k8s_restart():
"""
API: 重啟 K8s Deployment
- 用於 n8n 自動化運維
"""
import subprocess
try:
data = request.get_json() or {}
deployment = data.get('deployment', 'momo-app')
namespace = data.get('namespace', 'momo')
# 安全檢查:只允許重啟 momo namespace 的資源
if namespace != 'momo':
return jsonify({'success': False, 'error': '只允許操作 momo namespace'}), 403
allowed_deployments = ['momo-app', 'momo-scheduler']
if deployment not in allowed_deployments:
return jsonify({'success': False, 'error': f'不允許重啟 {deployment}'}), 403
result = subprocess.run(
['kubectl', 'rollout', 'restart', f'deployment/{deployment}', '-n', namespace],
capture_output=True, text=True, timeout=60
)
if result.returncode == 0:
sys_log.info(f"[n8n] K8s 重啟成功 | Deployment: {deployment}")
return jsonify({
'success': True,
'deployment': deployment,
'namespace': namespace,
'message': result.stdout
})
else:
sys_log.error(f"[n8n] K8s 重啟失敗 | Error: {result.stderr}")
return jsonify({'success': False, 'error': result.stderr}), 500
except subprocess.TimeoutExpired:
return jsonify({'success': False, 'error': '操作超時'}), 500
except Exception as e:
sys_log.error(f"[n8n] K8s 重啟錯誤 | Error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@system_bp.route('/api/system/crawler/status')
def crawler_status():
"""
API: 爬蟲執行狀態
- 用於 n8n 監控爬蟲健康
"""
try:
stats = load_scheduler_stats()
# 檢查最後執行時間
last_runs = {}
now = datetime.now(TAIPEI_TZ)
for task_name, task_stats in stats.items():
if isinstance(task_stats, dict) and 'last_run' in task_stats:
try:
last_run = datetime.fromisoformat(task_stats['last_run'].replace('Z', '+00:00'))
hours_ago = (now - last_run).total_seconds() / 3600
last_runs[task_name] = {
'last_run': task_stats['last_run'],
'hours_ago': round(hours_ago, 1),
'status': task_stats.get('status', 'unknown'),
'is_healthy': hours_ago < 24 # 超過 24 小時視為異常
}
except:
last_runs[task_name] = {'status': 'parse_error'}
all_healthy = all(t.get('is_healthy', False) for t in last_runs.values() if isinstance(t, dict))
return jsonify({
'healthy': all_healthy,
'tasks': last_runs,
'timestamp': now.isoformat()
})
except Exception as e:
sys_log.error(f"[n8n] 爬蟲狀態查詢失敗 | Error: {e}")
return jsonify({'healthy': False, 'error': str(e)}), 500
@system_bp.route('/api/system/backup/status')
def backup_status():
"""
API: 備份狀態檢查
- 用於 n8n 監控備份健康
"""
try:
backup_dir = os.path.join(BASE_DIR, 'backups')
if not os.path.exists(backup_dir):
return jsonify({
'healthy': False,
'error': '備份目錄不存在',
'last_backup': None
})
# 找到最新的備份檔案
backups = []
for f in os.listdir(backup_dir):
if f.endswith('.zip') or f.endswith('.sql') or f.endswith('.db'):
fpath = os.path.join(backup_dir, f)
backups.append({
'name': f,
'size': os.path.getsize(fpath),
'mtime': os.path.getmtime(fpath)
})
if not backups:
return jsonify({
'healthy': False,
'error': '沒有找到備份檔案',
'last_backup': None
})
# 找最新的
latest = max(backups, key=lambda x: x['mtime'])
hours_since_backup = (time.time() - latest['mtime']) / 3600
return jsonify({
'healthy': hours_since_backup < 48, # 48 小時內有備份視為健康
'last_backup': {
'name': latest['name'],
'size_mb': round(latest['size'] / 1024 / 1024, 2),
'hours_ago': round(hours_since_backup, 1)
},
'total_backups': len(backups)
})
except Exception as e:
sys_log.error(f"[n8n] 備份狀態查詢失敗 | Error: {e}")
return jsonify({'healthy': False, 'error': str(e)}), 500
@system_bp.route('/api/system/ssl/check')
def ssl_check():
"""
API: SSL 證書檢查
- 用於 n8n 監控證書到期
"""
import ssl
import socket
try:
domains = ['mo.wooo.work', 'monitor.wooo.work', 'ollama.wooo.work']
results = {}
for domain in domains:
try:
context = ssl.create_default_context()
with socket.create_connection((domain, 443), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
# 解析到期日
not_after = cert.get('notAfter', '')
if not_after:
# SSL 日期格式: 'Mar 25 12:00:00 2026 GMT'
expire_date = datetime.strptime(not_after, '%b %d %H:%M:%S %Y %Z')
days_left = (expire_date - datetime.utcnow()).days
results[domain] = {
'valid': True,
'expires': expire_date.strftime('%Y-%m-%d'),
'days_left': days_left,
'needs_renewal': days_left < 14
}
else:
results[domain] = {'valid': False, 'error': '無法取得到期日'}
except Exception as e:
results[domain] = {'valid': False, 'error': str(e)[:100]}
# 判斷整體健康狀態
all_healthy = all(
r.get('valid', False) and not r.get('needs_renewal', True)
for r in results.values()
)
return jsonify({
'healthy': all_healthy,
'certificates': results,
'timestamp': datetime.now(TAIPEI_TZ).isoformat()
})
except Exception as e:
sys_log.error(f"[n8n] SSL 檢查失敗 | Error: {e}")
return jsonify({'healthy': False, 'error': str(e)}), 500
@system_bp.route('/api/system/registry/health')
def registry_health():
"""
API: Docker Registry 健康檢查
- 用於 n8n 監控 Registry 狀態
"""
import requests
try:
# 檢查 Registry API (端口已改為 5002)
registry_url = 'http://127.0.0.1:5002/v2/'
response = requests.get(registry_url, timeout=10)
if response.status_code == 200:
return jsonify({
'healthy': True,
'status': 'healthy'
})
else:
return jsonify({
'healthy': False,
'status': 'unreachable',
'http_code': response.status_code
})
except requests.Timeout:
return jsonify({'healthy': False, 'status': 'timeout'})
except Exception as e:
sys_log.error(f"[n8n] Registry 健康檢查失敗 | Error: {e}")
return jsonify({'healthy': False, 'error': str(e)}), 500
# 保留舊的 Harbor 路由以兼容 (重導向)
@system_bp.route('/api/system/harbor/health')
def harbor_health_redirect():
"""舊 Harbor API - 重導向到 Registry"""
return registry_health()
# ==========================================
# PostgreSQL 慢查詢監控 API (2026-01-26 新增)
# ==========================================
@system_bp.route('/api/system/db/slow_queries')
def get_slow_queries():
"""
API: 取得 PostgreSQL 慢查詢列表
- 用於 n8n 慢查詢監控
- 需要啟用 pg_stat_statements 擴展
"""
from config import DATABASE_TYPE
try:
# 只支援 PostgreSQL
if DATABASE_TYPE != 'postgresql':
return jsonify({
'slow_queries': [],
'message': '此功能僅支援 PostgreSQL',
'database_type': DATABASE_TYPE
})
db = DatabaseManager()
slow_queries = []
with db.get_session() as session:
# 檢查 pg_stat_statements 是否可用
try:
check_ext = session.execute(
text("SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'")
).fetchone()
if check_ext:
# 查詢慢查詢 (平均執行時間 > 1 秒)
result = session.execute(text("""
SELECT
query,
calls,
total_exec_time as total_time,
mean_exec_time as mean_time,
rows,
shared_blks_hit,
shared_blks_read
FROM pg_stat_statements
WHERE mean_exec_time > 1000
ORDER BY mean_exec_time DESC
LIMIT 20
"""))
for row in result:
slow_queries.append({
'query': row[0][:500] if row[0] else '',
'calls': row[1],
'total_time': round(row[2], 2) if row[2] else 0,
'mean_time': round(row[3], 2) if row[3] else 0,
'rows': row[4],
'cache_hit_ratio': round(
row[5] / (row[5] + row[6]) * 100, 1
) if (row[5] + row[6]) > 0 else 100
})
else:
# pg_stat_statements 未啟用,使用 pg_stat_activity 查詢當前慢查詢
result = session.execute(text("""
SELECT
query,
state,
EXTRACT(EPOCH FROM (now() - query_start)) * 1000 as duration,
wait_event_type,
wait_event
FROM pg_stat_activity
WHERE state != 'idle'
AND query NOT LIKE '%pg_stat%'
AND EXTRACT(EPOCH FROM (now() - query_start)) > 5
ORDER BY query_start
LIMIT 10
"""))
for row in result:
slow_queries.append({
'query': row[0][:500] if row[0] else '',
'state': row[1],
'duration': round(row[2], 2) if row[2] else 0,
'wait_event_type': row[3],
'wait_event': row[4]
})
except Exception as e:
# 擴展不存在或查詢失敗
sys_log.warning(f"[DB] 慢查詢查詢失敗,使用備用方案 | Error: {e}")
# 備用方案:查詢長時間執行的查詢
result = session.execute(text("""
SELECT
query,
state,
EXTRACT(EPOCH FROM (now() - query_start)) * 1000 as duration
FROM pg_stat_activity
WHERE state != 'idle'
AND query NOT LIKE '%pg_stat%'
ORDER BY query_start
LIMIT 10
"""))
for row in result:
if row[2] and row[2] > 5000: # > 5 秒
slow_queries.append({
'query': row[0][:500] if row[0] else '',
'state': row[1],
'duration': round(row[2], 2)
})
return jsonify({
'slow_queries': slow_queries,
'count': len(slow_queries),
'timestamp': datetime.now(TAIPEI_TZ).isoformat()
})
except Exception as e:
sys_log.error(f"[DB] 慢查詢 API 錯誤 | Error: {e}")
return jsonify({'slow_queries': [], 'error': str(e)}), 500
@system_bp.route('/api/system/db/optimize', methods=['POST'])
def optimize_database():
"""
API: 執行資料庫優化 (VACUUM ANALYZE)
- 用於 n8n 自動修復慢查詢
- 清理死元組、更新統計資訊
"""
from config import DATABASE_TYPE
try:
data = request.get_json() or {}
action = data.get('action', 'vacuum_analyze')
auto_triggered = data.get('auto_triggered', False)
if DATABASE_TYPE == 'postgresql':
db = DatabaseManager()
with db.get_session() as session:
# PostgreSQL: 使用 autocommit 執行 VACUUM
# 注意VACUUM 不能在事務中執行
connection = session.connection()
connection.execution_options(isolation_level="AUTOCOMMIT")
results = []
if action in ['vacuum', 'vacuum_analyze']:
try:
# 執行 VACUUM
session.execute(text("VACUUM"))
results.append("VACUUM 完成")
sys_log.info(f"[DB] VACUUM 執行成功 | auto={auto_triggered}")
except Exception as e:
results.append(f"VACUUM 失敗: {str(e)[:100]}")
sys_log.error(f"[DB] VACUUM 失敗 | Error: {e}")
if action in ['analyze', 'vacuum_analyze']:
try:
# 執行 ANALYZE
session.execute(text("ANALYZE"))
results.append("ANALYZE 完成")
sys_log.info(f"[DB] ANALYZE 執行成功 | auto={auto_triggered}")
except Exception as e:
results.append(f"ANALYZE 失敗: {str(e)[:100]}")
sys_log.error(f"[DB] ANALYZE 失敗 | Error: {e}")
# 重置 pg_stat_statements 統計(如果存在)
if action == 'reset_stats':
try:
session.execute(text("SELECT pg_stat_statements_reset()"))
results.append("統計已重置")
except Exception:
pass # 擴展可能不存在
return jsonify({
'success': True,
'action': action,
'results': results,
'auto_triggered': auto_triggered,
'timestamp': datetime.now(TAIPEI_TZ).isoformat()
})
elif DATABASE_TYPE == 'sqlite':
# SQLite: 執行 VACUUM
db = DatabaseManager()
with db.get_session() as session:
session.execute(text("VACUUM"))
session.execute(text("ANALYZE"))
sys_log.info(f"[DB] SQLite 優化完成 | auto={auto_triggered}")
return jsonify({
'success': True,
'action': action,
'results': ['VACUUM 完成', 'ANALYZE 完成'],
'timestamp': datetime.now(TAIPEI_TZ).isoformat()
})
else:
return jsonify({
'success': False,
'error': f'不支援的資料庫類型: {DATABASE_TYPE}'
}), 400
except Exception as e:
sys_log.error(f"[DB] 資料庫優化失敗 | Error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@system_bp.route('/api/system/db/stats')
def database_stats():
"""
API: 資料庫統計資訊
- 連線數、快取命中率、資料表大小等
"""
from config import DATABASE_TYPE
try:
db = DatabaseManager()
stats = {'database_type': DATABASE_TYPE}
with db.get_session() as session:
if DATABASE_TYPE == 'postgresql':
# 連線數
conn_result = session.execute(text("""
SELECT count(*) as total,
count(*) FILTER (WHERE state = 'active') as active,
count(*) FILTER (WHERE state = 'idle') as idle
FROM pg_stat_activity
""")).fetchone()
stats['connections'] = {
'total': conn_result[0],
'active': conn_result[1],
'idle': conn_result[2]
}
# 快取命中率
cache_result = session.execute(text("""
SELECT
sum(heap_blks_read) as heap_read,
sum(heap_blks_hit) as heap_hit,
sum(idx_blks_read) as idx_read,
sum(idx_blks_hit) as idx_hit
FROM pg_statio_user_tables
""")).fetchone()
heap_total = (cache_result[0] or 0) + (cache_result[1] or 0)
idx_total = (cache_result[2] or 0) + (cache_result[3] or 0)
stats['cache_hit_ratio'] = {
'heap': round((cache_result[1] / heap_total * 100), 2) if heap_total > 0 else 100,
'index': round((cache_result[3] / idx_total * 100), 2) if idx_total > 0 else 100
}
# 資料庫大小
size_result = session.execute(text("""
SELECT pg_database_size(current_database())
""")).scalar()
stats['database_size_mb'] = round(size_result / 1024 / 1024, 2)
# 死元組數量
dead_result = session.execute(text("""
SELECT sum(n_dead_tup) FROM pg_stat_user_tables
""")).scalar()
stats['dead_tuples'] = dead_result or 0
else:
# SQLite 統計
page_count = session.execute(text("PRAGMA page_count")).scalar()
page_size = session.execute(text("PRAGMA page_size")).scalar()
stats['database_size_mb'] = round((page_count * page_size) / 1024 / 1024, 2)
stats['page_count'] = page_count
stats['page_size'] = page_size
stats['timestamp'] = datetime.now(TAIPEI_TZ).isoformat()
return jsonify(stats)
except Exception as e:
sys_log.error(f"[DB] 統計資訊查詢失敗 | Error: {e}")
return jsonify({'error': str(e)}), 500
# ==========================================
# 監控總覽 API (供 Monitor 頁面使用)
# ==========================================
@system_bp.route('/api/system/monitor/overview')
def monitor_overview():
"""
API: 監控總覽 - 整合所有監控狀態
- 供 Monitor 頁面即時更新使用
"""
import requests
overview = {
'timestamp': datetime.now(TAIPEI_TZ).isoformat(),
'services': {},
'alerts': [],
'n8n_workflows': []
}
# 1. 服務健康狀態
# 2026-02-13 修正:使用正確的 K8s 和 Docker 端點
# 注意API 運行在 K8s Pod 內127.0.0.1 指向 Pod 自身,需要使用主機 IP
services = [
('registry', 'https://registry.wooo.work/v2/'), # Docker Registry (HTTPS 域名,返回 401 表示正常)
('grafana', 'http://192.168.0.110:30030/api/health'), # K8s Grafana NodePort
('prometheus', 'http://10.43.25.78:9090/-/healthy'), # K8s Prometheus ClusterIP
('n8n', 'http://192.168.0.110:5678/healthz'), # n8n Docker 容器 (主機端口)
('momo_app', 'http://10.43.238.49/health'), # K8s momo-app ClusterIP
('superset', 'https://monitor.wooo.work/superset/health'), # Superset (通過 Nginx 代理)
]
for name, url in services:
try:
# 內部服務使用自簽憑證verify=False 是預期行為
resp = requests.get(url, timeout=5, verify=False) # nosec B501
# Registry 返回 401 (需要認證) 也視為健康
healthy_codes = [200, 204, 401] if name == 'registry' else [200, 204]
overview['services'][name] = {
'healthy': resp.status_code in healthy_codes,
'status_code': resp.status_code
}
except requests.Timeout:
overview['services'][name] = {'healthy': False, 'error': 'timeout'}
except Exception as e:
overview['services'][name] = {'healthy': False, 'error': str(e)[:50]}
# 2. Prometheus 告警 (使用 K8s ClusterIP)
try:
alerts_resp = requests.get(
'http://10.43.25.78:9090/api/v1/alerts', # K8s Prometheus ClusterIP
timeout=5
)
if alerts_resp.status_code == 200:
alerts_data = alerts_resp.json()
if alerts_data.get('status') == 'success':
for alert in alerts_data.get('data', {}).get('alerts', []):
if alert.get('state') == 'firing':
overview['alerts'].append({
'name': alert.get('labels', {}).get('alertname', 'Unknown'),
'severity': alert.get('labels', {}).get('severity', 'warning'),
'message': alert.get('annotations', {}).get('summary', ''),
'instance': alert.get('labels', {}).get('instance', '')
})
except Exception as e:
overview['alerts_error'] = str(e)[:100]
# 3. n8n 工作流程狀態 (使用主機端口)
try:
n8n_headers = {'X-N8N-API-KEY': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3YTAyMjk3NC02NmYwLTRhNDQtOTU2MS03MmNlMGFmMTBkNWUiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzY5Mzk5NjQyfQ.WBnHwbUf1-caKr53EB6Ew3--8QGOL5jMew3BtRRnUvY'}
workflows_resp = requests.get(
'http://192.168.0.110:5678/api/v1/workflows', # n8n Docker 容器 (主機端口)
headers=n8n_headers,
timeout=5
)
if workflows_resp.status_code == 200:
workflows_data = workflows_resp.json()
for wf in workflows_data.get('data', [])[:10]: # 只取前 10 個
overview['n8n_workflows'].append({
'id': wf.get('id'),
'name': wf.get('name'),
'active': wf.get('active', False)
})
except Exception as e:
overview['n8n_error'] = str(e)[:100]
# 4. 資料庫健康
try:
db = DatabaseManager()
with db.get_session() as session:
session.execute(text("SELECT 1"))
overview['services']['database'] = {'healthy': True}
except Exception as e:
overview['services']['database'] = {'healthy': False, 'error': str(e)[:50]}
return jsonify(overview)