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:
2026-05-11 08:49:10 +00:00
parent f937d1a98e
commit 78372d18ee
8 changed files with 577 additions and 18 deletions

View File

@@ -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
View 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
View 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": []
}
]
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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();

View File

@@ -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>

View File

@@ -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;