Phase 8 Complete: Sync Audit Log + Frontend Integration
- Thêm audit/sync_audit.py: Ghi lịch sử sync vào audit/sync_log.json
- Thêm API endpoints: /sync/history, /sync/history/{run_id}
- Frontend: Nút 'Lịch sử đồng bộ' + panel expand chi tiết từng file
- Sửa frontend served từ backend (http://localhost:8000)
- Cập nhật DEPLOYMENT_GUIDE với hướng dẫn chạy localhost
- Cập nhật ARCHITECTURE_MAP: Phase 8 hoàn thành
This commit is contained in:
53
api/main.py
53
api/main.py
@@ -5,7 +5,8 @@ import secrets
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import RedirectResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field, validator
|
||||
import uvicorn
|
||||
@@ -24,6 +25,7 @@ from extraction.ocr_service import OCRService
|
||||
from extraction.text_extractor import TextExtractor
|
||||
from chunking.markdown_chunker import MarkdownChunker
|
||||
from indexing.vector_store import VectorStore
|
||||
from audit import sync_audit
|
||||
|
||||
# --- Cấu hình Logging chuyên nghiệp ---
|
||||
logging.basicConfig(
|
||||
@@ -50,6 +52,15 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Serve frontend static files
|
||||
FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
|
||||
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
|
||||
@app.get("/", tags=["Frontend"])
|
||||
async def serve_frontend():
|
||||
"""Serve frontend index.html."""
|
||||
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
|
||||
|
||||
# --- Singleton Engine Instance ---
|
||||
rag_engine = None
|
||||
sync_status = {"running": False, "last_run": None, "processed": 0, "skipped": 0, "errors": []}
|
||||
@@ -270,6 +281,8 @@ def run_sync_background():
|
||||
global sync_status
|
||||
sync_status = {"running": True, "last_run": None, "processed": 0, "skipped": 0, "errors": []}
|
||||
|
||||
run_id = sync_audit.start_run()
|
||||
|
||||
try:
|
||||
provider = SharePointProvider()
|
||||
dce = DocumentClassificationEngine(provider=provider)
|
||||
@@ -304,16 +317,22 @@ def run_sync_background():
|
||||
|
||||
if classification.processing_policy in (ProcessingPolicy.UNSUPPORTED, ProcessingPolicy.METADATA_ONLY, ProcessingPolicy.REQUIRES_REVIEW):
|
||||
sync_status["skipped"] += 1
|
||||
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||
classification.doc_type.value, 0, "skipped", classification.reason)
|
||||
continue
|
||||
|
||||
try:
|
||||
file_bytes = provider.download_file(item)
|
||||
except Exception as e:
|
||||
sync_status["errors"].append(f"{name}: download failed")
|
||||
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||
classification.doc_type.value, 0, "error", str(e))
|
||||
continue
|
||||
|
||||
if not file_bytes:
|
||||
sync_status["errors"].append(f"{name}: empty file")
|
||||
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||
classification.doc_type.value, 0, "error", "empty file")
|
||||
continue
|
||||
|
||||
pages = []
|
||||
@@ -336,6 +355,8 @@ def run_sync_background():
|
||||
|
||||
if not pages:
|
||||
sync_status["skipped"] += 1
|
||||
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||
classification.doc_type.value, 0, "skipped", "no content extracted")
|
||||
continue
|
||||
|
||||
metadata = {
|
||||
@@ -352,16 +373,22 @@ def run_sync_background():
|
||||
vector_db.delete_by_file_id(item_id)
|
||||
vector_db.embed_and_index(chunks)
|
||||
sync_status["processed"] += 1
|
||||
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||
classification.doc_type.value, len(chunks), "indexed")
|
||||
logger.info(f"Sync: Indexed {name} → {len(chunks)} chunks")
|
||||
else:
|
||||
sync_status["skipped"] += 1
|
||||
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||
classification.doc_type.value, 0, "skipped", "no chunks generated")
|
||||
|
||||
sync_status["last_run"] = "completed"
|
||||
sync_audit.finish_run(run_id, "completed")
|
||||
logger.info(f"Sync completed: {sync_status['processed']} processed, {sync_status['skipped']} skipped")
|
||||
|
||||
except Exception as e:
|
||||
sync_status["last_run"] = "failed"
|
||||
sync_status["errors"].append(str(e))
|
||||
sync_audit.finish_run(run_id, "failed")
|
||||
logger.error(f"Sync failed: {e}")
|
||||
finally:
|
||||
sync_status["running"] = False
|
||||
@@ -382,8 +409,30 @@ async def sync_endpoint(background_tasks: BackgroundTasks):
|
||||
|
||||
@app.get("/sync/status", tags=["Ingestion"])
|
||||
async def sync_status_endpoint():
|
||||
"""Kiểm tra trạng thái đồng bộ."""
|
||||
"""Kiểm tra trạng thái đồng bộ hiện tại."""
|
||||
return sync_status
|
||||
|
||||
|
||||
@app.get("/sync/history", tags=["Ingestion"])
|
||||
async def sync_history_endpoint(limit: int = 10):
|
||||
"""
|
||||
Lấy lịch sử đồng bộ (audit log).
|
||||
Args:
|
||||
limit: Số lần sync gần nhất (mặc định 10)
|
||||
"""
|
||||
return {"runs": sync_audit.get_history(limit)}
|
||||
|
||||
|
||||
@app.get("/sync/history/{run_id}", tags=["Ingestion"])
|
||||
async def sync_run_detail_endpoint(run_id: str):
|
||||
"""
|
||||
Lấy chi tiết của 1 lần đồng bộ.
|
||||
"""
|
||||
run = sync_audit.get_run_detail(run_id)
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
return run
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
||||
144
audit/sync_audit.py
Normal file
144
audit/sync_audit.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("SyncAudit")
|
||||
|
||||
AUDIT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
AUDIT_FILE = os.path.join(AUDIT_DIR, "sync_log.json")
|
||||
|
||||
|
||||
def _load_log() -> Dict:
|
||||
"""Load audit log từ file."""
|
||||
if os.path.exists(AUDIT_FILE):
|
||||
try:
|
||||
with open(AUDIT_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
return {"runs": []}
|
||||
return {"runs": []}
|
||||
|
||||
|
||||
def _save_log(data: Dict):
|
||||
"""Lưu audit log ra file."""
|
||||
with open(AUDIT_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def start_run() -> str:
|
||||
"""
|
||||
Bắt đầu một sync run mới.
|
||||
Returns: run_id
|
||||
"""
|
||||
run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
data = _load_log()
|
||||
|
||||
run = {
|
||||
"run_id": run_id,
|
||||
"started_at": datetime.now().isoformat(),
|
||||
"finished_at": None,
|
||||
"status": "running",
|
||||
"summary": {"processed": 0, "skipped": 0, "errors": 0},
|
||||
"files": [],
|
||||
"errors": []
|
||||
}
|
||||
|
||||
data["runs"].insert(0, run) # Mới nhất lên đầu
|
||||
|
||||
# Giới hạn 50 runs gần nhất
|
||||
if len(data["runs"]) > 50:
|
||||
data["runs"] = data["runs"][:50]
|
||||
|
||||
_save_log(data)
|
||||
logger.info(f"Audit: Started run {run_id}")
|
||||
return run_id
|
||||
|
||||
|
||||
def log_file(run_id: str, name: str, item_id: str, policy: str,
|
||||
doc_type: str, chunks: int, status: str, detail: str = ""):
|
||||
"""
|
||||
Ghi log cho 1 file đã xử lý.
|
||||
|
||||
Args:
|
||||
run_id: ID của sync run
|
||||
name: Tên file
|
||||
item_id: SharePoint item ID
|
||||
policy: Processing policy (requires_ocr, skip_ocr, metadata_only, unsupported)
|
||||
doc_type: Document type (textual_document, spreadsheet, drawing, binary)
|
||||
chunks: Số chunks đã tạo
|
||||
status: indexed, skipped, error
|
||||
detail: Ghi chú thêm
|
||||
"""
|
||||
data = _load_log()
|
||||
|
||||
for run in data["runs"]:
|
||||
if run["run_id"] == run_id:
|
||||
file_entry = {
|
||||
"name": name,
|
||||
"item_id": item_id,
|
||||
"doc_type": doc_type,
|
||||
"policy": policy,
|
||||
"chunks": chunks,
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
run["files"].append(file_entry)
|
||||
|
||||
# Cập nhật summary
|
||||
if status == "indexed":
|
||||
run["summary"]["processed"] += 1
|
||||
elif status == "skipped":
|
||||
run["summary"]["skipped"] += 1
|
||||
elif status == "error":
|
||||
run["summary"]["errors"] += 1
|
||||
run["errors"].append(f"{name}: {detail}")
|
||||
|
||||
break
|
||||
|
||||
_save_log(data)
|
||||
|
||||
|
||||
def finish_run(run_id: str, status: str = "completed"):
|
||||
"""
|
||||
Đánh dấu sync run hoàn thành.
|
||||
"""
|
||||
data = _load_log()
|
||||
|
||||
for run in data["runs"]:
|
||||
if run["run_id"] == run_id:
|
||||
run["finished_at"] = datetime.now().isoformat()
|
||||
run["status"] = status
|
||||
break
|
||||
|
||||
_save_log(data)
|
||||
logger.info(f"Audit: Finished run {run_id} ({status})")
|
||||
|
||||
|
||||
def get_history(limit: int = 10) -> List[Dict]:
|
||||
"""
|
||||
Lấy lịch sử sync runs.
|
||||
"""
|
||||
data = _load_log()
|
||||
return data["runs"][:limit]
|
||||
|
||||
|
||||
def get_run_detail(run_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Lấy chi tiết của 1 sync run.
|
||||
"""
|
||||
data = _load_log()
|
||||
for run in data["runs"]:
|
||||
if run["run_id"] == run_id:
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def get_latest_run() -> Optional[Dict]:
|
||||
"""
|
||||
Lấy sync run gần nhất.
|
||||
"""
|
||||
data = _load_log()
|
||||
return data["runs"][0] if data["runs"] else None
|
||||
128
audit/sync_log.json
Normal file
128
audit/sync_log.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"runs": [
|
||||
{
|
||||
"run_id": "20260511_035430",
|
||||
"started_at": "2026-05-11T03:54:30.817636",
|
||||
"finished_at": "2026-05-11T03:55:11.377114",
|
||||
"status": "completed",
|
||||
"summary": {
|
||||
"processed": 8,
|
||||
"skipped": 3,
|
||||
"errors": 0
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"name": "Quy-trinh-nghiem-thu-thiet-bi-v1.pdf",
|
||||
"item_id": "01BP532D2O74Z6FYQPOVBKE5DBMYDWCWCK",
|
||||
"doc_type": "textual_document",
|
||||
"policy": "skip_ocr",
|
||||
"chunks": 1,
|
||||
"status": "indexed",
|
||||
"detail": "",
|
||||
"timestamp": "2026-05-11T03:54:47.793432"
|
||||
},
|
||||
{
|
||||
"name": "Bien-ban-ban-giao-scan.pdf",
|
||||
"item_id": "01BP532D7QZ5QES5H675D2Q62S3YNCN7MV",
|
||||
"doc_type": "textual_document",
|
||||
"policy": "requires_ocr",
|
||||
"chunks": 1,
|
||||
"status": "indexed",
|
||||
"detail": "",
|
||||
"timestamp": "2026-05-11T03:54:54.449272"
|
||||
},
|
||||
{
|
||||
"name": "So-do-mat-bang-kho-A1.pdf",
|
||||
"item_id": "01BP532D5ETRRCEFMAXVF3T5UOAN7ZMWSW",
|
||||
"doc_type": "textual_document",
|
||||
"policy": "skip_ocr",
|
||||
"chunks": 1,
|
||||
"status": "indexed",
|
||||
"detail": "",
|
||||
"timestamp": "2026-05-11T03:54:57.820068"
|
||||
},
|
||||
{
|
||||
"name": "Quy-dinh-luu-tru-ho-so.docx",
|
||||
"item_id": "01BP532D7R5UFAJ3N3FZC2LUGZ4EWKXVSV",
|
||||
"doc_type": "textual_document",
|
||||
"policy": "skip_ocr",
|
||||
"chunks": 1,
|
||||
"status": "indexed",
|
||||
"detail": "",
|
||||
"timestamp": "2026-05-11T03:55:00.316683"
|
||||
},
|
||||
{
|
||||
"name": "Mo-ta-nghiep-vu.xlsx",
|
||||
"item_id": "01BP532D2GHD2SMYQZZRBJ5YBYXH32YLCC",
|
||||
"doc_type": "spreadsheet",
|
||||
"policy": "skip_ocr",
|
||||
"chunks": 1,
|
||||
"status": "indexed",
|
||||
"detail": "",
|
||||
"timestamp": "2026-05-11T03:55:02.318406"
|
||||
},
|
||||
{
|
||||
"name": "sample-layout.dxf",
|
||||
"item_id": "01BP532D2ICBWDIKS4ARA2QXJLUYYPARDV",
|
||||
"doc_type": "drawing",
|
||||
"policy": "metadata_only",
|
||||
"chunks": 0,
|
||||
"status": "skipped",
|
||||
"detail": "Native CAD drawing format",
|
||||
"timestamp": "2026-05-11T03:55:03.301004"
|
||||
},
|
||||
{
|
||||
"name": "unsupported-binary.bin",
|
||||
"item_id": "01BP532DZ77VD5TK6SVRC3EBEF3PTWPQ7J",
|
||||
"doc_type": "binary",
|
||||
"policy": "unsupported",
|
||||
"chunks": 0,
|
||||
"status": "skipped",
|
||||
"detail": "Unsupported or binary extension: .bin",
|
||||
"timestamp": "2026-05-11T03:55:04.189531"
|
||||
},
|
||||
{
|
||||
"name": "mystery-file.xyzbin",
|
||||
"item_id": "01BP532D7RTGNA3Q2TRRAIOS7LPEIBRAVT",
|
||||
"doc_type": "binary",
|
||||
"policy": "unsupported",
|
||||
"chunks": 0,
|
||||
"status": "skipped",
|
||||
"detail": "Unsupported or binary extension: .xyzbin",
|
||||
"timestamp": "2026-05-11T03:55:05.013405"
|
||||
},
|
||||
{
|
||||
"name": "README.txt",
|
||||
"item_id": "01BP532D6PKB3WWG4X5VGYBMG7IFYZTG6M",
|
||||
"doc_type": "textual_document",
|
||||
"policy": "skip_ocr",
|
||||
"chunks": 1,
|
||||
"status": "indexed",
|
||||
"detail": "",
|
||||
"timestamp": "2026-05-11T03:55:06.986536"
|
||||
},
|
||||
{
|
||||
"name": "Bien-ban-noi-bo-restricted.docx",
|
||||
"item_id": "01BP532D65XW2KEXQQMRF3JVLAQZOKIW2V",
|
||||
"doc_type": "textual_document",
|
||||
"policy": "skip_ocr",
|
||||
"chunks": 1,
|
||||
"status": "indexed",
|
||||
"detail": "",
|
||||
"timestamp": "2026-05-11T03:55:09.096708"
|
||||
},
|
||||
{
|
||||
"name": "Bang-ngan-sach-du-an.xlsx",
|
||||
"item_id": "01BP532D6KRPO27NSMUBC3FXJYN7TKDMVR",
|
||||
"doc_type": "spreadsheet",
|
||||
"policy": "skip_ocr",
|
||||
"chunks": 1,
|
||||
"status": "indexed",
|
||||
"detail": "",
|
||||
"timestamp": "2026-05-11T03:55:11.376444"
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,8 +38,9 @@ SharePoint → Ingestion → DCE → [OCR/Extract/Skip] → Chunking → OpenSea
|
||||
- **LLM Factory:** `chat/llm_factory.py` → Hỗ trợ Gemini, Groq, Local (config trong `.env`).
|
||||
|
||||
### E. Tầng API & Frontend
|
||||
- **Backend:** `api/main.py` → FastAPI tại port 8000. Endpoint: `/health`, `/auth/login` (SSO), `/auth/callback`, `/auth/login-email`, `/chat`, `/sync`, `/sync/status`.
|
||||
- **Frontend:** `frontend/` → Glassmorphism UI với SSO login + email fallback + sync button. Gọi `http://localhost:8000`.
|
||||
- **Backend:** `api/main.py` → FastAPI tại port 8000. Endpoint: `/health`, `/auth/login` (SSO), `/auth/callback`, `/auth/login-email`, `/chat`, `/sync`, `/sync/status`, `/sync/history`, `/sync/history/{run_id}`.
|
||||
- **Frontend:** `frontend/` → Glassmorphism UI với SSO login + email fallback + sync button + sync history panel. Gọi `http://localhost:8000`.
|
||||
- **Audit:** `audit/sync_audit.py` → Lưu lịch sử sync vào `audit/sync_log.json`.
|
||||
|
||||
### F. Tầng Cấu hình (Decoupled Configuration)
|
||||
- Toàn bộ thông số trong `.env`. Load qua `core/config.py`.
|
||||
@@ -83,6 +84,9 @@ SharePoint → Ingestion → DCE → [OCR/Extract/Skip] → Chunking → OpenSea
|
||||
│ └── local_llm.py
|
||||
├── 📁 api/
|
||||
│ └── main.py # 🚀 FastAPI Backend (port 8000)
|
||||
├── 📁 audit/
|
||||
│ ├── sync_audit.py # 📋 Sync audit logging (ghi lịch sử sync)
|
||||
│ └── sync_log.json # 📄 Audit log data
|
||||
├── 📁 frontend/
|
||||
│ ├── index.html # 🎨 Glassmorphism UI (Login + Chat + Sync)
|
||||
│ ├── app.js # 💬 Chat, Auth, Sync logic
|
||||
@@ -175,12 +179,10 @@ SharePoint → Ingestion → DCE → [OCR/Extract/Skip] → Chunking → OpenSea
|
||||
- [x] Auth UI: Simple email login + SSO Azure AD + user context cho API calls
|
||||
- [x] DOCX Text Extraction: python-docx (paragraphs + tables)
|
||||
- [x] XLSX Text Extraction: openpyxl (sheets + cells)
|
||||
- [x] Sync Audit: Lịch sử sync persist vào file + API + Frontend panel
|
||||
|
||||
### Chưa triển khai (Phase 9 - Production Ready)
|
||||
|
||||
#### Ưu tiên trung bình
|
||||
- [ ] **Cấu hình Azure AD cho SSO:** Thêm Redirect URI `http://localhost:8000/auth/callback` và bật "ID tokens" trong App Registration.
|
||||
|
||||
#### Ưu tiên thấp
|
||||
- [ ] **Monitoring Dashboard:** Health metrics, ingestion status, OCR success rate.
|
||||
- [ ] **Multi-tenant:** Hỗ trợ nhiều SharePoint site/tenant.
|
||||
|
||||
@@ -4,6 +4,42 @@
|
||||
|
||||
---
|
||||
|
||||
## 0. Chạy hệ thống trên Localhost
|
||||
|
||||
### Yêu cầu
|
||||
- Python 3.10+ với venv đã cài dependencies
|
||||
- Docker (cho OpenSearch)
|
||||
- Azure AD App Registration đã cấu hình
|
||||
|
||||
### Các bước khởi động
|
||||
|
||||
```bash
|
||||
# 1. Khởi động OpenSearch
|
||||
docker-compose up -d opensearch
|
||||
|
||||
# 2. Khởi động Backend (serves cả API + Frontend)
|
||||
source venv/bin/activate
|
||||
python3 api/main.py
|
||||
|
||||
# 3. Mở trình duyệt
|
||||
# http://localhost:8000
|
||||
```
|
||||
|
||||
### Truy cập
|
||||
|
||||
| URL | Mô tả |
|
||||
|-----|-------|
|
||||
| `http://localhost:8000` | Frontend (Login + Chat) |
|
||||
| `http://localhost:8000/docs` | Swagger API Documentation |
|
||||
| `http://localhost:8000/health` | Health check |
|
||||
| `http://localhost:8000/auth/login` | SSO Azure AD redirect |
|
||||
|
||||
### Đăng nhập
|
||||
- **SSO:** Bấm "Đăng nhập Microsoft SSO" → đăng nhập bằng tài khoản Microsoft 365
|
||||
- **Email:** Nhập email bất kỳ (fallback, không cần password)
|
||||
|
||||
---
|
||||
|
||||
## 1. Cấu hình Azure AD App Registration (MANUAL)
|
||||
|
||||
### 1.1 Tạo App Registration (nếu chưa có)
|
||||
@@ -36,13 +72,13 @@
|
||||
4. Bấm **Add permissions**
|
||||
5. **Quan trọng:** Bấm **Grant admin consent for [tenant]** → Confirm
|
||||
|
||||
### 1.4 Cấu hình Redirect URI cho SSO (khi cần login)
|
||||
### 1.4 Cấu hình Redirect URI cho SSO ✅ ĐÃ CẤU HÌNH
|
||||
|
||||
1. App Registration → **Authentication** → **Add a platform** → **Web**
|
||||
2. Nhập Redirect URI:
|
||||
- **PoC (localhost):** `http://localhost:8000/auth/callback`
|
||||
- **Production:** `https://your-domain.com/auth/callback`
|
||||
3. Tích chọn: ✅ **ID tokens** (implicit grant)
|
||||
- **PoC (localhost):** `http://localhost:8000/auth/callback` ✅
|
||||
- **Production:** `https://your-domain.com/auth/callback` (thêm khi có domain)
|
||||
3. Tích chọn: ✅ **ID tokens** (implicit grant) ✅
|
||||
4. Bấm **Save**
|
||||
|
||||
### 1.5 Kiểm tra Token Claims
|
||||
@@ -350,9 +386,11 @@ curl -X POST http://localhost:8000/chat \
|
||||
|
||||
## 7. Checklist trước khi Production
|
||||
|
||||
- [ ] Azure AD App Registration đã cấu hình đúng permissions
|
||||
- [ ] Client Secret còn hạn sử dụng
|
||||
- [ ] Redirect URI đã thêm cho production domain
|
||||
- [x] Azure AD App Registration đã cấu hình đúng permissions
|
||||
- [x] Client Secret còn hạn sử dụng
|
||||
- [x] Redirect URI đã thêm cho localhost (`http://localhost:8000/auth/callback`)
|
||||
- [x] ID tokens đã bật (implicit grant)
|
||||
- [ ] Redirect URI cho production domain (khi có domain thật)
|
||||
- [ ] OpenSearch đã đổi password mặc định
|
||||
- [ ] SSL certificate đã cài đặt
|
||||
- [ ] CORS đã giới hạn origins
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// API Base URL
|
||||
const API_BASE = 'http://localhost:8000';
|
||||
// API Base URL (relative khi served từ backend)
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
// DOM Elements
|
||||
const loginScreen = document.getElementById('login-screen');
|
||||
@@ -19,6 +19,10 @@ const logoutBtn = document.getElementById('logout-btn');
|
||||
const syncBtn = document.getElementById('sync-btn');
|
||||
const syncStatus = document.getElementById('sync-status');
|
||||
const ssoBtn = document.getElementById('sso-btn');
|
||||
const historyBtn = document.getElementById('history-btn');
|
||||
const historyPanel = document.getElementById('history-panel');
|
||||
const historyList = document.getElementById('history-list');
|
||||
const closeHistory = document.getElementById('close-history');
|
||||
|
||||
let chatHistory = [];
|
||||
let currentUser = null;
|
||||
@@ -287,5 +291,71 @@ async function pollSyncStatus() {
|
||||
|
||||
syncBtn.onclick = triggerSync;
|
||||
|
||||
// ====== SYNC HISTORY ======
|
||||
|
||||
async function loadSyncHistory() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/sync/history?limit=20`);
|
||||
const data = await response.json();
|
||||
|
||||
historyList.innerHTML = '';
|
||||
|
||||
if (!data.runs || data.runs.length === 0) {
|
||||
historyList.innerHTML = '<p style="color: var(--text-muted); font-size: 13px; text-align: center; padding: 20px;">Chưa có lần đồng bộ nào.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
data.runs.forEach(run => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
|
||||
const time = new Date(run.started_at).toLocaleString('vi-VN');
|
||||
const statusClass = run.status;
|
||||
const statusText = run.status === 'completed' ? 'Hoàn thành' :
|
||||
run.status === 'failed' ? 'Thất bại' : 'Đang chạy';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="history-header">
|
||||
<span class="history-time">${time}</span>
|
||||
<span class="history-status ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div class="history-summary">
|
||||
<span><i class="fas fa-check-circle" style="color: #10b981"></i> ${run.summary.processed} nạp</span>
|
||||
<span><i class="fas fa-forward" style="color: #eab308"></i> ${run.summary.skipped} bỏ</span>
|
||||
<span><i class="fas fa-exclamation-circle" style="color: #ef4444"></i> ${run.summary.errors} lỗi</span>
|
||||
</div>
|
||||
<div class="history-files">
|
||||
${run.files.map(f => `
|
||||
<div class="history-file">
|
||||
<span class="history-file-name" title="${f.name}">${f.name}</span>
|
||||
<span class="history-file-status ${f.status}">${f.chunks > 0 ? f.chunks + ' chunks' : f.status}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${run.errors.length > 0 ? `
|
||||
<div style="margin-top: 8px; font-size: 11px; color: #ef4444;">
|
||||
${run.errors.map(e => `<div>⚠ ${e}</div>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
item.onclick = () => item.classList.toggle('expanded');
|
||||
historyList.appendChild(item);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Load history error:', error);
|
||||
historyList.innerHTML = '<p style="color: #ef4444; font-size: 13px; text-align: center;">Lỗi tải lịch sử.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
historyBtn.onclick = () => {
|
||||
historyPanel.classList.toggle('active');
|
||||
if (historyPanel.classList.contains('active')) {
|
||||
loadSyncHistory();
|
||||
}
|
||||
};
|
||||
|
||||
closeHistory.onclick = () => historyPanel.classList.remove('active');
|
||||
|
||||
// Init
|
||||
checkLogin();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Knowledge Hub | Enterprise RAG</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
@@ -48,6 +48,7 @@
|
||||
<nav class="side-nav">
|
||||
<div class="nav-item active"><i class="fas fa-comments"></i> <span>Hỏi đáp RAG</span></div>
|
||||
<button class="sync-btn" id="sync-btn"><i class="fas fa-sync-alt"></i> Đồng bộ SharePoint</button>
|
||||
<button class="sync-btn" id="history-btn"><i class="fas fa-history"></i> Lịch sử đồng bộ</button>
|
||||
</nav>
|
||||
|
||||
<div class="sync-status" id="sync-status" style="display: none;">
|
||||
@@ -112,8 +113,19 @@
|
||||
<!-- Nguồn sẽ được render ở đây -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sync History Panel -->
|
||||
<section class="source-panel" id="history-panel">
|
||||
<div class="panel-header">
|
||||
<h3><i class="fas fa-history"></i> Lịch sử đồng bộ</h3>
|
||||
<button id="close-history"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="history-list" id="history-list">
|
||||
<!-- Lịch sử sẽ được render ở đây -->
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -344,6 +344,122 @@ textarea {
|
||||
.source-item h4 { font-size: 14px; margin-bottom: 5px; color: var(--primary); }
|
||||
.source-item p { font-size: 12px; color: var(--text-muted); line-height: 1.4; }
|
||||
|
||||
/* History Panel */
|
||||
.history-item {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.history-item .history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.history-item .history-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.history-item .history-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-item .history-status.completed {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.history-item .history-status.failed {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.history-item .history-status.running {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.history-item .history-summary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.history-item .history-summary span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.history-files {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.history-item.expanded .history-files {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.history-file {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.history-file:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-file-name {
|
||||
color: var(--text-main);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-file-status {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.history-file-status.indexed {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.history-file-status.skipped {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.history-file-status.error {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Login Screen */
|
||||
.login-screen {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user