feature/file-manager-user-only-read
This commit is contained in:
@@ -1,19 +1,147 @@
|
|||||||
"""
|
"""
|
||||||
Audit Log Router
|
Audit Log Router
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_, desc, distinct
|
from sqlalchemy import or_, desc, distinct
|
||||||
|
|
||||||
from core.database import get_core_db
|
from core.database import get_core_db
|
||||||
from core.security import get_current_user # Assuming this exists
|
from core.security import get_current_user, get_tenant_from_token
|
||||||
|
from core.storage_s3 import get_object_bytes, list_objects_tree, should_ensure_s3_bucket
|
||||||
from .models import AuditLog
|
from .models import AuditLog
|
||||||
from .schemas import AuditLogListResponse, AuditLogResponse, AuditLogDetailResponse
|
from .schemas import (
|
||||||
|
AuditFileBreadcrumb,
|
||||||
|
AuditFileBrowserResponse,
|
||||||
|
AuditFileFolderItem,
|
||||||
|
AuditFileObjectItem,
|
||||||
|
AuditLogDetailResponse,
|
||||||
|
AuditLogListResponse,
|
||||||
|
)
|
||||||
|
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_SEGMENT_LABELS = {
|
||||||
|
"tenants": "Espacio",
|
||||||
|
"companies": "Companias",
|
||||||
|
"users": "Usuarios",
|
||||||
|
"imports": "Importaciones",
|
||||||
|
"csv": "Archivos CSV",
|
||||||
|
"branding": "Logotipos",
|
||||||
|
"certificates": "Certificados",
|
||||||
|
"customs_brokers": "Agentes aduanales",
|
||||||
|
"keys": "Llaves",
|
||||||
|
"cove": "COVE",
|
||||||
|
"doda": "DODA",
|
||||||
|
"system": "Sistema",
|
||||||
|
"help": "Ayuda",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_id_from_user(current_user: Dict[str, Any]) -> int:
|
||||||
|
tenant_id = get_tenant_from_token(current_user) or current_user.get("tenant_id")
|
||||||
|
if not tenant_id:
|
||||||
|
raise HTTPException(status_code=401, detail="User context is invalid")
|
||||||
|
return int(tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_relative_path(raw: Optional[str]) -> str:
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
val = raw.strip().strip("/")
|
||||||
|
if not val:
|
||||||
|
return ""
|
||||||
|
if ".." in val or "\\" in val:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid path")
|
||||||
|
parts = [p for p in val.split("/") if p]
|
||||||
|
for part in parts:
|
||||||
|
if part in (".", ".."):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid path segment")
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_prefix(tenant_id: int) -> str:
|
||||||
|
return f"tenants/{tenant_id}/"
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_from_tenant_prefix(key: str, tenant_prefix: str) -> str:
|
||||||
|
if not key.startswith(tenant_prefix):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to object key")
|
||||||
|
return key[len(tenant_prefix) :].strip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _companies_map(db: Session, tenant_id: int) -> Dict[str, str]:
|
||||||
|
rows = (
|
||||||
|
db.query(Company.id, Company.name)
|
||||||
|
.filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for company_id, company_name in rows:
|
||||||
|
if company_id is None:
|
||||||
|
continue
|
||||||
|
safe_name = (company_name or "").strip()
|
||||||
|
out[str(company_id)] = safe_name or "Compania"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _display_segment(part: str, prev_part: Optional[str], company_names: Dict[str, str]) -> str:
|
||||||
|
if prev_part == "companies":
|
||||||
|
return company_names.get(part, "Compania")
|
||||||
|
if part in _SEGMENT_LABELS:
|
||||||
|
return _SEGMENT_LABELS[part]
|
||||||
|
# Evita exponer IDs puros en UI.
|
||||||
|
if part.isdigit():
|
||||||
|
return "Elemento"
|
||||||
|
return part.replace("_", " ").strip().title() or "Elemento"
|
||||||
|
|
||||||
|
|
||||||
|
def _display_path(rel_path: str, company_names: Dict[str, str]) -> str:
|
||||||
|
if not rel_path:
|
||||||
|
return "Raiz de archivos"
|
||||||
|
parts = [p for p in rel_path.split("/") if p]
|
||||||
|
labels: List[str] = []
|
||||||
|
prev: Optional[str] = None
|
||||||
|
for part in parts:
|
||||||
|
labels.append(_display_segment(part, prev, company_names))
|
||||||
|
prev = part
|
||||||
|
return " / ".join(labels)
|
||||||
|
|
||||||
|
|
||||||
|
def _display_file_name(filename: str) -> str:
|
||||||
|
stem, dot, ext = filename.rpartition(".")
|
||||||
|
if not dot:
|
||||||
|
stem = filename
|
||||||
|
ext = ""
|
||||||
|
if stem.isdigit():
|
||||||
|
return f"Archivo{f'.{ext}' if ext else ''}"
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
def _build_breadcrumbs(rel_path: str, company_names: Dict[str, str]) -> List[AuditFileBreadcrumb]:
|
||||||
|
breadcrumbs: List[AuditFileBreadcrumb] = [
|
||||||
|
AuditFileBreadcrumb(path="", display_name="Raiz de archivos")
|
||||||
|
]
|
||||||
|
if not rel_path:
|
||||||
|
return breadcrumbs
|
||||||
|
parts = [p for p in rel_path.split("/") if p]
|
||||||
|
prev: Optional[str] = None
|
||||||
|
acc: List[str] = []
|
||||||
|
for part in parts:
|
||||||
|
acc.append(part)
|
||||||
|
breadcrumbs.append(
|
||||||
|
AuditFileBreadcrumb(
|
||||||
|
path="/".join(acc),
|
||||||
|
display_name=_display_segment(part, prev, company_names),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
prev = part
|
||||||
|
return breadcrumbs
|
||||||
|
|
||||||
@router.get("/bitacora", response_model=AuditLogListResponse)
|
@router.get("/bitacora", response_model=AuditLogListResponse)
|
||||||
async def get_bitacora(
|
async def get_bitacora(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -91,3 +219,101 @@ async def get_audit_detail(spec_id: int, db: Session = Depends(get_core_db)):
|
|||||||
if not log:
|
if not log:
|
||||||
raise HTTPException(status_code=404, detail="Log entry not found")
|
raise HTTPException(status_code=404, detail="Log entry not found")
|
||||||
return log
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files", response_model=AuditFileBrowserResponse)
|
||||||
|
async def list_tenant_files(
|
||||||
|
path: Optional[str] = Query(default="", description="Ruta relativa de navegación."),
|
||||||
|
continuation_token: Optional[str] = Query(default=None),
|
||||||
|
max_keys: int = Query(default=100, ge=1, le=500),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Explorador de archivos de solo lectura para Auditoría.
|
||||||
|
"""
|
||||||
|
if not should_ensure_s3_bucket():
|
||||||
|
raise HTTPException(status_code=400, detail="S3 storage is disabled")
|
||||||
|
|
||||||
|
tenant_id = _tenant_id_from_user(current_user)
|
||||||
|
tenant_prefix = _tenant_prefix(tenant_id)
|
||||||
|
rel_path = _normalize_relative_path(path)
|
||||||
|
list_prefix = f"{tenant_prefix}{rel_path}/" if rel_path else tenant_prefix
|
||||||
|
|
||||||
|
data = list_objects_tree(
|
||||||
|
prefix=list_prefix,
|
||||||
|
delimiter="/",
|
||||||
|
max_keys=max_keys,
|
||||||
|
continuation_token=continuation_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
company_names = _companies_map(db, tenant_id)
|
||||||
|
folders: List[AuditFileFolderItem] = []
|
||||||
|
for prefix in data.get("prefixes", []):
|
||||||
|
rel = _relative_from_tenant_prefix(prefix, tenant_prefix)
|
||||||
|
folders.append(
|
||||||
|
AuditFileFolderItem(
|
||||||
|
path=rel,
|
||||||
|
display_name=_display_path(rel, company_names).split(" / ")[-1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
files: List[AuditFileObjectItem] = []
|
||||||
|
for obj in data.get("objects", []):
|
||||||
|
key = obj.get("key")
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
rel = _relative_from_tenant_prefix(key, tenant_prefix)
|
||||||
|
name = rel.rsplit("/", 1)[-1]
|
||||||
|
files.append(
|
||||||
|
AuditFileObjectItem(
|
||||||
|
path=rel,
|
||||||
|
display_name=_display_file_name(name),
|
||||||
|
size=int(obj.get("size", 0) or 0),
|
||||||
|
last_modified=obj.get("last_modified"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AuditFileBrowserResponse(
|
||||||
|
current_path=rel_path,
|
||||||
|
display_path=_display_path(rel_path, company_names),
|
||||||
|
breadcrumbs=_build_breadcrumbs(rel_path, company_names),
|
||||||
|
folders=sorted(folders, key=lambda x: x.display_name.lower()),
|
||||||
|
files=sorted(files, key=lambda x: x.display_name.lower()),
|
||||||
|
next_token=data.get("next_continuation_token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/download")
|
||||||
|
async def download_tenant_file(
|
||||||
|
path: str = Query(..., description="Ruta relativa del archivo a descargar."),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Descarga segura (backend streaming) de archivos autorizados.
|
||||||
|
"""
|
||||||
|
if not should_ensure_s3_bucket():
|
||||||
|
raise HTTPException(status_code=400, detail="S3 storage is disabled")
|
||||||
|
|
||||||
|
tenant_id = _tenant_id_from_user(current_user)
|
||||||
|
tenant_prefix = _tenant_prefix(tenant_id)
|
||||||
|
rel_path = _normalize_relative_path(path)
|
||||||
|
if not rel_path or rel_path.endswith("/"):
|
||||||
|
raise HTTPException(status_code=400, detail="A file path is required")
|
||||||
|
|
||||||
|
object_key = f"{tenant_prefix}{rel_path}"
|
||||||
|
if not object_key.startswith(tenant_prefix):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to object key")
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = get_object_bytes(object_key)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=404, detail=f"File not found: {e}") from e
|
||||||
|
|
||||||
|
filename = rel_path.rsplit("/", 1)[-1]
|
||||||
|
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([body]),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|||||||
@@ -50,3 +50,29 @@ class AuditLogListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
page: int
|
page: int
|
||||||
page_size: int
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class AuditFileBreadcrumb(BaseModel):
|
||||||
|
path: str = Field(default="")
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class AuditFileFolderItem(BaseModel):
|
||||||
|
path: str = Field(description="Ruta relativa interna del archivo, para navegación.")
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class AuditFileObjectItem(BaseModel):
|
||||||
|
path: str = Field(description="Ruta relativa interna del archivo, para descarga.")
|
||||||
|
display_name: str
|
||||||
|
size: int
|
||||||
|
last_modified: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditFileBrowserResponse(BaseModel):
|
||||||
|
current_path: str = Field(default="")
|
||||||
|
display_path: str = Field(default="")
|
||||||
|
breadcrumbs: List[AuditFileBreadcrumb]
|
||||||
|
folders: List[AuditFileFolderItem]
|
||||||
|
files: List[AuditFileObjectItem]
|
||||||
|
next_token: Optional[str] = None
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Las claves de objeto deben generarse con ``core.s3_keys`` (p. ej. ``csv_import_k
|
|||||||
``s3_key_for_csv_import``); no construir prefijos ``tenants/...`` aquí.
|
``s3_key_for_csv_import``); no construir prefijos ``tenants/...`` aquí.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
from botocore.config import Config
|
from botocore.config import Config
|
||||||
@@ -111,6 +111,59 @@ def presigned_get_url(key: str, expires_in: Optional[int] = None) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_objects_tree(
|
||||||
|
prefix: str,
|
||||||
|
delimiter: str = "/",
|
||||||
|
max_keys: int = 100,
|
||||||
|
continuation_token: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Lista objetos/prefijos como árbol virtual.
|
||||||
|
|
||||||
|
Retorna:
|
||||||
|
- ``prefixes``: subcarpetas (CommonPrefixes)
|
||||||
|
- ``objects``: objetos directos bajo ``prefix``
|
||||||
|
- ``next_continuation_token`` y ``is_truncated`` para paginación
|
||||||
|
"""
|
||||||
|
params: Dict[str, Any] = {
|
||||||
|
"Bucket": settings.S3_BUCKET,
|
||||||
|
"Prefix": prefix,
|
||||||
|
"Delimiter": delimiter,
|
||||||
|
"MaxKeys": max(1, min(int(max_keys), 500)),
|
||||||
|
}
|
||||||
|
if continuation_token:
|
||||||
|
params["ContinuationToken"] = continuation_token
|
||||||
|
|
||||||
|
resp = _client().list_objects_v2(**params)
|
||||||
|
common_prefixes: List[str] = [
|
||||||
|
p.get("Prefix", "") for p in (resp.get("CommonPrefixes") or []) if p.get("Prefix")
|
||||||
|
]
|
||||||
|
objects: List[Dict[str, Any]] = []
|
||||||
|
for obj in resp.get("Contents") or []:
|
||||||
|
key = obj.get("Key")
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
if key == prefix:
|
||||||
|
# Marcador de carpeta (objeto vacío con mismo nombre del prefijo).
|
||||||
|
continue
|
||||||
|
objects.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"size": int(obj.get("Size", 0) or 0),
|
||||||
|
"last_modified": obj.get("LastModified"),
|
||||||
|
"etag": obj.get("ETag"),
|
||||||
|
"storage_class": obj.get("StorageClass"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"prefixes": common_prefixes,
|
||||||
|
"objects": objects,
|
||||||
|
"next_continuation_token": resp.get("NextContinuationToken"),
|
||||||
|
"is_truncated": bool(resp.get("IsTruncated")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def s3_key_for_csv_import(
|
def s3_key_for_csv_import(
|
||||||
tenant_id,
|
tenant_id,
|
||||||
company_id: int,
|
company_id: int,
|
||||||
|
|||||||
@@ -119,6 +119,19 @@
|
|||||||
"audit_logs_description": "Audit trail of operations and background task (Celery) status.",
|
"audit_logs_description": "Audit trail of operations and background task (Celery) status.",
|
||||||
"audit_logs_tab_bitacora": "Audit trail",
|
"audit_logs_tab_bitacora": "Audit trail",
|
||||||
"audit_logs_tab_tasks": "Background tasks",
|
"audit_logs_tab_tasks": "Background tasks",
|
||||||
|
"audit_logs_tab_files": "File manager",
|
||||||
|
"audit_logs_files_title": "File manager",
|
||||||
|
"audit_logs_files_root": "Files root",
|
||||||
|
"audit_logs_files_refresh": "Refresh",
|
||||||
|
"audit_logs_files_list_title": "Contents",
|
||||||
|
"audit_logs_files_error_prefix": "Error:",
|
||||||
|
"audit_logs_files_col_name": "Name",
|
||||||
|
"audit_logs_files_col_size": "Size",
|
||||||
|
"audit_logs_files_col_modified": "Modified",
|
||||||
|
"audit_logs_files_col_actions": "Actions",
|
||||||
|
"audit_logs_files_loading": "Loading files...",
|
||||||
|
"audit_logs_files_empty": "No files or folders found in this location.",
|
||||||
|
"audit_logs_files_download": "Download",
|
||||||
"client_provider_type": {
|
"client_provider_type": {
|
||||||
"client_indicator": "C",
|
"client_indicator": "C",
|
||||||
"provider_indicator": "P",
|
"provider_indicator": "P",
|
||||||
|
|||||||
@@ -119,6 +119,19 @@
|
|||||||
"audit_logs_description": "Auditoría de operaciones y seguimiento de tareas en segundo plano (Celery).",
|
"audit_logs_description": "Auditoría de operaciones y seguimiento de tareas en segundo plano (Celery).",
|
||||||
"audit_logs_tab_bitacora": "Bitácora",
|
"audit_logs_tab_bitacora": "Bitácora",
|
||||||
"audit_logs_tab_tasks": "Tareas en segundo plano",
|
"audit_logs_tab_tasks": "Tareas en segundo plano",
|
||||||
|
"audit_logs_tab_files": "Gestor de archivos",
|
||||||
|
"audit_logs_files_title": "Gestor de archivos",
|
||||||
|
"audit_logs_files_root": "Raíz de archivos",
|
||||||
|
"audit_logs_files_refresh": "Actualizar",
|
||||||
|
"audit_logs_files_list_title": "Contenido",
|
||||||
|
"audit_logs_files_error_prefix": "Error:",
|
||||||
|
"audit_logs_files_col_name": "Nombre",
|
||||||
|
"audit_logs_files_col_size": "Tamaño",
|
||||||
|
"audit_logs_files_col_modified": "Modificado",
|
||||||
|
"audit_logs_files_col_actions": "Acciones",
|
||||||
|
"audit_logs_files_loading": "Cargando archivos...",
|
||||||
|
"audit_logs_files_empty": "No hay archivos o carpetas en esta ubicación.",
|
||||||
|
"audit_logs_files_download": "Descargar",
|
||||||
"client_provider_type": {
|
"client_provider_type": {
|
||||||
"client_indicator": "C",
|
"client_indicator": "C",
|
||||||
"provider_indicator": "P",
|
"provider_indicator": "P",
|
||||||
|
|||||||
@@ -456,6 +456,7 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise<B
|
|||||||
// Métodos HTTP
|
// Métodos HTTP
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
||||||
|
getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }),
|
||||||
|
|
||||||
post: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
|
post: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
|
||||||
fetchApi<T>(endpoint, {
|
fetchApi<T>(endpoint, {
|
||||||
|
|||||||
53
frontend/src/lib/api/dashboard/a76/audit_files.ts
Normal file
53
frontend/src/lib/api/dashboard/a76/audit_files.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
const BASE_PATH = '/v1/a76/audit-log/files';
|
||||||
|
|
||||||
|
export interface AuditFileBreadcrumb {
|
||||||
|
path: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditFolderItem {
|
||||||
|
path: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditFileItem {
|
||||||
|
path: string;
|
||||||
|
display_name: string;
|
||||||
|
size: number;
|
||||||
|
last_modified?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditFileListResponse {
|
||||||
|
current_path: string;
|
||||||
|
display_path: string;
|
||||||
|
breadcrumbs: AuditFileBreadcrumb[];
|
||||||
|
folders: AuditFolderItem[];
|
||||||
|
files: AuditFileItem[];
|
||||||
|
next_token?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuditFilesAPI = {
|
||||||
|
list: async (params?: {
|
||||||
|
path?: string;
|
||||||
|
continuation_token?: string;
|
||||||
|
max_keys?: number;
|
||||||
|
}): Promise<AuditFileListResponse> => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.path) query.set('path', params.path);
|
||||||
|
if (params?.continuation_token) query.set('continuation_token', params.continuation_token);
|
||||||
|
if (params?.max_keys) query.set('max_keys', String(params.max_keys));
|
||||||
|
|
||||||
|
const qs = query.toString();
|
||||||
|
const endpoint = qs ? `${BASE_PATH}?${qs}` : BASE_PATH;
|
||||||
|
const response = await api.get<AuditFileListResponse>(endpoint);
|
||||||
|
if (response.error || !response.data) {
|
||||||
|
throw new Error(response.error || 'Failed to list tenant files');
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadBlob: (path: string) =>
|
||||||
|
api.getBlob(`${BASE_PATH}/download?path=${encodeURIComponent(path)}`)
|
||||||
|
};
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
import { afterNavigate, goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import * as Tabs from '$lib/components/ui/tabs';
|
import * as Tabs from '$lib/components/ui/tabs';
|
||||||
import { ScrollText, ListTodo } from 'lucide-svelte';
|
import { ScrollText, ListTodo, FolderTree } from 'lucide-svelte';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import BitacoraTab from './bitacora-tab.svelte';
|
import BitacoraTab from './bitacora-tab.svelte';
|
||||||
import TasksTab from './tasks-tab.svelte';
|
import TasksTab from './tasks-tab.svelte';
|
||||||
|
import FilesTab from './files-tab.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
@@ -17,17 +18,19 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterNavigate(() => {
|
afterNavigate(() => {
|
||||||
const t = page.url.searchParams.get('tab') === 'tasks' ? 'tasks' : 'bitacora';
|
const raw = page.url.searchParams.get('tab');
|
||||||
|
const t = raw === 'tasks' || raw === 'files' ? raw : 'bitacora';
|
||||||
tabValue = t;
|
tabValue = t;
|
||||||
});
|
});
|
||||||
|
|
||||||
function onTabValueChange(v: string) {
|
function onTabValueChange(v: string) {
|
||||||
const fromUrl = page.url.searchParams.get('tab') === 'tasks' ? 'tasks' : 'bitacora';
|
const raw = page.url.searchParams.get('tab');
|
||||||
|
const fromUrl = raw === 'tasks' || raw === 'files' ? raw : 'bitacora';
|
||||||
if (v === fromUrl) return;
|
if (v === fromUrl) return;
|
||||||
|
|
||||||
const u = new URL(page.url.href);
|
const u = new URL(page.url.href);
|
||||||
if (v === 'tasks') {
|
if (v === 'tasks' || v === 'files') {
|
||||||
u.searchParams.set('tab', 'tasks');
|
u.searchParams.set('tab', v);
|
||||||
} else {
|
} else {
|
||||||
u.searchParams.delete('tab');
|
u.searchParams.delete('tab');
|
||||||
}
|
}
|
||||||
@@ -53,7 +56,7 @@
|
|||||||
onValueChange={onTabValueChange}
|
onValueChange={onTabValueChange}
|
||||||
class="flex min-h-0 w-full flex-1 flex-col"
|
class="flex min-h-0 w-full flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<Tabs.List class="grid w-full grid-cols-2">
|
<Tabs.List class="grid w-full grid-cols-3">
|
||||||
<Tabs.Trigger value="bitacora">
|
<Tabs.Trigger value="bitacora">
|
||||||
<ScrollText class="mr-2 h-4 w-4" />
|
<ScrollText class="mr-2 h-4 w-4" />
|
||||||
{m['sidebar.audit_logs_tab_bitacora']()}
|
{m['sidebar.audit_logs_tab_bitacora']()}
|
||||||
@@ -62,6 +65,10 @@
|
|||||||
<ListTodo class="mr-2 h-4 w-4" />
|
<ListTodo class="mr-2 h-4 w-4" />
|
||||||
{m['sidebar.audit_logs_tab_tasks']()}
|
{m['sidebar.audit_logs_tab_tasks']()}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="files">
|
||||||
|
<FolderTree class="mr-2 h-4 w-4" />
|
||||||
|
{m['sidebar.audit_logs_tab_files']()}
|
||||||
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Content value="bitacora" class="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden">
|
<Tabs.Content value="bitacora" class="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
@@ -74,5 +81,10 @@
|
|||||||
<TasksTab />
|
<TasksTab />
|
||||||
{/if}
|
{/if}
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="files" class="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
{#if tabValue === 'files'}
|
||||||
|
<FilesTab />
|
||||||
|
{/if}
|
||||||
|
</Tabs.Content>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import type { PageLoad } from './$types';
|
|||||||
|
|
||||||
export const load: PageLoad = ({ url }) => {
|
export const load: PageLoad = ({ url }) => {
|
||||||
const tab = url.searchParams.get('tab');
|
const tab = url.searchParams.get('tab');
|
||||||
const initialTab = tab === 'tasks' ? 'tasks' : 'bitacora';
|
const initialTab = tab === 'tasks' || tab === 'files' ? tab : 'bitacora';
|
||||||
return { initialTab };
|
return { initialTab };
|
||||||
};
|
};
|
||||||
|
|||||||
205
frontend/src/routes/dashboard/audit_logs/files-tab.svelte
Normal file
205
frontend/src/routes/dashboard/audit_logs/files-tab.svelte
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Folder, FileDown, RefreshCw } from 'lucide-svelte';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import {
|
||||||
|
AuditFilesAPI,
|
||||||
|
type AuditFileBreadcrumb,
|
||||||
|
type AuditFileItem,
|
||||||
|
type AuditFolderItem
|
||||||
|
} from '$lib/api/dashboard/a76/audit_files';
|
||||||
|
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let currentPath = $state('');
|
||||||
|
let displayPath = $state('');
|
||||||
|
let breadcrumbs = $state<AuditFileBreadcrumb[]>([]);
|
||||||
|
let folders = $state<AuditFolderItem[]>([]);
|
||||||
|
let files = $state<AuditFileItem[]>([]);
|
||||||
|
|
||||||
|
async function loadPath(path = '') {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const res = await AuditFilesAPI.list({ path, max_keys: 200 });
|
||||||
|
currentPath = res.current_path ?? '';
|
||||||
|
displayPath = res.display_path ?? '';
|
||||||
|
breadcrumbs = res.breadcrumbs ?? [];
|
||||||
|
folders = res.folders ?? [];
|
||||||
|
files = res.files ?? [];
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (!bytes || bytes < 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let value = bytes;
|
||||||
|
let idx = 0;
|
||||||
|
while (value >= 1024 && idx < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(path: string, filename: string) {
|
||||||
|
try {
|
||||||
|
const blob = await AuditFilesAPI.downloadBlob(path);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadPath('');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">{m['sidebar.audit_logs_files_title']()}</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">{displayPath || m['sidebar.audit_logs_files_root']()}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onclick={() => void loadPath(currentPath)} disabled={loading}>
|
||||||
|
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
|
||||||
|
{m['sidebar.audit_logs_files_refresh']()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="py-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
{#each breadcrumbs as crumb, idx}
|
||||||
|
{#if idx > 0}
|
||||||
|
<span class="mx-1 text-[10px]">/</span>
|
||||||
|
{/if}
|
||||||
|
{#if idx === breadcrumbs.length - 1}
|
||||||
|
<span
|
||||||
|
class="max-w-[240px] truncate font-medium text-foreground sm:max-w-[320px]"
|
||||||
|
>
|
||||||
|
{crumb.display_name}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="max-w-[180px] truncate hover:text-foreground hover:underline underline-offset-2"
|
||||||
|
onclick={() => void loadPath(crumb.path)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{crumb.display_name}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<Card.Header class="pb-2">
|
||||||
|
<Card.Title class="text-base">{m['sidebar.audit_logs_files_list_title']()}</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="min-h-0 flex-1 overflow-auto p-0">
|
||||||
|
{#if error}
|
||||||
|
<div class="m-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
|
||||||
|
{m['sidebar.audit_logs_files_error_prefix']()} {error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="w-full overflow-x-auto">
|
||||||
|
<Table.Root class="min-w-full text-sm">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head class="min-w-[200px]">
|
||||||
|
{m['sidebar.audit_logs_files_col_name']()}
|
||||||
|
</Table.Head>
|
||||||
|
<Table.Head class="w-[110px]">
|
||||||
|
{m['sidebar.audit_logs_files_col_size']()}
|
||||||
|
</Table.Head>
|
||||||
|
<Table.Head class="w-[160px]">
|
||||||
|
{m['sidebar.audit_logs_files_col_modified']()}
|
||||||
|
</Table.Head>
|
||||||
|
<Table.Head class="w-[120px] text-left">
|
||||||
|
{m['sidebar.audit_logs_files_col_actions']()}
|
||||||
|
</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#if loading}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell colspan={4} class="py-8 text-left text-muted-foreground">
|
||||||
|
{m['sidebar.audit_logs_files_loading']()}
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{:else if folders.length === 0 && files.length === 0}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell colspan={4} class="py-8 text-left text-muted-foreground">
|
||||||
|
{m['sidebar.audit_logs_files_empty']()}
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{:else}
|
||||||
|
{#each folders as folder}
|
||||||
|
<Table.Row
|
||||||
|
class="cursor-pointer hover:bg-muted/30"
|
||||||
|
onclick={() => void loadPath(folder.path)}
|
||||||
|
>
|
||||||
|
<Table.Cell class="max-w-[260px] font-medium">
|
||||||
|
<span class="inline-flex items-center gap-2 truncate text-left">
|
||||||
|
<Folder class="h-4 w-4 flex-shrink-0 text-amber-500" />
|
||||||
|
<span class="truncate">{folder.display_name}</span>
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-muted-foreground">—</Table.Cell>
|
||||||
|
<Table.Cell class="text-muted-foreground">—</Table.Cell>
|
||||||
|
<Table.Cell class="text-left text-muted-foreground">—</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
{#each files as file}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="max-w-[260px] font-medium">
|
||||||
|
<span class="truncate">{file.display_name}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>{formatSize(file.size)}</Table.Cell>
|
||||||
|
<Table.Cell>{formatDate(file.last_modified)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-left">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onclick={() => void downloadFile(file.path, file.display_name)}
|
||||||
|
>
|
||||||
|
<FileDown class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user