Spaces:
Sleeping
Sleeping
BrianChuan commited on
Commit ·
fb04509
1
Parent(s): 51c5a22
完成階段三Vue 前端 - 個人 Log 檢視頁面開發與修正後端API錯誤
Browse files- .gitignore +7 -0
- __pycache__/api.cpython-311.pyc +0 -0
- __pycache__/auth.cpython-311.pyc +0 -0
- __pycache__/models.cpython-311.pyc +0 -0
- __pycache__/plotting.cpython-311.pyc +0 -0
- __pycache__/ui.cpython-311.pyc +0 -0
- api.py +137 -14
- app.py +1 -1
- auth.py +30 -0
- models.py +15 -13
- plotting.py +135 -3
- ui.py +8 -25
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
SimHei*
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.py[cod]
|
| 5 |
+
*$py.class
|
| 6 |
+
*.pkl
|
| 7 |
+
baseline_logs.csv
|
__pycache__/api.cpython-311.pyc
CHANGED
|
Binary files a/__pycache__/api.cpython-311.pyc and b/__pycache__/api.cpython-311.pyc differ
|
|
|
__pycache__/auth.cpython-311.pyc
CHANGED
|
Binary files a/__pycache__/auth.cpython-311.pyc and b/__pycache__/auth.cpython-311.pyc differ
|
|
|
__pycache__/models.cpython-311.pyc
CHANGED
|
Binary files a/__pycache__/models.cpython-311.pyc and b/__pycache__/models.cpython-311.pyc differ
|
|
|
__pycache__/plotting.cpython-311.pyc
CHANGED
|
Binary files a/__pycache__/plotting.cpython-311.pyc and b/__pycache__/plotting.cpython-311.pyc differ
|
|
|
__pycache__/ui.cpython-311.pyc
CHANGED
|
Binary files a/__pycache__/ui.cpython-311.pyc and b/__pycache__/ui.cpython-311.pyc differ
|
|
|
api.py
CHANGED
|
@@ -7,11 +7,16 @@ from fastapi.responses import JSONResponse
|
|
| 7 |
from pydantic import BaseModel
|
| 8 |
from typing import Optional, List
|
| 9 |
|
| 10 |
-
from auth import
|
|
|
|
|
|
|
|
|
|
| 11 |
from security import test_send_security_alert, verify_identity
|
| 12 |
from config import SYSTEM_WEIGHTS, ACTION_MAP, FIXED_TEST_EMAIL
|
| 13 |
from plotting import get_plot_data_for_api
|
| 14 |
import pandas as pd
|
|
|
|
|
|
|
| 15 |
|
| 16 |
api_app = FastAPI(title="數位保鏢 API")
|
| 17 |
|
|
@@ -23,6 +28,15 @@ class WeightsRequest(BaseModel):
|
|
| 23 |
dw: float
|
| 24 |
gw: float
|
| 25 |
fw: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
class StudentActionRequest(BaseModel):
|
| 28 |
ip: str
|
|
@@ -40,12 +54,12 @@ class GenericActionRequest(StudentActionRequest):
|
|
| 40 |
# --- 純淨 API 端點專用函式 (供內部呼叫) ---
|
| 41 |
def internal_get_stats():
|
| 42 |
"""取得系統狀態與權重"""
|
| 43 |
-
total = global_stats["total_count"]
|
| 44 |
-
abnormal = global_stats["abnormal_count"]
|
| 45 |
rate = round((abnormal / total * 100), 2) if total > 0 else 0
|
| 46 |
return {
|
| 47 |
"status": "success",
|
| 48 |
-
"ai_ready": AI_READY,
|
| 49 |
"data": {
|
| 50 |
"total_logs": total,
|
| 51 |
"abnormal_logs": abnormal,
|
|
@@ -58,10 +72,18 @@ def internal_get_logs(limit=20):
|
|
| 58 |
"""取得最新的 N 筆 Log"""
|
| 59 |
return {
|
| 60 |
"status": "success",
|
| 61 |
-
"count": len(log_history[:limit]),
|
| 62 |
-
"data": log_history[:limit]
|
| 63 |
}
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
# --- FastAPI 路由定義 ---
|
| 66 |
|
| 67 |
@api_app.get("/api/stats")
|
|
@@ -72,18 +94,35 @@ def get_stats():
|
|
| 72 |
def get_logs(limit: int = 20):
|
| 73 |
return internal_get_logs(limit)
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
@api_app.get("/api/chart_data")
|
| 76 |
def get_chart_data(action_name: str = Query(...)):
|
| 77 |
"""取得特定系統的圖表原始數據"""
|
| 78 |
-
if not
|
| 79 |
-
return {"status": "error", "message": "
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
df_hist = baseline_df[baseline_df['action'] == action_code].copy()
|
| 86 |
-
df_live = pd.DataFrame(log_history)
|
| 87 |
|
| 88 |
if not df_live.empty:
|
| 89 |
df_live['timestamp'] = pd.to_datetime(df_live['timestamp'], errors='coerce')
|
|
@@ -116,16 +155,100 @@ def get_chart_data(action_name: str = Query(...)):
|
|
| 116 |
@api_app.get("/api/abnormal_logs")
|
| 117 |
def get_abnormal_logs():
|
| 118 |
"""取得所有異常 Log"""
|
| 119 |
-
abnormal_logs = [log for log in log_history if "異常" in str(log.get("status", "")) or "錯誤" in str(log.get("status", "")) or "受限" in str(log.get("status", ""))]
|
| 120 |
return {
|
| 121 |
"status": "success",
|
| 122 |
"count": len(abnormal_logs),
|
| 123 |
"data": abnormal_logs
|
| 124 |
}
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
@api_app.get("/api/plot_image")
|
| 127 |
def get_plot_image(action_name: str = Query(...)):
|
| 128 |
"""取得圖表的 Base64 圖片"""
|
|
|
|
|
|
|
| 129 |
img_data = get_plot_data_for_api(action_name)
|
| 130 |
return {"status": "success", "image": img_data}
|
| 131 |
|
|
|
|
| 7 |
from pydantic import BaseModel
|
| 8 |
from typing import Optional, List
|
| 9 |
|
| 10 |
+
from auth import (
|
| 11 |
+
simulate_click, USER_DB, check_session_timeouts, get_user_status,
|
| 12 |
+
internal_process_login, internal_logout
|
| 13 |
+
)
|
| 14 |
from security import test_send_security_alert, verify_identity
|
| 15 |
from config import SYSTEM_WEIGHTS, ACTION_MAP, FIXED_TEST_EMAIL
|
| 16 |
from plotting import get_plot_data_for_api
|
| 17 |
import pandas as pd
|
| 18 |
+
import models # 使用 models.XXX 存取即時狀態,避免布林值被複製後失效
|
| 19 |
+
from utils import get_geo_level
|
| 20 |
|
| 21 |
api_app = FastAPI(title="數位保鏢 API")
|
| 22 |
|
|
|
|
| 28 |
dw: float
|
| 29 |
gw: float
|
| 30 |
fw: float
|
| 31 |
+
|
| 32 |
+
class LoginRequest(BaseModel):
|
| 33 |
+
account: str
|
| 34 |
+
password: str
|
| 35 |
+
captcha: str
|
| 36 |
+
ip: str
|
| 37 |
+
cookie: str
|
| 38 |
+
time_mode: str
|
| 39 |
+
custom_time: Optional[str] = ""
|
| 40 |
|
| 41 |
class StudentActionRequest(BaseModel):
|
| 42 |
ip: str
|
|
|
|
| 54 |
# --- 純淨 API 端點專用函式 (供內部呼叫) ---
|
| 55 |
def internal_get_stats():
|
| 56 |
"""取得系統狀態與權重"""
|
| 57 |
+
total = models.global_stats["total_count"]
|
| 58 |
+
abnormal = models.global_stats["abnormal_count"]
|
| 59 |
rate = round((abnormal / total * 100), 2) if total > 0 else 0
|
| 60 |
return {
|
| 61 |
"status": "success",
|
| 62 |
+
"ai_ready": models.AI_READY,
|
| 63 |
"data": {
|
| 64 |
"total_logs": total,
|
| 65 |
"abnormal_logs": abnormal,
|
|
|
|
| 72 |
"""取得最新的 N 筆 Log"""
|
| 73 |
return {
|
| 74 |
"status": "success",
|
| 75 |
+
"count": len(models.log_history[:limit]),
|
| 76 |
+
"data": models.log_history[:limit]
|
| 77 |
}
|
| 78 |
|
| 79 |
+
def _extract_gr_update_value(upd):
|
| 80 |
+
"""從 Gradio update 物件中提取 value (如果有)"""
|
| 81 |
+
if hasattr(upd, 'value'):
|
| 82 |
+
return upd.value
|
| 83 |
+
if isinstance(upd, dict) and 'value' in upd:
|
| 84 |
+
return upd['value']
|
| 85 |
+
return ""
|
| 86 |
+
|
| 87 |
# --- FastAPI 路由定義 ---
|
| 88 |
|
| 89 |
@api_app.get("/api/stats")
|
|
|
|
| 94 |
def get_logs(limit: int = 20):
|
| 95 |
return internal_get_logs(limit)
|
| 96 |
|
| 97 |
+
@api_app.post("/api/login")
|
| 98 |
+
def login_endpoint(req: LoginRequest):
|
| 99 |
+
return internal_process_login(
|
| 100 |
+
req.ip, req.cookie, req.account, req.password,
|
| 101 |
+
req.captcha, req.time_mode, req.custom_time
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
@api_app.post("/api/logout")
|
| 105 |
+
def logout_endpoint(req: StudentActionExtendedRequest):
|
| 106 |
+
return internal_logout(
|
| 107 |
+
req.ip, req.cookie, req.account, req.time_mode, req.custom_time
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
@api_app.get("/api/chart_data")
|
| 111 |
def get_chart_data(action_name: str = Query(...)):
|
| 112 |
"""取得特定系統的圖表原始數據"""
|
| 113 |
+
if not models.DATA_READY:
|
| 114 |
+
return {"status": "error", "message": "歷史基準資料未載入,請確認 baseline_logs.csv 是否存在或雲端同步是否正常"}
|
| 115 |
|
| 116 |
+
# 嘗試從顯示名稱映射到代碼,如果找不到,假設輸入已經是代碼
|
| 117 |
+
action_code = ACTION_MAP.get(action_name) or action_name
|
| 118 |
+
|
| 119 |
+
# 驗證 action_code 是否有效 (在 baseline_df 中存在或在 ACTION_MAP 的值中)
|
| 120 |
+
valid_codes = set(ACTION_MAP.values())
|
| 121 |
+
if action_code not in valid_codes and action_code not in models.baseline_df['action'].unique():
|
| 122 |
+
return {"status": "error", "message": f"找不到系統代碼或名稱: {action_name}"}
|
| 123 |
|
| 124 |
+
df_hist = models.baseline_df[models.baseline_df['action'] == action_code].copy()
|
| 125 |
+
df_live = pd.DataFrame(models.log_history)
|
| 126 |
|
| 127 |
if not df_live.empty:
|
| 128 |
df_live['timestamp'] = pd.to_datetime(df_live['timestamp'], errors='coerce')
|
|
|
|
| 155 |
@api_app.get("/api/abnormal_logs")
|
| 156 |
def get_abnormal_logs():
|
| 157 |
"""取得所有異常 Log"""
|
| 158 |
+
abnormal_logs = [log for log in models.log_history if "異常" in str(log.get("status", "")) or "錯誤" in str(log.get("status", "")) or "受限" in str(log.get("status", ""))]
|
| 159 |
return {
|
| 160 |
"status": "success",
|
| 161 |
"count": len(abnormal_logs),
|
| 162 |
"data": abnormal_logs
|
| 163 |
}
|
| 164 |
|
| 165 |
+
@api_app.get("/api/get_user_logs")
|
| 166 |
+
def get_user_logs(session_id: str = Query(...)):
|
| 167 |
+
"""
|
| 168 |
+
獲取指定 session_id (cookie_id) 的所有日誌
|
| 169 |
+
過濾自 Hugging Face 同步的歷史數據與當前記憶體數據
|
| 170 |
+
"""
|
| 171 |
+
# 1. 準備過濾後的數據
|
| 172 |
+
filtered_logs = []
|
| 173 |
+
|
| 174 |
+
# 從歷史數據中過濾 (如果有的話)
|
| 175 |
+
if models.baseline_df is not None and not models.baseline_df.empty:
|
| 176 |
+
# 確保資料格式一致
|
| 177 |
+
df_filtered = models.baseline_df[models.baseline_df['cookie_id'] == session_id].copy()
|
| 178 |
+
if not df_filtered.empty:
|
| 179 |
+
df_filtered['timestamp'] = pd.to_datetime(df_filtered['timestamp']).dt.strftime('%Y-%m-%d %H:%M:%S')
|
| 180 |
+
filtered_logs.extend(df_filtered.to_dict('records'))
|
| 181 |
+
|
| 182 |
+
# 從當前記憶體日誌中過濾
|
| 183 |
+
mem_logs = [log for log in models.log_history if log.get("cookie_id") == session_id]
|
| 184 |
+
filtered_logs.extend(mem_logs)
|
| 185 |
+
|
| 186 |
+
# 2. 去重並排序 (依時間倒序)
|
| 187 |
+
# 使用 timestamp + action 作為簡單的去重基準
|
| 188 |
+
seen = set()
|
| 189 |
+
unique_logs = []
|
| 190 |
+
for log in filtered_logs:
|
| 191 |
+
key = (log.get('timestamp'), log.get('action'))
|
| 192 |
+
if key not in seen:
|
| 193 |
+
seen.add(key)
|
| 194 |
+
unique_logs.append(log)
|
| 195 |
+
|
| 196 |
+
unique_logs.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
| 197 |
+
|
| 198 |
+
# 3. 豐富化資料 (加上地理位置、設備資訊、判定狀態)
|
| 199 |
+
final_logs = []
|
| 200 |
+
for log in unique_logs:
|
| 201 |
+
ip = log.get('ip_address', 'Unknown')
|
| 202 |
+
geo_code = get_geo_level(ip)
|
| 203 |
+
|
| 204 |
+
# 地理位置映射
|
| 205 |
+
geo_map = {
|
| 206 |
+
0: "台北市 (校內專網)",
|
| 207 |
+
1: "台北市 (校園網路)",
|
| 208 |
+
2: "台灣 (宿舍/寬頻)",
|
| 209 |
+
3: "台灣 (公共網路)",
|
| 210 |
+
4: "美國 (海外連線)",
|
| 211 |
+
5: "未知區域"
|
| 212 |
+
}
|
| 213 |
+
location = geo_map.get(geo_code, "未知區域")
|
| 214 |
+
|
| 215 |
+
# 設備資訊 (目前資料集無此欄位,給予模擬值或依 IP 推斷)
|
| 216 |
+
# 這裡模擬一些常見設備
|
| 217 |
+
device = "PC (Chrome/Windows)" if "140.124" in ip else "Mobile (Safari/iOS)"
|
| 218 |
+
|
| 219 |
+
# AI 判定狀態轉為中文
|
| 220 |
+
status_raw = str(log.get('status', 'success'))
|
| 221 |
+
ai_status = "異常" if ("異常" in status_raw or "受限" in status_raw or "錯誤" in status_raw) else "正常"
|
| 222 |
+
|
| 223 |
+
final_logs.append({
|
| 224 |
+
"time": log.get('timestamp'),
|
| 225 |
+
"action": log.get('action'),
|
| 226 |
+
"ip": ip,
|
| 227 |
+
"location": location,
|
| 228 |
+
"device": device,
|
| 229 |
+
"ai_status": ai_status,
|
| 230 |
+
"status_detail": status_raw # 保留原始詳細資訊
|
| 231 |
+
})
|
| 232 |
+
|
| 233 |
+
return {
|
| 234 |
+
"status": "success",
|
| 235 |
+
"session_id": session_id,
|
| 236 |
+
"count": len(final_logs),
|
| 237 |
+
"data": final_logs
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
@api_app.get("/api/chart_html")
|
| 241 |
+
def get_chart_html(action_name: str = Query(...)):
|
| 242 |
+
"""取得圖表的 HTML 內容 (Gradio 專用)"""
|
| 243 |
+
from plotting import get_chart_js_html
|
| 244 |
+
html_data = get_chart_js_html(action_name)
|
| 245 |
+
return {"status": "success", "html": html_data}
|
| 246 |
+
|
| 247 |
@api_app.get("/api/plot_image")
|
| 248 |
def get_plot_image(action_name: str = Query(...)):
|
| 249 |
"""取得圖表的 Base64 圖片"""
|
| 250 |
+
if not models.DATA_READY:
|
| 251 |
+
return {"status": "error", "message": "歷史基準資料未載入"}
|
| 252 |
img_data = get_plot_data_for_api(action_name)
|
| 253 |
return {"status": "success", "image": img_data}
|
| 254 |
|
app.py
CHANGED
|
@@ -23,7 +23,7 @@ api_app.add_middleware(
|
|
| 23 |
allow_origins=["*"],
|
| 24 |
allow_methods=["*"],
|
| 25 |
allow_headers=["*"],
|
| 26 |
-
allow_credentials=
|
| 27 |
)
|
| 28 |
|
| 29 |
if __name__ == "__main__":
|
|
|
|
| 23 |
allow_origins=["*"],
|
| 24 |
allow_methods=["*"],
|
| 25 |
allow_headers=["*"],
|
| 26 |
+
allow_credentials=False, # 避免與 allow_origins=["*"] 衝突
|
| 27 |
)
|
| 28 |
|
| 29 |
if __name__ == "__main__":
|
auth.py
CHANGED
|
@@ -101,6 +101,36 @@ def process_login(ip, cookie, account, password, captcha, time_mode, custom_time
|
|
| 101 |
df_all, df_abn, inp_upd = record_log(ip, cookie, account, role, "login_attempt", "failed_password", time_mode, custom_time)
|
| 102 |
return fail_response("密碼錯誤!", df_all, df_abn, inp_upd)
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def fast_login(target_account, ip, cookie, time_mode, custom_time):
|
| 105 |
"""快速登入"""
|
| 106 |
role, name = USER_DB[target_account][1], USER_DB[target_account][2]
|
|
|
|
| 101 |
df_all, df_abn, inp_upd = record_log(ip, cookie, account, role, "login_attempt", "failed_password", time_mode, custom_time)
|
| 102 |
return fail_response("密碼錯誤!", df_all, df_abn, inp_upd)
|
| 103 |
|
| 104 |
+
def internal_process_login(ip, cookie, account, password, captcha, time_mode, custom_time):
|
| 105 |
+
"""內部處理登入邏輯,回傳 JSON 友善格式"""
|
| 106 |
+
if account not in USER_DB:
|
| 107 |
+
df_all, df_abn, inp_upd = record_log(ip, cookie, account, "Unknown", "login_attempt", "failed_no_account", time_mode, custom_time)
|
| 108 |
+
return {"status": "error", "message": "帳號不存在!"}
|
| 109 |
+
|
| 110 |
+
role, name = USER_DB[account][1], USER_DB[account][2]
|
| 111 |
+
|
| 112 |
+
if captcha.upper() != "FEIK":
|
| 113 |
+
df_all, df_abn, inp_upd = record_log(ip, cookie, account, role, "login_attempt", "failed_captcha", time_mode, custom_time)
|
| 114 |
+
return {"status": "error", "message": "驗證碼錯誤!"}
|
| 115 |
+
|
| 116 |
+
if USER_DB[account][0] == password:
|
| 117 |
+
df_all, df_abn, inp_upd = record_log(ip, cookie, account, role, "login_attempt", "success", time_mode, custom_time)
|
| 118 |
+
return {
|
| 119 |
+
"status": "success",
|
| 120 |
+
"role": "admin" if role == "管理" else "student",
|
| 121 |
+
"name": name,
|
| 122 |
+
"message": "登入成功"
|
| 123 |
+
}
|
| 124 |
+
else:
|
| 125 |
+
df_all, df_abn, inp_upd = record_log(ip, cookie, account, role, "login_attempt", "failed_password", time_mode, custom_time)
|
| 126 |
+
return {"status": "error", "message": "密碼錯誤!"}
|
| 127 |
+
|
| 128 |
+
def internal_logout(ip, cookie, account, time_mode, custom_time):
|
| 129 |
+
"""內部處理登出邏輯"""
|
| 130 |
+
role = USER_DB.get(account, ["", "Unknown"])[1]
|
| 131 |
+
record_log(ip, cookie, account, role, "logout", "success", time_mode, custom_time)
|
| 132 |
+
return {"status": "success", "message": "已登出"}
|
| 133 |
+
|
| 134 |
def fast_login(target_account, ip, cookie, time_mode, custom_time):
|
| 135 |
"""快速登入"""
|
| 136 |
role, name = USER_DB[target_account][1], USER_DB[target_account][2]
|
models.py
CHANGED
|
@@ -16,6 +16,7 @@ le_action = None
|
|
| 16 |
baseline_df = None
|
| 17 |
baseline_X = None
|
| 18 |
AI_READY = False
|
|
|
|
| 19 |
|
| 20 |
# 全域統計與狀態變數
|
| 21 |
global_stats = {
|
|
@@ -29,12 +30,10 @@ log_history = []
|
|
| 29 |
|
| 30 |
def load_models_and_data():
|
| 31 |
"""載入模型和歷史資料"""
|
| 32 |
-
global le_action, baseline_df, baseline_X, AI_READY, global_stats, log_history
|
| 33 |
|
|
|
|
| 34 |
try:
|
| 35 |
-
# 載入標籤編碼器
|
| 36 |
-
le_action = joblib.load(MODEL_FILES["label_encoder"])
|
| 37 |
-
|
| 38 |
print("📂 正在從雲端獲取最新資料 (Hugging Face API)...")
|
| 39 |
try:
|
| 40 |
import logger_service
|
|
@@ -64,12 +63,17 @@ def load_models_and_data():
|
|
| 64 |
new_logs = display_df[['timestamp', 'ip_address', 'cookie_id', 'account', 'role', 'action', 'status']].to_dict('records')
|
| 65 |
log_history.clear()
|
| 66 |
log_history.extend(new_logs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
abn_df_temp['timestamp'] = abn_df_temp['timestamp'].dt.strftime('%Y-%m-%d %H:%M:%S')
|
| 73 |
|
| 74 |
# 準備特徵矩陣
|
| 75 |
baseline_df['month'] = baseline_df['timestamp'].dt.month
|
|
@@ -90,16 +94,14 @@ def load_models_and_data():
|
|
| 90 |
))
|
| 91 |
|
| 92 |
AI_READY = True
|
| 93 |
-
print("✅ AI 模組
|
| 94 |
|
| 95 |
except Exception as e:
|
| 96 |
AI_READY = False
|
| 97 |
-
global_stats["total_count"] = 0
|
| 98 |
-
global_stats["abnormal_count"] = 0
|
| 99 |
if "KeyError: 118" in str(e) or "version https://git-lfs" in str(e):
|
| 100 |
print(f"⚠️ AI 模組載入失敗:偵測到 Git LFS 指標檔案。請確保已安裝 git-lfs 並執行 `git lfs pull` 下載實際模型檔。")
|
| 101 |
else:
|
| 102 |
-
print(f"⚠️ AI 模組載入失敗
|
| 103 |
|
| 104 |
def check_anomaly(ip, account, action, log_time):
|
| 105 |
"""核心異常偵測邏輯"""
|
|
|
|
| 16 |
baseline_df = None
|
| 17 |
baseline_X = None
|
| 18 |
AI_READY = False
|
| 19 |
+
DATA_READY = False # 新增:標記資料是否載入成功
|
| 20 |
|
| 21 |
# 全域統計與狀態變數
|
| 22 |
global_stats = {
|
|
|
|
| 30 |
|
| 31 |
def load_models_and_data():
|
| 32 |
"""載入模型和歷史資料"""
|
| 33 |
+
global le_action, baseline_df, baseline_X, AI_READY, DATA_READY, global_stats, log_history
|
| 34 |
|
| 35 |
+
# 1. 優先載入資料 (圖表顯示核心)
|
| 36 |
try:
|
|
|
|
|
|
|
|
|
|
| 37 |
print("📂 正在從雲端獲取最新資料 (Hugging Face API)...")
|
| 38 |
try:
|
| 39 |
import logger_service
|
|
|
|
| 63 |
new_logs = display_df[['timestamp', 'ip_address', 'cookie_id', 'account', 'role', 'action', 'status']].to_dict('records')
|
| 64 |
log_history.clear()
|
| 65 |
log_history.extend(new_logs)
|
| 66 |
+
|
| 67 |
+
DATA_READY = True
|
| 68 |
+
print("✅ 歷史基準資料載入成功!")
|
| 69 |
+
except Exception as e:
|
| 70 |
+
DATA_READY = False
|
| 71 |
+
print(f"❌ 資料載入失敗: {e}")
|
| 72 |
|
| 73 |
+
# 2. 嘗試載入 AI 模型 (異常偵測核心)
|
| 74 |
+
try:
|
| 75 |
+
# 載入標籤編碼器
|
| 76 |
+
le_action = joblib.load(MODEL_FILES["label_encoder"])
|
|
|
|
| 77 |
|
| 78 |
# 準備特徵矩陣
|
| 79 |
baseline_df['month'] = baseline_df['timestamp'].dt.month
|
|
|
|
| 94 |
))
|
| 95 |
|
| 96 |
AI_READY = True
|
| 97 |
+
print("✅ AI 模組載入成功!")
|
| 98 |
|
| 99 |
except Exception as e:
|
| 100 |
AI_READY = False
|
|
|
|
|
|
|
| 101 |
if "KeyError: 118" in str(e) or "version https://git-lfs" in str(e):
|
| 102 |
print(f"⚠️ AI 模組載入失敗:偵測到 Git LFS 指標檔案。請確保已安裝 git-lfs 並執行 `git lfs pull` 下載實際模型檔。")
|
| 103 |
else:
|
| 104 |
+
print(f"⚠️ AI 模組載入失敗 (不影響基本圖表顯示)。錯誤: {e}")
|
| 105 |
|
| 106 |
def check_anomaly(ip, account, action, log_time):
|
| 107 |
"""核心異常偵測邏輯"""
|
plotting.py
CHANGED
|
@@ -94,7 +94,7 @@ def get_dashboard_html():
|
|
| 94 |
|
| 95 |
def get_plot_data_for_api(selected_key):
|
| 96 |
"""API 專用版本,返回 Base64 圖片字符串"""
|
| 97 |
-
if not selected_key or not models.
|
| 98 |
return None
|
| 99 |
|
| 100 |
action_code = ACTION_MAP.get(selected_key)
|
|
@@ -202,7 +202,7 @@ def get_plot_data_for_api(selected_key):
|
|
| 202 |
|
| 203 |
def get_plot_data(selected_key):
|
| 204 |
"""給 Gradio 界面使用,返回 Figure 對象"""
|
| 205 |
-
if not selected_key or not models.
|
| 206 |
return None
|
| 207 |
|
| 208 |
action_code = ACTION_MAP.get(selected_key)
|
|
@@ -275,4 +275,136 @@ def get_plot_data(selected_key):
|
|
| 275 |
|
| 276 |
def timer_update(selected_key):
|
| 277 |
"""定時更新函數"""
|
| 278 |
-
return get_dashboard_html(), get_plot_data(selected_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
def get_plot_data_for_api(selected_key):
|
| 96 |
"""API 專用版本,返回 Base64 圖片字符串"""
|
| 97 |
+
if not selected_key or not models.DATA_READY:
|
| 98 |
return None
|
| 99 |
|
| 100 |
action_code = ACTION_MAP.get(selected_key)
|
|
|
|
| 202 |
|
| 203 |
def get_plot_data(selected_key):
|
| 204 |
"""給 Gradio 界面使用,返回 Figure 對象"""
|
| 205 |
+
if not selected_key or not models.DATA_READY:
|
| 206 |
return None
|
| 207 |
|
| 208 |
action_code = ACTION_MAP.get(selected_key)
|
|
|
|
| 275 |
|
| 276 |
def timer_update(selected_key):
|
| 277 |
"""定時更新函數"""
|
| 278 |
+
return get_dashboard_html(), get_plot_data(selected_key)
|
| 279 |
+
def get_chart_js_html(selected_key):
|
| 280 |
+
"""生成 Chart.js HTML 內容 (Gradio 專用)"""
|
| 281 |
+
import models
|
| 282 |
+
import numpy as np
|
| 283 |
+
import json
|
| 284 |
+
from config import ACTION_MAP
|
| 285 |
+
import pandas as pd
|
| 286 |
+
|
| 287 |
+
if not selected_key or not models.DATA_READY:
|
| 288 |
+
return f'<div style="text-align:center; padding:40px; color:#94a3b8;">【{selected_key}】系統目前尚無資料</div>'
|
| 289 |
+
|
| 290 |
+
# 取得代碼
|
| 291 |
+
action_code = ACTION_MAP.get(selected_key)
|
| 292 |
+
print(f"🔍 [Chart Debug] Selected: '{selected_key}', Mapped Code: '{action_code}'")
|
| 293 |
+
|
| 294 |
+
if not action_code:
|
| 295 |
+
# 嘗試模糊匹配
|
| 296 |
+
for k, v in ACTION_MAP.items():
|
| 297 |
+
if selected_key in k or k in selected_key:
|
| 298 |
+
action_code = v
|
| 299 |
+
print(f"💡 [Chart Debug] Fuzzy matched to: '{action_code}'")
|
| 300 |
+
break
|
| 301 |
+
|
| 302 |
+
if not action_code:
|
| 303 |
+
action_code = selected_key # 最後手段:直接用名稱查
|
| 304 |
+
|
| 305 |
+
# 取得資料
|
| 306 |
+
df_hist = models.baseline_df[models.baseline_df['action'] == action_code].copy()
|
| 307 |
+
print(f"📊 [Chart Debug] Found {len(df_hist)} historical records for '{action_code}'")
|
| 308 |
+
|
| 309 |
+
df_live = pd.DataFrame(models.log_history)
|
| 310 |
+
if not df_live.empty:
|
| 311 |
+
df_live['timestamp'] = pd.to_datetime(df_live['timestamp'], errors='coerce')
|
| 312 |
+
df_live = df_live.dropna(subset=['timestamp'])
|
| 313 |
+
df_live = df_live[df_live['action'] == action_code].copy()
|
| 314 |
+
print(f"📈 [Chart Debug] Found {len(df_live)} live records for '{action_code}'")
|
| 315 |
+
|
| 316 |
+
if not df_live.empty:
|
| 317 |
+
df_combined = pd.concat([df_hist, df_live], ignore_index=True)
|
| 318 |
+
df_combined = df_combined.drop_duplicates(subset=['timestamp', 'account', 'action'])
|
| 319 |
+
else:
|
| 320 |
+
df_combined = df_hist.copy()
|
| 321 |
+
|
| 322 |
+
if df_combined.empty:
|
| 323 |
+
print(f"⚠️ [Chart Debug] Combined data is empty for '{action_code}'")
|
| 324 |
+
return f'<div style="text-align:center; padding:40px; color:#94a3b8;">【{selected_key}】系統目前尚無資料</div>'
|
| 325 |
+
|
| 326 |
+
df_combined['month'] = df_combined['timestamp'].dt.month
|
| 327 |
+
df_combined['time_of_day'] = df_combined['timestamp'].dt.hour + df_combined['timestamp'].dt.minute / 60.0
|
| 328 |
+
|
| 329 |
+
is_abnormal = df_combined['status'].astype(str).str.contains("異常", na=False)
|
| 330 |
+
|
| 331 |
+
# 準備資料點 (添加 Jitter)
|
| 332 |
+
normal_pts = []
|
| 333 |
+
for _, row in df_combined[~is_abnormal].iterrows():
|
| 334 |
+
normal_pts.append({"x": float(row['month']) + (np.random.rand()-0.5)*0.4, "y": float(row['time_of_day']) + (np.random.rand()-0.5)*0.5})
|
| 335 |
+
|
| 336 |
+
abnormal_pts = []
|
| 337 |
+
for _, row in df_combined[is_abnormal].iterrows():
|
| 338 |
+
abnormal_pts.append({"x": float(row['month']) + (np.random.rand()-0.5)*0.4, "y": float(row['time_of_day']) + (np.random.rand()-0.5)*0.5})
|
| 339 |
+
|
| 340 |
+
normal_json = json.dumps(normal_pts)
|
| 341 |
+
abnormal_json = json.dumps(abnormal_pts)
|
| 342 |
+
|
| 343 |
+
# 使用隨機 ID 避免 Gradio 多次渲染衝突
|
| 344 |
+
import time
|
| 345 |
+
chart_id = f"chart_{int(time.time() * 1000)}"
|
| 346 |
+
|
| 347 |
+
html = f"""
|
| 348 |
+
<div style="width: 100%; height: 450px; background: white; border-radius: 12px; padding: 20px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); margin-top: 10px;">
|
| 349 |
+
<canvas id="{chart_id}"></canvas>
|
| 350 |
+
<script>
|
| 351 |
+
(function() {{
|
| 352 |
+
const initChart = () => {{
|
| 353 |
+
const canvas = document.getElementById('{chart_id}');
|
| 354 |
+
if (!canvas) return;
|
| 355 |
+
const ctx = canvas.getContext('2d');
|
| 356 |
+
|
| 357 |
+
if (typeof Chart === 'undefined') {{
|
| 358 |
+
setTimeout(initChart, 500);
|
| 359 |
+
return;
|
| 360 |
+
}}
|
| 361 |
+
|
| 362 |
+
console.log("Rendering Gradio Chart: {selected_key}");
|
| 363 |
+
|
| 364 |
+
new Chart(ctx, {{
|
| 365 |
+
type: 'scatter',
|
| 366 |
+
data: {{
|
| 367 |
+
datasets: [
|
| 368 |
+
{{
|
| 369 |
+
label: '正常行為',
|
| 370 |
+
data: {normal_json},
|
| 371 |
+
backgroundColor: 'rgba(16, 185, 129, 0.4)',
|
| 372 |
+
pointRadius: 5
|
| 373 |
+
}},
|
| 374 |
+
{{
|
| 375 |
+
label: '異常行為',
|
| 376 |
+
data: {abnormal_json},
|
| 377 |
+
backgroundColor: 'rgba(239, 68, 68, 0.8)',
|
| 378 |
+
pointRadius: 7,
|
| 379 |
+
pointStyle: 'rectRot'
|
| 380 |
+
}}
|
| 381 |
+
]
|
| 382 |
+
}},
|
| 383 |
+
options: {{
|
| 384 |
+
responsive: true,
|
| 385 |
+
maintainAspectRatio: false,
|
| 386 |
+
animation: {{ duration: 800 }},
|
| 387 |
+
scales: {{
|
| 388 |
+
x: {{ title: {{ display: true, text: '月份' }}, min: 0.5, max: 12.5, ticks: {{ stepSize: 1 }} }},
|
| 389 |
+
y: {{ title: {{ display: true, text: '時間 (0-24h)' }}, min: 0, max: 24, reverse: true, ticks: {{ stepSize: 4 }} }}
|
| 390 |
+
}},
|
| 391 |
+
plugins: {{
|
| 392 |
+
legend: {{ position: 'top' }}
|
| 393 |
+
}}
|
| 394 |
+
}}
|
| 395 |
+
}});
|
| 396 |
+
}};
|
| 397 |
+
|
| 398 |
+
if (typeof Chart === 'undefined') {{
|
| 399 |
+
const script = document.createElement('script');
|
| 400 |
+
script.src = 'https://cdn.jsdelivr.net/npm/chart.js';
|
| 401 |
+
script.onload = initChart;
|
| 402 |
+
document.head.appendChild(script);
|
| 403 |
+
}} else {{
|
| 404 |
+
initChart();
|
| 405 |
+
}}
|
| 406 |
+
}})();
|
| 407 |
+
</script>
|
| 408 |
+
</div>
|
| 409 |
+
"""
|
| 410 |
+
return html
|
ui.py
CHANGED
|
@@ -81,33 +81,16 @@ def full_ui_update(selected_key):
|
|
| 81 |
"""更新所有 UI 元件:儀表板、圖表、所有 Log、異常 Log"""
|
| 82 |
# 1. 獲取儀表板 HTML
|
| 83 |
dash_html = get_dashboard_html()
|
| 84 |
-
|
| 85 |
-
# 2. 獲取圖表 (
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
plot_img = None
|
| 89 |
-
if selected_key:
|
| 90 |
-
try:
|
| 91 |
-
# 呼叫本機 API 端點
|
| 92 |
-
port = SERVER_CONFIG["port"]
|
| 93 |
-
api_url = f"http://127.0.0.1:{port}/api/plot_image?action_name={selected_key}"
|
| 94 |
-
resp = requests.get(api_url, timeout=5)
|
| 95 |
-
if resp.status_code == 200:
|
| 96 |
-
img_base64 = resp.json().get("image")
|
| 97 |
-
if img_base64:
|
| 98 |
-
plot_img = f'<img src="{img_base64}" style="width:100%; max-height:450px; object-fit:contain; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.1);">'
|
| 99 |
-
except:
|
| 100 |
-
# Fallback: 直接呼叫函式
|
| 101 |
-
img_b64 = get_plot_data_for_api(selected_key)
|
| 102 |
-
if img_b64:
|
| 103 |
-
plot_img = f'<img src="{img_b64}" style="width:100%; max-height:450px; object-fit:contain; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.1);">'
|
| 104 |
-
|
| 105 |
# 3. 獲取 Log 資料
|
| 106 |
df_all = pd.DataFrame(log_history)
|
| 107 |
abnormal_logs = [log for log in log_history if any(word in str(log.get("status", "")) for word in ["異常", "錯誤", "受限"])]
|
| 108 |
df_abn = pd.DataFrame(abnormal_logs) if abnormal_logs else pd.DataFrame(columns=["timestamp", "ip_address", "cookie_id", "account", "role", "action", "status"])
|
| 109 |
-
|
| 110 |
-
return dash_html,
|
| 111 |
|
| 112 |
def create_demo():
|
| 113 |
"""創建 Gradio 應用"""
|
|
@@ -210,8 +193,8 @@ def create_demo():
|
|
| 210 |
with gr.Tab("🛡️ 管理員專區", id="admin_tab"):
|
| 211 |
stat_dashboard_display = gr.HTML(value=get_dashboard_html())
|
| 212 |
with gr.Accordion("▼ 系統操作時間分佈圖 (即時監控)", open=True, elem_classes="weight-card"):
|
| 213 |
-
action_dropdown = gr.Dropdown(choices=list(ACTION_MAP.keys()), label="選擇要檢視的系統資料分佈", value=
|
| 214 |
-
action_plot = gr.
|
| 215 |
|
| 216 |
with gr.Accordion("▼ 防護強度微調 (AI 敏感度)", open=True, elem_classes="weight-card"):
|
| 217 |
gr.Markdown("<span style='color:gray; font-size:0.9em;'>動態防護已全面啟動:包含 HDBSCAN 空間異常偵測、IP 聲譽聯防,以及動態視窗頻率攔截。</span>")
|
|
|
|
| 81 |
"""更新所有 UI 元件:儀表板、圖表、所有 Log、異常 Log"""
|
| 82 |
# 1. 獲取儀表板 HTML
|
| 83 |
dash_html = get_dashboard_html()
|
| 84 |
+
|
| 85 |
+
# 2. 獲取圖表 (直接使用 matplotlib Figure,交給 gr.Plot 渲染)
|
| 86 |
+
plot_fig = get_plot_data(selected_key)
|
| 87 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
# 3. 獲取 Log 資料
|
| 89 |
df_all = pd.DataFrame(log_history)
|
| 90 |
abnormal_logs = [log for log in log_history if any(word in str(log.get("status", "")) for word in ["異常", "錯誤", "受限"])]
|
| 91 |
df_abn = pd.DataFrame(abnormal_logs) if abnormal_logs else pd.DataFrame(columns=["timestamp", "ip_address", "cookie_id", "account", "role", "action", "status"])
|
| 92 |
+
|
| 93 |
+
return dash_html, plot_fig, df_all, df_abn
|
| 94 |
|
| 95 |
def create_demo():
|
| 96 |
"""創建 Gradio 應用"""
|
|
|
|
| 193 |
with gr.Tab("🛡️ 管理員專區", id="admin_tab"):
|
| 194 |
stat_dashboard_display = gr.HTML(value=get_dashboard_html())
|
| 195 |
with gr.Accordion("▼ 系統操作時間分佈圖 (即時監控)", open=True, elem_classes="weight-card"):
|
| 196 |
+
action_dropdown = gr.Dropdown(choices=list(ACTION_MAP.keys()), label="選擇要檢視的系統資料分佈", value=list(ACTION_MAP.keys())[0])
|
| 197 |
+
action_plot = gr.Plot(label="圖表顯示區")
|
| 198 |
|
| 199 |
with gr.Accordion("▼ 防護強度微調 (AI 敏感度)", open=True, elem_classes="weight-card"):
|
| 200 |
gr.Markdown("<span style='color:gray; font-size:0.9em;'>動態防護已全面啟動:包含 HDBSCAN 空間異常偵測、IP 聲譽聯防,以及動態視窗頻率攔截。</span>")
|