From 78372d18ee0c4b0d959e1151781506c69c5a6f47 Mon Sep 17 00:00:00 2001 From: phuongtc Date: Mon, 11 May 2026 08:49:10 +0000 Subject: [PATCH] Phase 8 Complete: Sync Audit Log + Frontend Integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/main.py | 53 +++++++++++- audit/sync_audit.py | 144 +++++++++++++++++++++++++++++++ audit/sync_log.json | 128 +++++++++++++++++++++++++++ doc/00.AGENT_ARCHITECTURE_MAP.md | 12 +-- doc/DEPLOYMENT_GUIDE.md | 52 +++++++++-- frontend/app.js | 74 +++++++++++++++- frontend/index.html | 16 +++- frontend/style.css | 116 +++++++++++++++++++++++++ 8 files changed, 577 insertions(+), 18 deletions(-) create mode 100644 audit/sync_audit.py create mode 100644 audit/sync_log.json diff --git a/api/main.py b/api/main.py index 306e3b7..aa7a7f5 100644 --- a/api/main.py +++ b/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) diff --git a/audit/sync_audit.py b/audit/sync_audit.py new file mode 100644 index 0000000..3027461 --- /dev/null +++ b/audit/sync_audit.py @@ -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 diff --git a/audit/sync_log.json b/audit/sync_log.json new file mode 100644 index 0000000..d2b0c80 --- /dev/null +++ b/audit/sync_log.json @@ -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": [] + } + ] +} \ No newline at end of file diff --git a/doc/00.AGENT_ARCHITECTURE_MAP.md b/doc/00.AGENT_ARCHITECTURE_MAP.md index 018eb0b..5090e0a 100644 --- a/doc/00.AGENT_ARCHITECTURE_MAP.md +++ b/doc/00.AGENT_ARCHITECTURE_MAP.md @@ -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. diff --git a/doc/DEPLOYMENT_GUIDE.md b/doc/DEPLOYMENT_GUIDE.md index f156c22..8b948bf 100644 --- a/doc/DEPLOYMENT_GUIDE.md +++ b/doc/DEPLOYMENT_GUIDE.md @@ -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 diff --git a/frontend/app.js b/frontend/app.js index 8a81ef6..bed4d31 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 = '

Chưa có lần đồng bộ nào.

'; + 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 = ` +
+ ${time} + ${statusText} +
+
+ ${run.summary.processed} nạp + ${run.summary.skipped} bỏ + ${run.summary.errors} lỗi +
+
+ ${run.files.map(f => ` +
+ ${f.name} + ${f.chunks > 0 ? f.chunks + ' chunks' : f.status} +
+ `).join('')} + ${run.errors.length > 0 ? ` +
+ ${run.errors.map(e => `
⚠ ${e}
`).join('')} +
+ ` : ''} +
+ `; + + item.onclick = () => item.classList.toggle('expanded'); + historyList.appendChild(item); + }); + } catch (error) { + console.error('Load history error:', error); + historyList.innerHTML = '

Lỗi tải lịch sử.

'; + } +} + +historyBtn.onclick = () => { + historyPanel.classList.toggle('active'); + if (historyPanel.classList.contains('active')) { + loadSyncHistory(); + } +}; + +closeHistory.onclick = () => historyPanel.classList.remove('active'); + // Init checkLogin(); diff --git a/frontend/index.html b/frontend/index.html index 4d73d9e..22271ca 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ AI Knowledge Hub | Enterprise RAG - + @@ -48,6 +48,7 @@ + + +
+
+

Lịch sử đồng bộ

+ +
+
+ +
+
- + diff --git a/frontend/style.css b/frontend/style.css index a673e07..4d8c28a 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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;