Some checks failed
CD Pipeline / deploy (push) Failing after 59s
- 建立 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>
1159 lines
44 KiB
Python
1159 lines
44 KiB
Python
#!/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)
|