feat(admin): enhance file manager with DB reconciliation view (filesystem/database/reconcile tabs)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-21 09:20:59 +00:00
parent 16e7b6478e
commit 54736ea35a
3 changed files with 531 additions and 108 deletions
+232 -46
View File
@@ -1,18 +1,25 @@
"""
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
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Set
from fastapi import Request
from fastapi import Depends, HTTPException, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.config import settings
from app.views.base import APIRouter, require_login, templates
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__)
@@ -21,6 +28,11 @@ 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)
@@ -39,78 +51,256 @@ def _safe_path(workdir: str, rel_path: str) -> Path:
"""
base = Path(workdir).resolve()
target = (base / rel_path).resolve()
if not str(target).startswith(str(base)):
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):
async def filemanager(request: Request, db: Session = Depends(get_db)):
"""
Admin-only file manager for browsing the workdir directory.
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
rel_path = request.query_params.get("path", "")
workdir_base = Path(workdir).resolve()
view = request.query_params.get("view", "filesystem")
# Sanitise the relative path strip leading slashes / dots
rel_path = rel_path.lstrip("/").lstrip(".")
# 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 = Path(workdir).resolve()
target = workdir_base
rel_path = ""
if not target.exists():
target = Path(workdir).resolve()
target = workdir_base
rel_path = ""
entries = []
if target.is_dir():
for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
try:
stat = item.stat()
item_rel = str(item.relative_to(Path(workdir).resolve()))
mime_type, _ = mimetypes.guess_type(item.name)
entries.append(
{
"name": item.name,
"rel_path": item_rel,
"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 or "",
}
)
except (PermissionError, OSError) as exc:
logger.warning(f"Could not stat {item}: {exc}")
fs_entries: List[Dict[str, Any]] = []
if view == "filesystem" and target.is_dir():
fs_entries = _scan_dir(target, workdir_base, db_paths)
# Build breadcrumb trail
# Breadcrumb for filesystem view
breadcrumbs = []
if rel_path:
parts = Path(rel_path).parts
accumulated = ""
for part in parts:
accumulated = str(Path(accumulated) / part) if accumulated else part
breadcrumbs.append({"name": part, "path": accumulated})
accumulated = Path()
for part in Path(rel_path).parts:
accumulated = accumulated / part
breadcrumbs.append({"name": part, "path": str(accumulated)})
# Parent path for the "go up" link
parent_path = str(Path(rel_path).parent) if rel_path and Path(rel_path).parent != Path(".") else ""
if parent_path == ".":
parent_path = ""
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,
"entries": entries,
# 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,
},
@@ -130,13 +320,9 @@ async def filemanager_download(request: Request):
try:
target = _safe_path(workdir, rel_path)
except ValueError:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Invalid path")
if not target.exists() or not target.is_file():
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="File not found")
mime_type, _ = mimetypes.guess_type(target.name)
+1 -1
View File
@@ -86,7 +86,7 @@
</div>
<!-- Status (de-emphasized) -->
<a href="/status" class="text-gray-400 hover:text-gray-600 text-sm" title="System Status">
<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>
+298 -61
View File
@@ -4,106 +4,148 @@
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-6">
<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-sm font-normal text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-2">Admin Only</span>
</h1>
<p class="text-gray-500 mt-1 text-sm">
Browsing: <code class="bg-gray-100 rounded px-1">{{ workdir }}</code>
{% if current_path %}/ <code class="bg-gray-100 rounded px-1">{{ current_path }}</code>{% endif %}
</p>
<!-- ── 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">
<ol class="inline-flex items-center space-x-1 flex-wrap">
<li>
<a href="/admin/files" class="text-blue-600 hover:underline flex items-center">
<i class="fas fa-home mr-1"></i> workdir
<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-400 mx-1 text-xs"></i>
<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="/admin/files?path={{ crumb.path | urlencode }}" class="text-blue-600 hover:underline">{{ crumb.name }}</a>
<a href="?view=filesystem&path={{ crumb.path | urlencode }}" class="text-blue-600 hover:underline">{{ crumb.name }}</a>
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
<!-- File listing table -->
<!-- Directory listing -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-8"></th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">Size</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden md:table-cell">Modified</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<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="bg-white divide-y divide-gray-200">
<tbody class="divide-y divide-gray-100">
<!-- Parent directory row -->
{% if current_path %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-3 text-yellow-500"><i class="fas fa-folder"></i></td>
<td class="px-6 py-3 text-sm font-medium text-gray-900" colspan="3">
<a href="/admin/files{% if parent_path %}?path={{ parent_path | urlencode }}{% endif %}" class="text-blue-600 hover:underline flex items-center gap-1">
<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 class="px-6 py-3"></td>
<td></td>
</tr>
{% endif %}
{% if entries %}
{% for entry in entries %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-3">
{% 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 %}
<i class="fas fa-folder text-yellow-400"></i>
{% elif entry.mime_type.startswith('image') %}
<i class="fas fa-file-image text-blue-400"></i>
{% elif entry.mime_type == 'application/pdf' %}
<i class="fas fa-file-pdf text-red-400"></i>
{% elif 'text' in entry.mime_type %}
<i class="fas fa-file-alt text-gray-400"></i>
{% elif 'json' in entry.mime_type %}
<i class="fas fa-file-code text-green-400"></i>
{% else %}
<i class="fas fa-file text-gray-400"></i>
{% endif %}
</td>
<td class="px-6 py-3 text-sm font-medium text-gray-900">
{% if entry.is_dir %}
<a href="/admin/files?path={{ entry.rel_path | urlencode }}" class="text-blue-600 hover:underline">
<a href="?view=filesystem&path={{ entry.rel_path | urlencode }}" class="text-blue-600 hover:underline">
{{ entry.name }}
</a>
{% else %}
<span>{{ entry.name }}</span>
<span title="{{ entry.rel_path }}">{{ entry.name }}</span>
{% endif %}
</td>
<td class="px-6 py-3 text-sm text-gray-500 hidden sm:table-cell">
{{ entry.size }}
<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-6 py-3 text-sm text-gray-500 hidden md:table-cell">
{{ entry.modified }}
</td>
<td class="px-6 py-3 text-sm">
<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 px-2.5 py-1 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50"
title="Download">
<i class="fas fa-download mr-1"></i> Download
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>
@@ -111,8 +153,8 @@
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="px-6 py-10 text-center text-sm text-gray-500">
<i class="fas fa-folder-open text-gray-300 text-3xl mb-2 block"></i>
<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>
@@ -120,6 +162,201 @@
</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 %}