Merge pull request #343 from christianlouis/copilot/add-admin-file-manager
Admin File Manager, Menu Refactor, Dashboard Improvements & Kubernetes Env Var Quote Stripping
This commit is contained in:
+21
-2
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
from typing import List, Optional, Union
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -267,6 +267,25 @@ class Settings(BaseSettings):
|
||||
description="Stricter rate limit for authentication endpoints to prevent brute force attacks.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def strip_outer_quotes(cls, data: Any) -> Any:
|
||||
"""
|
||||
Strip matching surrounding quotes from string values.
|
||||
|
||||
In Kubernetes (and some other environments) env var values can arrive
|
||||
with literal quote characters included, e.g. the value for DATABASE_URL
|
||||
may be ``"postgresql://..."`` (with the quotes as part of the string)
|
||||
rather than just ``postgresql://...``. Docker Compose strips these
|
||||
automatically; Kubernetes does not.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, str) and len(value) >= 2:
|
||||
if (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'"):
|
||||
data[key] = value[1:-1]
|
||||
return data
|
||||
|
||||
@field_validator("notification_urls", mode="before")
|
||||
@classmethod
|
||||
def parse_notification_urls(cls, v: str | list[str]) -> list[str]:
|
||||
|
||||
@@ -5,6 +5,7 @@ Aggregated view routers for the application.
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.views.dropbox import router as dropbox_router
|
||||
from app.views.filemanager import router as filemanager_router
|
||||
|
||||
# Import all the view routers
|
||||
from app.views.general import router as general_router
|
||||
@@ -25,3 +26,4 @@ router.include_router(dropbox_router)
|
||||
router.include_router(google_drive_router)
|
||||
router.include_router(license_router) # Include the license router
|
||||
router.include_router(settings_router)
|
||||
router.include_router(filemanager_router)
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
Admin-only file manager view for browsing the workdir directory.
|
||||
|
||||
Supports three views:
|
||||
filesystem – navigate the raw workdir tree, each file tagged against DB records
|
||||
database – list all FileRecord rows, each tagged with on-disk existence
|
||||
reconcile – show only the delta: orphan disk files and ghost DB records
|
||||
"""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import FileRecord
|
||||
from app.views.base import APIRouter, get_db, require_login, templates
|
||||
from app.views.settings import require_admin_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_size(size_bytes: int) -> str:
|
||||
"""Format file size in human-readable form."""
|
||||
size = float(size_bytes)
|
||||
for unit in _SIZE_UNITS:
|
||||
if size < 1024.0:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.1f} PB"
|
||||
|
||||
|
||||
def _safe_path(workdir: str, rel_path: str) -> Path:
|
||||
"""
|
||||
Resolve a relative path inside workdir safely.
|
||||
|
||||
Raises ValueError if the resolved path escapes workdir.
|
||||
"""
|
||||
base = Path(workdir).resolve()
|
||||
target = (base / rel_path).resolve()
|
||||
if not target.is_relative_to(base):
|
||||
raise ValueError("Path traversal detected")
|
||||
return target
|
||||
|
||||
|
||||
def _db_path_set(db: Session) -> Set[str]:
|
||||
"""
|
||||
Return the set of all absolute, normalised paths that the DB references
|
||||
across local_filename, original_file_path, and processed_file_path.
|
||||
"""
|
||||
paths: Set[str] = set()
|
||||
for row in db.query(
|
||||
FileRecord.local_filename,
|
||||
FileRecord.original_file_path,
|
||||
FileRecord.processed_file_path,
|
||||
).all():
|
||||
for p in row:
|
||||
if p:
|
||||
paths.add(str(Path(p).resolve()))
|
||||
return paths
|
||||
|
||||
|
||||
def _file_icon(mime_type: str, is_dir: bool) -> str:
|
||||
"""Return a Font Awesome class for a file/directory."""
|
||||
if is_dir:
|
||||
return "fas fa-folder text-yellow-400"
|
||||
if mime_type.startswith("image/"):
|
||||
return "fas fa-file-image text-blue-400"
|
||||
if mime_type == "application/pdf":
|
||||
return "fas fa-file-pdf text-red-400"
|
||||
if "text/" in mime_type:
|
||||
return "fas fa-file-alt text-gray-400"
|
||||
if "json" in mime_type:
|
||||
return "fas fa-file-code text-green-400"
|
||||
return "fas fa-file text-gray-400"
|
||||
|
||||
|
||||
def _scan_dir(target: Path, workdir_base: Path, db_paths: Set[str]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List one directory level; annotate each file with its DB status.
|
||||
|
||||
db_status values:
|
||||
"in_db" – path found in DB
|
||||
"orphan" – on disk but not in DB
|
||||
"" – directories (not checked against DB)
|
||||
"""
|
||||
entries = []
|
||||
for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
||||
try:
|
||||
stat = item.stat()
|
||||
except (PermissionError, OSError) as exc:
|
||||
logger.warning(f"Could not stat {item}: {exc}")
|
||||
continue
|
||||
|
||||
item_rel = str(item.relative_to(workdir_base))
|
||||
mime_type, _ = mimetypes.guess_type(item.name)
|
||||
mime_type = mime_type or ""
|
||||
|
||||
if item.is_dir():
|
||||
db_status = ""
|
||||
else:
|
||||
abs_str = str(item.resolve())
|
||||
db_status = "in_db" if abs_str in db_paths else "orphan"
|
||||
|
||||
entries.append(
|
||||
{
|
||||
"name": item.name,
|
||||
"rel_path": item_rel,
|
||||
"abs_path": str(item.resolve()),
|
||||
"is_dir": item.is_dir(),
|
||||
"size": _format_size(stat.st_size) if not item.is_dir() else "",
|
||||
"size_bytes": stat.st_size if not item.is_dir() else 0,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
|
||||
"mime_type": mime_type,
|
||||
"icon": _file_icon(mime_type, item.is_dir()),
|
||||
"db_status": db_status,
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _walk_all_files(base: Path, db_paths: Set[str]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Walk the entire workdir tree and return every file (not directory).
|
||||
Used for the reconciliation view.
|
||||
"""
|
||||
entries = []
|
||||
for item in sorted(base.rglob("*"), key=lambda p: str(p).lower()):
|
||||
if item.is_dir():
|
||||
continue
|
||||
try:
|
||||
stat = item.stat()
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
|
||||
abs_str = str(item.resolve())
|
||||
mime_type, _ = mimetypes.guess_type(item.name)
|
||||
mime_type = mime_type or ""
|
||||
entries.append(
|
||||
{
|
||||
"name": item.name,
|
||||
"rel_path": str(item.relative_to(base)),
|
||||
"abs_path": abs_str,
|
||||
"is_dir": False,
|
||||
"size": _format_size(stat.st_size),
|
||||
"size_bytes": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
|
||||
"mime_type": mime_type,
|
||||
"icon": _file_icon(mime_type, False),
|
||||
"db_status": "in_db" if abs_str in db_paths else "orphan",
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _db_records(db: Session, workdir_base: Path) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return every FileRecord annotated with on-disk existence for each stored path.
|
||||
"""
|
||||
rows = []
|
||||
for rec in db.query(FileRecord).order_by(FileRecord.id.desc()).all():
|
||||
def _check(p: str | None) -> Dict[str, Any]:
|
||||
if not p:
|
||||
return {"path": None, "exists": None, "rel": None}
|
||||
resolved = Path(p).resolve()
|
||||
exists = resolved.exists()
|
||||
try:
|
||||
rel = str(resolved.relative_to(workdir_base))
|
||||
except ValueError:
|
||||
rel = p # outside workdir – show full path
|
||||
return {"path": p, "exists": exists, "rel": rel}
|
||||
|
||||
local = _check(rec.local_filename)
|
||||
original = _check(rec.original_file_path)
|
||||
processed = _check(rec.processed_file_path)
|
||||
|
||||
any_missing = any(info["exists"] is False for info in [local, original, processed])
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"id": rec.id,
|
||||
"original_filename": rec.original_filename or "—",
|
||||
"file_size": _format_size(rec.file_size) if rec.file_size else "—",
|
||||
"mime_type": rec.mime_type or "—",
|
||||
"created_at": rec.created_at.strftime("%Y-%m-%d %H:%M") if rec.created_at else "—",
|
||||
"is_duplicate": rec.is_duplicate,
|
||||
"filehash": (rec.filehash or "")[:12],
|
||||
"local": local,
|
||||
"original": original,
|
||||
"processed": processed,
|
||||
# overall health flag
|
||||
"health": "missing" if any_missing else "ok",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/admin/files")
|
||||
@require_login
|
||||
@require_admin_access
|
||||
async def filemanager(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Admin-only file manager with three views:
|
||||
?view=filesystem (default) – navigate workdir tree
|
||||
?view=database – list all DB FileRecord rows
|
||||
?view=reconcile – show only deltas (orphans + ghost records)
|
||||
"""
|
||||
workdir = settings.workdir
|
||||
workdir_base = Path(workdir).resolve()
|
||||
view = request.query_params.get("view", "filesystem")
|
||||
|
||||
# Build the DB path set once (used by all views)
|
||||
db_paths = _db_path_set(db)
|
||||
|
||||
# ── Filesystem view ───────────────────────────────────────────────────
|
||||
rel_path = request.query_params.get("path", "").lstrip("/").lstrip(".")
|
||||
try:
|
||||
target = _safe_path(workdir, rel_path)
|
||||
except ValueError:
|
||||
logger.warning(f"Path traversal attempt blocked: path={rel_path!r}")
|
||||
target = workdir_base
|
||||
rel_path = ""
|
||||
|
||||
if not target.exists():
|
||||
target = workdir_base
|
||||
rel_path = ""
|
||||
|
||||
fs_entries: List[Dict[str, Any]] = []
|
||||
if view == "filesystem" and target.is_dir():
|
||||
fs_entries = _scan_dir(target, workdir_base, db_paths)
|
||||
|
||||
# Breadcrumb for filesystem view
|
||||
breadcrumbs = []
|
||||
if rel_path:
|
||||
accumulated = Path()
|
||||
for part in Path(rel_path).parts:
|
||||
accumulated = accumulated / part
|
||||
breadcrumbs.append({"name": part, "path": str(accumulated)})
|
||||
|
||||
parent_path = ""
|
||||
if rel_path:
|
||||
parent = str(Path(rel_path).parent)
|
||||
parent_path = "" if parent == "." else parent
|
||||
|
||||
# ── Database view ─────────────────────────────────────────────────────
|
||||
db_records: List[Dict[str, Any]] = []
|
||||
if view in ("database", "reconcile"):
|
||||
db_records = _db_records(db, workdir_base)
|
||||
|
||||
# ── Reconciliation view ───────────────────────────────────────────────
|
||||
orphan_files: List[Dict[str, Any]] = []
|
||||
ghost_records: List[Dict[str, Any]] = []
|
||||
if view == "reconcile":
|
||||
all_disk = _walk_all_files(workdir_base, db_paths)
|
||||
orphan_files = [f for f in all_disk if f["db_status"] == "orphan"]
|
||||
ghost_records = [r for r in db_records if r["health"] == "missing"]
|
||||
|
||||
# ── Summary counts ────────────────────────────────────────────────────
|
||||
# Disk file count is only computed for non-filesystem views to avoid
|
||||
# the overhead of walking the full tree on every directory navigation.
|
||||
if view in ("database", "reconcile") and workdir_base.exists():
|
||||
total_disk = sum(1 for p in workdir_base.rglob("*") if p.is_file())
|
||||
else:
|
||||
total_disk = None # deferred; not shown on filesystem tab header
|
||||
total_db = db.query(FileRecord).count()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"filemanager.html",
|
||||
{
|
||||
"request": request,
|
||||
# view selector
|
||||
"view": view,
|
||||
# filesystem tab
|
||||
"fs_entries": fs_entries,
|
||||
"current_path": rel_path,
|
||||
"parent_path": parent_path,
|
||||
"breadcrumbs": breadcrumbs,
|
||||
# database tab
|
||||
"db_records": db_records,
|
||||
# reconcile tab
|
||||
"orphan_files": orphan_files,
|
||||
"ghost_records": ghost_records,
|
||||
# summary
|
||||
"total_disk": total_disk,
|
||||
"total_db": total_db,
|
||||
"workdir": workdir,
|
||||
"app_version": settings.version,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/files/download")
|
||||
@require_login
|
||||
@require_admin_access
|
||||
async def filemanager_download(request: Request):
|
||||
"""
|
||||
Admin-only endpoint to download a file from workdir.
|
||||
"""
|
||||
workdir = settings.workdir
|
||||
rel_path = request.query_params.get("path", "").lstrip("/").lstrip(".")
|
||||
|
||||
try:
|
||||
target = _safe_path(workdir, rel_path)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
if not target.exists() or not target.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(target.name)
|
||||
return FileResponse(
|
||||
path=str(target),
|
||||
filename=target.name,
|
||||
media_type=mime_type or "application/octet-stream",
|
||||
)
|
||||
@@ -16,6 +16,18 @@
|
||||
// Get the display name (prefer name, fall back to preferred_username, then email)
|
||||
const displayName = data.name || data.preferred_username || data.email;
|
||||
|
||||
// Show admin menu items if the user is an admin
|
||||
if (data.is_admin) {
|
||||
const adminMenuContainer = document.getElementById("adminMenuContainer");
|
||||
if (adminMenuContainer) {
|
||||
adminMenuContainer.classList.remove("hidden");
|
||||
}
|
||||
const mobileAdminSection = document.getElementById("mobileAdminSection");
|
||||
if (mobileAdminSection) {
|
||||
mobileAdminSection.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// User is logged in - use DOM API to prevent XSS
|
||||
if (authSection) {
|
||||
authSection.textContent = ''; // Clear existing content
|
||||
|
||||
@@ -41,13 +41,55 @@
|
||||
</div>
|
||||
|
||||
<!-- Menu Items - using x-data for mobile menu toggle -->
|
||||
<div x-data="{ mobileMenuOpen: false }">
|
||||
<div x-data="{ mobileMenuOpen: false, adminMenuOpen: false }">
|
||||
<div class="hidden md:flex space-x-4 items-center">
|
||||
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
|
||||
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
|
||||
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
|
||||
<a href="/status" class="text-gray-700 hover:text-gray-900">Status</a>
|
||||
<a href="/settings" class="text-gray-700 hover:text-gray-900">Settings</a>
|
||||
|
||||
<!-- Admin dropdown (shown only for admin users via JS) -->
|
||||
<div id="adminMenuContainer" class="relative hidden">
|
||||
<button
|
||||
@click="adminMenuOpen = !adminMenuOpen"
|
||||
@click.outside="adminMenuOpen = false"
|
||||
type="button"
|
||||
class="inline-flex items-center text-gray-700 hover:text-gray-900 focus:outline-none"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="adminMenuOpen"
|
||||
>
|
||||
<i class="fas fa-shield-alt mr-1 text-red-500"></i>
|
||||
Admin
|
||||
<i class="fas fa-chevron-down ml-1 text-xs"></i>
|
||||
</button>
|
||||
<div
|
||||
x-show="adminMenuOpen"
|
||||
x-transition:enter="transition ease-out duration-100 transform"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75 transform"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
|
||||
>
|
||||
<div class="py-1">
|
||||
<a href="/settings" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-cog w-4 mr-2 text-gray-500"></i> Settings
|
||||
</a>
|
||||
<a href="/env" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-terminal w-4 mr-2 text-gray-500"></i> Environment
|
||||
</a>
|
||||
<a href="/admin/files" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-folder-open w-4 mr-2 text-gray-500"></i> File Manager
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status (de-emphasized) -->
|
||||
<a href="/status" class="text-gray-500 hover:text-gray-700 text-sm" title="System Status">
|
||||
<i class="fas fa-circle-dot mr-0.5"></i> Status
|
||||
</a>
|
||||
|
||||
<a href="/about" class="text-gray-700 hover:text-gray-900">About</a>
|
||||
|
||||
<!-- Dynamic Auth Section -->
|
||||
@@ -82,8 +124,30 @@
|
||||
<a href="/" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Home</a>
|
||||
<a href="/upload" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Upload</a>
|
||||
<a href="/files" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Files</a>
|
||||
<a href="/status" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Status</a>
|
||||
<a href="/settings" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Settings</a>
|
||||
|
||||
<!-- Admin section in mobile menu (shown only for admin users via JS) -->
|
||||
<div id="mobileAdminSection" class="hidden">
|
||||
<div class="border-t border-gray-200 mt-1 pt-1">
|
||||
<p class="px-3 py-1 text-xs font-semibold text-red-600 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-shield-alt mr-1"></i> Admin
|
||||
</p>
|
||||
<a href="/settings" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-cog mr-2 text-gray-400"></i> Settings
|
||||
</a>
|
||||
<a href="/env" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-terminal mr-2 text-gray-400"></i> Environment
|
||||
</a>
|
||||
<a href="/admin/files" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-folder-open mr-2 text-gray-400"></i> File Manager
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status (de-emphasized) in mobile -->
|
||||
<a href="/status" class="block px-3 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-gray-600 hover:bg-gray-50">
|
||||
<i class="fas fa-circle-dot mr-1"></i> Status
|
||||
</a>
|
||||
|
||||
<a href="/about" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">About</a>
|
||||
|
||||
<!-- Mobile Auth Section -->
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}File Manager – Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<i class="fas fa-folder-open text-blue-500"></i>
|
||||
File Manager
|
||||
<span class="text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-1">Admin Only</span>
|
||||
</h1>
|
||||
<p class="text-gray-400 text-sm mt-1">
|
||||
<code class="bg-gray-100 rounded px-1 text-xs">{{ workdir }}</code>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Summary badges -->
|
||||
<div class="flex gap-3 text-sm flex-wrap">
|
||||
<span class="inline-flex items-center gap-1.5 bg-blue-50 border border-blue-200 text-blue-700 rounded-full px-3 py-1">
|
||||
<i class="fas fa-hdd text-xs"></i>
|
||||
{% if total_disk is none %}…{% else %}{{ total_disk }}{% endif %} on disk
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 bg-green-50 border border-green-200 text-green-700 rounded-full px-3 py-1">
|
||||
<i class="fas fa-database text-xs"></i> {{ total_db }} in DB
|
||||
</span>
|
||||
{% if total_disk is not none and total_disk != total_db %}
|
||||
<span class="inline-flex items-center gap-1.5 bg-amber-50 border border-amber-300 text-amber-700 rounded-full px-3 py-1 font-medium">
|
||||
<i class="fas fa-triangle-exclamation text-xs"></i> Delta detected
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── View tabs ────────────────────────────────────────────────────────── -->
|
||||
<div class="flex border-b border-gray-200 mb-6 gap-1">
|
||||
<a href="?view=filesystem"
|
||||
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
|
||||
{% if view == 'filesystem' %}border-blue-600 text-blue-600 bg-blue-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
|
||||
<i class="fas fa-folder-tree mr-1.5"></i>Filesystem
|
||||
</a>
|
||||
<a href="?view=database"
|
||||
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
|
||||
{% if view == 'database' %}border-green-600 text-green-600 bg-green-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
|
||||
<i class="fas fa-database mr-1.5"></i>Database Records
|
||||
</a>
|
||||
<a href="?view=reconcile"
|
||||
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
|
||||
{% if view == 'reconcile' %}border-amber-500 text-amber-600 bg-amber-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
|
||||
<i class="fas fa-code-compare mr-1.5"></i>Reconcile
|
||||
{% if total_disk != total_db %}
|
||||
<span class="ml-1 bg-amber-500 text-white rounded-full text-xs px-1.5 py-0.5">!</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
FILESYSTEM TAB
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
{% if view == 'filesystem' %}
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-4 text-xs text-gray-500 mb-4">
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> Found in DB</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-amber-400 inline-block"></span> Not in DB (orphan)</span>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 flex-wrap">
|
||||
<li>
|
||||
<a href="?view=filesystem" class="text-blue-600 hover:underline flex items-center gap-1">
|
||||
<i class="fas fa-home"></i> workdir
|
||||
</a>
|
||||
</li>
|
||||
{% for crumb in breadcrumbs %}
|
||||
<li class="flex items-center">
|
||||
<i class="fas fa-chevron-right text-gray-300 mx-1 text-xs"></i>
|
||||
{% if loop.last %}
|
||||
<span class="text-gray-700 font-medium">{{ crumb.name }}</span>
|
||||
{% else %}
|
||||
<a href="?view=filesystem&path={{ crumb.path | urlencode }}" class="text-blue-600 hover:underline">{{ crumb.name }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Directory listing -->
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left w-8"></th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">Name</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden sm:table-cell">Size</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden md:table-cell">Modified</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">DB</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
|
||||
{% if current_path %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-yellow-400"><i class="fas fa-folder"></i></td>
|
||||
<td class="px-4 py-3 font-medium text-gray-900" colspan="4">
|
||||
<a href="?view=filesystem{% if parent_path %}&path={{ parent_path | urlencode }}{% endif %}"
|
||||
class="text-blue-600 hover:underline flex items-center gap-1">
|
||||
<i class="fas fa-level-up-alt text-xs"></i> ..
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if fs_entries %}
|
||||
{% for entry in fs_entries %}
|
||||
<tr class="hover:bg-gray-50 {% if entry.db_status == 'orphan' %}bg-amber-50{% endif %}">
|
||||
<td class="px-4 py-3"><i class="{{ entry.icon }}"></i></td>
|
||||
<td class="px-4 py-3 font-medium text-gray-900 max-w-xs truncate">
|
||||
{% if entry.is_dir %}
|
||||
<a href="?view=filesystem&path={{ entry.rel_path | urlencode }}" class="text-blue-600 hover:underline">
|
||||
{{ entry.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span title="{{ entry.rel_path }}">{{ entry.name }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 hidden sm:table-cell">{{ entry.size }}</td>
|
||||
<td class="px-4 py-3 text-gray-400 hidden md:table-cell">{{ entry.modified }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if entry.db_status == 'in_db' %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span> In DB
|
||||
</span>
|
||||
{% elif entry.db_status == 'orphan' %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span> Orphan
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if not entry.is_dir %}
|
||||
<a href="/admin/files/download?path={{ entry.rel_path | urlencode }}"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 border border-gray-200 text-xs font-medium rounded text-gray-600 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-download text-xs"></i> Download
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-10 text-center text-sm text-gray-400">
|
||||
<i class="fas fa-folder-open text-gray-200 text-3xl mb-2 block"></i>
|
||||
This directory is empty.
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
DATABASE TAB
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
{% if view == 'database' %}
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-4 text-xs text-gray-500 mb-4">
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> File exists on disk</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-red-400 inline-block"></span> File missing from disk</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-gray-300 inline-block"></span> Path not set</span>
|
||||
</div>
|
||||
|
||||
{% if db_records %}
|
||||
<div class="bg-white rounded-lg shadow overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">ID</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Original Filename</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Size</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden lg:table-cell">Ingested</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">local_filename</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">original_file_path</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">processed_file_path</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for rec in db_records %}
|
||||
<tr class="hover:bg-gray-50 {% if rec.health == 'missing' %}bg-red-50{% endif %}">
|
||||
<td class="px-4 py-3 text-gray-400 font-mono text-xs">{{ rec.id }}</td>
|
||||
<td class="px-4 py-3 font-medium text-gray-800 max-w-xs truncate" title="{{ rec.original_filename }}">
|
||||
{% if rec.is_duplicate %}
|
||||
<span class="text-xs text-purple-600 bg-purple-50 border border-purple-200 rounded px-1 mr-1">dup</span>
|
||||
{% endif %}
|
||||
{{ rec.original_filename }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 hidden md:table-cell">{{ rec.file_size }}</td>
|
||||
<td class="px-4 py-3 text-gray-400 text-xs hidden lg:table-cell">{{ rec.created_at }}</td>
|
||||
|
||||
{# Helper macro: render a path cell #}
|
||||
{% for path_info in [rec.local, rec.original, rec.processed] %}
|
||||
<td class="px-4 py-3 text-xs font-mono max-w-xs">
|
||||
{% if path_info.path is none %}
|
||||
<span class="text-gray-300">—</span>
|
||||
{% elif path_info.exists %}
|
||||
<span class="flex items-start gap-1">
|
||||
<span class="h-2 w-2 rounded-full bg-green-400 mt-0.5 flex-shrink-0"></span>
|
||||
<span class="text-gray-600 truncate" title="{{ path_info.path }}">{{ path_info.rel }}</span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="flex items-start gap-1">
|
||||
<span class="h-2 w-2 rounded-full bg-red-400 mt-0.5 flex-shrink-0"></span>
|
||||
<span class="text-red-700 line-through truncate" title="{{ path_info.path }}">{{ path_info.rel }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
|
||||
<td class="px-4 py-3">
|
||||
{% if rec.health == 'ok' %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5">
|
||||
<i class="fas fa-check text-xs"></i> OK
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-red-700 bg-red-50 border border-red-200 rounded-full px-2 py-0.5">
|
||||
<i class="fas fa-triangle-exclamation text-xs"></i> Missing
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-white rounded-lg shadow p-10 text-center text-gray-400">
|
||||
<i class="fas fa-database text-4xl mb-3 block text-gray-200"></i>
|
||||
No file records found in the database.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
RECONCILE TAB
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
{% if view == 'reconcile' %}
|
||||
|
||||
{% if orphan_files or ghost_records %}
|
||||
<div class="bg-amber-50 border border-amber-300 rounded-lg px-5 py-4 mb-6 flex items-start gap-3">
|
||||
<i class="fas fa-triangle-exclamation text-amber-500 mt-0.5"></i>
|
||||
<div class="text-sm text-amber-800">
|
||||
<strong>Delta detected.</strong>
|
||||
Found <strong>{{ orphan_files | length }}</strong> orphan file(s) on disk with no DB record,
|
||||
and <strong>{{ ghost_records | length }}</strong> DB record(s) with missing files on disk.
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-green-50 border border-green-300 rounded-lg px-5 py-4 mb-6 flex items-center gap-3">
|
||||
<i class="fas fa-circle-check text-green-500"></i>
|
||||
<span class="text-sm text-green-800 font-medium">No delta found — filesystem and database are in sync.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Orphan files (on disk, not in DB) -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<span class="h-3 w-3 rounded-full bg-amber-400 inline-block"></span>
|
||||
Orphan files <span class="text-gray-400 font-normal text-sm ml-1">(on disk, no DB record)</span>
|
||||
<span class="text-xs bg-amber-100 text-amber-700 border border-amber-200 rounded-full px-2 py-0.5 ml-auto">{{ orphan_files | length }}</span>
|
||||
</h2>
|
||||
{% if orphan_files %}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-amber-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left w-8"></th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Path (relative to workdir)</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden sm:table-cell">Size</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Modified</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for f in orphan_files %}
|
||||
<tr class="hover:bg-amber-50">
|
||||
<td class="px-4 py-3"><i class="{{ f.icon }}"></i></td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-amber-800 max-w-xs truncate" title="{{ f.rel_path }}">{{ f.rel_path }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 hidden sm:table-cell">{{ f.size }}</td>
|
||||
<td class="px-4 py-3 text-gray-400 text-xs hidden md:table-cell">{{ f.modified }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="/admin/files/download?path={{ f.rel_path | urlencode }}"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 border border-gray-200 text-xs font-medium rounded text-gray-600 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-download text-xs"></i> Download
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400 italic">No orphan files found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Ghost records (in DB, file missing on disk) -->
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<span class="h-3 w-3 rounded-full bg-red-400 inline-block"></span>
|
||||
Ghost records <span class="text-gray-400 font-normal text-sm ml-1">(in DB, file(s) missing on disk)</span>
|
||||
<span class="text-xs bg-red-100 text-red-700 border border-red-200 rounded-full px-2 py-0.5 ml-auto">{{ ghost_records | length }}</span>
|
||||
</h2>
|
||||
{% if ghost_records %}
|
||||
<div class="bg-white rounded-lg shadow overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-red-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">ID</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Original Filename</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Ingested</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Missing paths</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for rec in ghost_records %}
|
||||
<tr class="hover:bg-red-50">
|
||||
<td class="px-4 py-3 text-gray-400 font-mono text-xs">{{ rec.id }}</td>
|
||||
<td class="px-4 py-3 font-medium text-gray-800 max-w-xs truncate" title="{{ rec.original_filename }}">
|
||||
{{ rec.original_filename }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-400 text-xs hidden md:table-cell">{{ rec.created_at }}</td>
|
||||
<td class="px-4 py-3 text-xs font-mono">
|
||||
{% for path_info in [rec.local, rec.original, rec.processed] %}
|
||||
{% if path_info.exists is sameas false %}
|
||||
<div class="flex items-start gap-1 text-red-700">
|
||||
<span class="h-2 w-2 rounded-full bg-red-400 mt-0.5 flex-shrink-0"></span>
|
||||
<span class="line-through truncate" title="{{ path_info.path }}">{{ path_info.rel }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400 italic">No ghost records found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+137
-114
@@ -1,136 +1,159 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Home - DocuElevate{% endblock %}
|
||||
{% block title %}Dashboard – DocuElevate{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="bg-gradient-to-r from-blue-500 to-indigo-600 rounded-lg shadow-lg text-white p-8 mb-8">
|
||||
<h1 class="text-4xl font-bold mb-4">Welcome to DocuElevate</h1>
|
||||
<p class="text-xl mb-6">
|
||||
Your intelligent solution for processing, managing, and organizing documents effortlessly.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<a href="/upload" class="bg-white text-blue-600 hover:bg-gray-100 font-bold py-2 px-4 rounded-lg transition duration-300 flex items-center">
|
||||
<i class="fas fa-upload mr-2"></i> Upload Document
|
||||
</a>
|
||||
<a href="/status" class="bg-transparent border border-white text-white hover:bg-white hover:text-blue-600 font-bold py-2 px-4 rounded-lg transition duration-300 flex items-center">
|
||||
<i class="fas fa-cog mr-2"></i> System Status
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-4">System Overview</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="border-r border-gray-200 px-4">
|
||||
<p class="text-4xl font-bold text-blue-600">{{ stats.processed_files|default('0') }}</p>
|
||||
<p class="text-gray-600">Documents Processed</p>
|
||||
<!-- Hero / Quick Actions -->
|
||||
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-xl shadow-lg text-white p-8 mb-8">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-1">DocuElevate Dashboard</h1>
|
||||
<p class="text-blue-100 text-sm">Intelligent document processing & management</p>
|
||||
</div>
|
||||
<div class="border-r border-gray-200 px-4">
|
||||
<p class="text-4xl font-bold text-green-600">{{ stats.active_integrations|default('0') }}</p>
|
||||
<p class="text-gray-600">Active Integrations</p>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<p class="text-4xl font-bold text-indigo-600">{{ stats.storage_targets|default('0') }}</p>
|
||||
<p class="text-gray-600">Storage Targets</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="/upload" class="bg-white text-blue-700 hover:bg-blue-50 font-semibold py-2 px-5 rounded-lg transition duration-200 flex items-center shadow-sm">
|
||||
<i class="fas fa-upload mr-2"></i> Upload
|
||||
</a>
|
||||
<a href="/files" class="bg-transparent border border-white text-white hover:bg-white hover:text-blue-700 font-semibold py-2 px-5 rounded-lg transition duration-200 flex items-center">
|
||||
<i class="fas fa-list mr-2"></i> Browse Files
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Section -->
|
||||
<h2 class="text-3xl font-semibold mb-6">Core Features</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-10">
|
||||
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
|
||||
<div class="text-blue-600 mb-4">
|
||||
<i class="fas fa-file-alt fa-2x"></i>
|
||||
<!-- Stats Row -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-xl shadow p-6 flex items-center gap-4">
|
||||
<div class="h-12 w-12 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-file-alt text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">Document Processing</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Upload and process documents with OCR, metadata extraction, and intelligent classification.
|
||||
</p>
|
||||
<a href="/upload" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
Start processing →
|
||||
</a>
|
||||
<div>
|
||||
<p class="text-3xl font-bold text-blue-600">{{ stats.processed_files | default(0) }}</p>
|
||||
<p class="text-gray-500 text-sm">Documents Processed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow p-6 flex items-center gap-4">
|
||||
<div class="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-plug text-green-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-3xl font-bold text-green-600">{{ stats.active_integrations | default(0) }}</p>
|
||||
<p class="text-gray-500 text-sm">Active Integrations</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow p-6 flex items-center gap-4">
|
||||
<div class="h-12 w-12 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-database text-indigo-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-3xl font-bold text-indigo-600">{{ stats.storage_targets | default(0) }}</p>
|
||||
<p class="text-gray-500 text-sm">Storage Targets</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Widget Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
|
||||
<!-- Quick Actions Widget -->
|
||||
<div class="bg-white rounded-xl shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-bolt text-yellow-500"></i> Quick Actions
|
||||
</h2>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="/upload" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
|
||||
<span class="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 group-hover:bg-blue-200">
|
||||
<i class="fas fa-upload text-blue-600 text-sm"></i>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800">Upload Document</p>
|
||||
<p class="text-xs text-gray-400">Process a new file</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/files" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
|
||||
<span class="h-8 w-8 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 group-hover:bg-indigo-200">
|
||||
<i class="fas fa-list text-indigo-600 text-sm"></i>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800">View All Files</p>
|
||||
<p class="text-xs text-gray-400">Browse processed documents</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/status" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
|
||||
<span class="h-8 w-8 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0 group-hover:bg-green-200">
|
||||
<i class="fas fa-heartbeat text-green-600 text-sm"></i>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800">System Status</p>
|
||||
<p class="text-xs text-gray-400">Check integration health</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
|
||||
<div class="text-blue-600 mb-4">
|
||||
<i class="fas fa-cloud fa-2x"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">Multiple Storage Options</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Connect to Dropbox, NextCloud, OneDrive and other storage solutions seamlessly.
|
||||
</p>
|
||||
<a href="/status" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
Configure storage →
|
||||
</a>
|
||||
<!-- Capabilities Widget -->
|
||||
<div class="bg-white rounded-xl shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-cubes text-blue-500"></i> Capabilities
|
||||
</h2>
|
||||
<ul class="space-y-3 text-sm text-gray-600">
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0"></i>
|
||||
<span>OCR & metadata extraction with AI</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0"></i>
|
||||
<span>Cloud storage: Dropbox, OneDrive, Google Drive, NextCloud</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0"></i>
|
||||
<span>Paperless-ngx integration for document management</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0"></i>
|
||||
<span>Email & URL-based document ingestion</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0"></i>
|
||||
<span>Automated classification & routing workflows</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
|
||||
<div class="text-blue-600 mb-4">
|
||||
<i class="fas fa-robot fa-2x"></i>
|
||||
<!-- Getting Started / Help Widget -->
|
||||
<div class="bg-white rounded-xl shadow p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-compass text-indigo-500"></i> Getting Started
|
||||
</h2>
|
||||
<ol class="space-y-3 text-sm text-gray-600 list-none">
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="h-5 w-5 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center flex-shrink-0 font-bold mt-0.5">1</span>
|
||||
Configure integrations via <a href="/status" class="text-blue-600 hover:underline">System Status</a>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="h-5 w-5 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center flex-shrink-0 font-bold mt-0.5">2</span>
|
||||
<a href="/upload" class="text-blue-600 hover:underline">Upload</a> your first document
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="h-5 w-5 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center flex-shrink-0 font-bold mt-0.5">3</span>
|
||||
Review results in <a href="/files" class="text-blue-600 hover:underline">Files</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">AI-Powered Analysis</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Leverage AI to extract metadata, classify documents, and organize your information.
|
||||
</p>
|
||||
<a href="/files" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
View processed files →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
|
||||
<div class="text-blue-600 mb-4">
|
||||
<i class="fas fa-envelope fa-2x"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">Email Integration</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Auto-process documents received via email with our IMAP polling capabilities.
|
||||
</p>
|
||||
<a href="/status" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
Set up email →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
|
||||
<div class="text-blue-600 mb-4">
|
||||
<i class="fas fa-cogs fa-2x"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">Workflow Automation</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Create workflows to automatically process and route documents based on content.
|
||||
</p>
|
||||
<a href="/about" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
Learn more →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
|
||||
<div class="text-blue-600 mb-4">
|
||||
<i class="fas fa-shield-alt fa-2x"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">Secure & Private</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Your documents are processed securely with privacy-focused design principles.
|
||||
</p>
|
||||
<a href="/privacy" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
Privacy policy →
|
||||
<a href="/about" class="mt-6 inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 font-medium">
|
||||
Learn more about DocuElevate <i class="fas fa-arrow-right ml-1 text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Getting Started Section -->
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between">
|
||||
<div class="mb-6 md:mb-0">
|
||||
<h2 class="text-2xl font-bold mb-2">Ready to get started?</h2>
|
||||
<p class="text-gray-600">Upload your first document and see DocuElevate in action.</p>
|
||||
</div>
|
||||
<a href="/upload" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition duration-300">
|
||||
Try it now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -289,3 +289,94 @@ class TestSecurityConfiguration:
|
||||
|
||||
# Should not raise an error
|
||||
assert config.database_url == "sqlite:///test.db"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers shared across quote-stripping tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BASE_KWARGS = dict(
|
||||
auth_enabled=False,
|
||||
azure_ai_key="test",
|
||||
azure_region="eastus",
|
||||
azure_endpoint="https://test.example.com",
|
||||
gotenberg_url="http://localhost:3000",
|
||||
workdir="/tmp",
|
||||
openai_api_key="sk-test",
|
||||
redis_url="redis://localhost:6379",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOuterQuoteStripping:
|
||||
"""
|
||||
Tests that Settings strips surrounding quotes from string env var values.
|
||||
|
||||
In Kubernetes env vars can arrive with literal quote characters included
|
||||
(e.g. DATABASE_URL="postgresql://..." with the quotes as part of the value).
|
||||
Docker Compose strips these automatically; Kubernetes does not.
|
||||
"""
|
||||
|
||||
def test_double_quotes_stripped_from_url(self):
|
||||
"""Double-quoted URL value has quotes removed."""
|
||||
config = Settings(
|
||||
database_url='"sqlite:///test.db"',
|
||||
**_BASE_KWARGS,
|
||||
)
|
||||
assert config.database_url == "sqlite:///test.db"
|
||||
|
||||
def test_single_quotes_stripped_from_url(self):
|
||||
"""Single-quoted URL value has quotes removed."""
|
||||
config = Settings(
|
||||
database_url="'sqlite:///test.db'",
|
||||
**_BASE_KWARGS,
|
||||
)
|
||||
assert config.database_url == "sqlite:///test.db"
|
||||
|
||||
def test_double_quotes_stripped_from_optional_field(self):
|
||||
"""Quotes are stripped from optional string fields too."""
|
||||
config = Settings(
|
||||
database_url="sqlite:///test.db",
|
||||
dropbox_app_key='"my-app-key"',
|
||||
**_BASE_KWARGS,
|
||||
)
|
||||
assert config.dropbox_app_key == "my-app-key"
|
||||
|
||||
def test_unquoted_value_unchanged(self):
|
||||
"""Values without surrounding quotes are left as-is."""
|
||||
config = Settings(
|
||||
database_url="sqlite:///test.db",
|
||||
**_BASE_KWARGS,
|
||||
)
|
||||
assert config.database_url == "sqlite:///test.db"
|
||||
|
||||
def test_mismatched_quotes_not_stripped(self):
|
||||
"""Mismatched quotes (open with one type, close with another) are NOT stripped."""
|
||||
raw = '"sqlite:///test.db\''
|
||||
config = Settings(
|
||||
database_url=raw,
|
||||
**_BASE_KWARGS,
|
||||
)
|
||||
assert config.database_url == raw
|
||||
|
||||
def test_single_quote_char_not_stripped(self):
|
||||
"""A single-character string that is just one quote is NOT modified."""
|
||||
# A value of exactly one character cannot have matching outer quotes
|
||||
config = Settings(
|
||||
database_url="sqlite:///test.db",
|
||||
openai_model='"',
|
||||
**_BASE_KWARGS,
|
||||
)
|
||||
assert config.openai_model == '"'
|
||||
|
||||
def test_multiple_fields_stripped_simultaneously(self):
|
||||
"""Multiple quoted fields in the same config are all stripped."""
|
||||
config = Settings(
|
||||
database_url='"sqlite:///test.db"',
|
||||
redis_url='"redis://localhost:6379"',
|
||||
workdir='"/data/workdir"',
|
||||
**{k: v for k, v in _BASE_KWARGS.items() if k not in ("redis_url", "workdir")},
|
||||
)
|
||||
assert config.database_url == "sqlite:///test.db"
|
||||
assert config.redis_url == "redis://localhost:6379"
|
||||
assert config.workdir == "/data/workdir"
|
||||
|
||||
Reference in New Issue
Block a user