Update main to 0.5.1. See changelog. #18

Merged
serversdown merged 16 commits from dev into main 2026-01-27 22:29:57 -05:00
9 changed files with 783 additions and 271 deletions
Showing only changes of commit 6c7ce5aad0 - Show all commits

View File

@@ -1,6 +1,6 @@
import os import os
import logging import logging
from fastapi import FastAPI, Request, Depends from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -111,6 +111,26 @@ async def startup_event():
await start_scheduler() await start_scheduler()
logger.info("Scheduler service started") logger.info("Scheduler service started")
# Sync all SLMs to SLMM on startup
logger.info("Syncing SLM devices to SLMM...")
try:
from backend.services.slmm_sync import sync_all_slms_to_slmm, cleanup_orphaned_slmm_devices
from backend.database import SessionLocal
db = SessionLocal()
try:
# Sync all SLMs from roster to SLMM
sync_results = await sync_all_slms_to_slmm(db)
logger.info(f"SLM sync complete: {sync_results}")
# Clean up orphaned devices in SLMM
cleanup_results = await cleanup_orphaned_slmm_devices(db)
logger.info(f"SLMM cleanup complete: {cleanup_results}")
finally:
db.close()
except Exception as e:
logger.error(f"Error syncing SLMs to SLMM on startup: {e}")
@app.on_event("shutdown") @app.on_event("shutdown")
def shutdown_event(): def shutdown_event():
"""Clean up services on app shutdown""" """Clean up services on app shutdown"""

View File

@@ -522,51 +522,6 @@ async def get_project_sessions(
}) })
@router.get("/{project_id}/files", response_class=HTMLResponse)
async def get_project_files(
project_id: str,
request: Request,
db: Session = Depends(get_db),
file_type: Optional[str] = Query(None),
):
"""
Get all data files from all sessions in this project.
Returns HTML partial with file list.
Optional file_type filter: audio, data, log, etc.
"""
from backend.models import DataFile
# Join through RecordingSession to get project files
query = db.query(DataFile).join(
RecordingSession,
DataFile.session_id == RecordingSession.id
).filter(RecordingSession.project_id == project_id)
# Filter by file type if provided
if file_type:
query = query.filter(DataFile.file_type == file_type)
files = query.order_by(DataFile.created_at.desc()).all()
# Enrich with session details
files_data = []
for file in files:
session = None
if file.session_id:
session = db.query(RecordingSession).filter_by(id=file.session_id).first()
files_data.append({
"file": file,
"session": session,
})
return templates.TemplateResponse("partials/projects/file_list.html", {
"request": request,
"project_id": project_id,
"files": files_data,
})
@router.get("/{project_id}/ftp-browser", response_class=HTMLResponse) @router.get("/{project_id}/ftp-browser", response_class=HTMLResponse)
async def get_ftp_browser( async def get_ftp_browser(
project_id: str, project_id: str,
@@ -649,10 +604,11 @@ async def ftp_download_to_server(
project_id=project_id, project_id=project_id,
location_id=location_id, location_id=location_id,
unit_id=unit_id, unit_id=unit_id,
session_type="sound", # SLMs are sound monitoring devices
status="completed", status="completed",
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
stopped_at=datetime.utcnow(), stopped_at=datetime.utcnow(),
notes="Auto-created for FTP download" session_metadata='{"source": "ftp_download", "note": "Auto-created for FTP download"}'
) )
db.add(session) db.add(session)
db.commit() db.commit()
@@ -680,12 +636,35 @@ async def ftp_download_to_server(
# Determine file type from extension # Determine file type from extension
ext = os.path.splitext(filename)[1].lower() ext = os.path.splitext(filename)[1].lower()
file_type_map = { file_type_map = {
# Audio files
'.wav': 'audio', '.wav': 'audio',
'.mp3': 'audio', '.mp3': 'audio',
'.flac': 'audio',
'.m4a': 'audio',
'.aac': 'audio',
# Data files
'.csv': 'data', '.csv': 'data',
'.txt': 'data', '.txt': 'data',
'.log': 'log',
'.json': 'data', '.json': 'data',
'.xml': 'data',
'.dat': 'data',
# Log files
'.log': 'log',
# Archives
'.zip': 'archive',
'.tar': 'archive',
'.gz': 'archive',
'.7z': 'archive',
'.rar': 'archive',
# Images
'.jpg': 'image',
'.jpeg': 'image',
'.png': 'image',
'.gif': 'image',
# Documents
'.pdf': 'document',
'.doc': 'document',
'.docx': 'document',
} }
file_type = file_type_map.get(ext, 'data') file_type = file_type_map.get(ext, 'data')
@@ -751,12 +730,15 @@ async def ftp_download_folder_to_server(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Download an entire folder from an SLM to the server via FTP as a ZIP file. Download an entire folder from an SLM to the server via FTP.
Creates a DataFile record and stores the ZIP in data/Projects/{project_id}/ Extracts all files from the ZIP and preserves folder structure.
Creates individual DataFile records for each file.
""" """
import httpx import httpx
import os import os
import hashlib import hashlib
import zipfile
import io
from pathlib import Path from pathlib import Path
from backend.models import DataFile from backend.models import DataFile
@@ -785,16 +767,17 @@ async def ftp_download_folder_to_server(
project_id=project_id, project_id=project_id,
location_id=location_id, location_id=location_id,
unit_id=unit_id, unit_id=unit_id,
session_type="sound", # SLMs are sound monitoring devices
status="completed", status="completed",
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
stopped_at=datetime.utcnow(), stopped_at=datetime.utcnow(),
notes="Auto-created for FTP folder download" session_metadata='{"source": "ftp_folder_download", "note": "Auto-created for FTP folder download"}'
) )
db.add(session) db.add(session)
db.commit() db.commit()
db.refresh(session) db.refresh(session)
# Download folder from SLMM # Download folder from SLMM (returns ZIP)
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
try: try:
@@ -812,29 +795,64 @@ async def ftp_download_folder_to_server(
# Extract folder name from remote_path # Extract folder name from remote_path
folder_name = os.path.basename(remote_path.rstrip('/')) folder_name = os.path.basename(remote_path.rstrip('/'))
filename = f"{folder_name}.zip"
# Create directory structure: data/Projects/{project_id}/{session_id}/ # Create base directory: data/Projects/{project_id}/{session_id}/{folder_name}/
project_dir = Path(f"data/Projects/{project_id}/{session.id}") base_dir = Path(f"data/Projects/{project_id}/{session.id}/{folder_name}")
project_dir.mkdir(parents=True, exist_ok=True) base_dir.mkdir(parents=True, exist_ok=True)
# Save ZIP file to disk # Extract ZIP and save individual files
file_path = project_dir / filename zip_content = response.content
file_content = response.content created_files = []
total_size = 0
# File type mapping for classification
file_type_map = {
# Audio files
'.wav': 'audio', '.mp3': 'audio', '.flac': 'audio', '.m4a': 'audio', '.aac': 'audio',
# Data files
'.csv': 'data', '.txt': 'data', '.json': 'data', '.xml': 'data', '.dat': 'data',
# Log files
'.log': 'log',
# Archives
'.zip': 'archive', '.tar': 'archive', '.gz': 'archive', '.7z': 'archive', '.rar': 'archive',
# Images
'.jpg': 'image', '.jpeg': 'image', '.png': 'image', '.gif': 'image',
# Documents
'.pdf': 'document', '.doc': 'document', '.docx': 'document',
}
with zipfile.ZipFile(io.BytesIO(zip_content)) as zf:
for zip_info in zf.filelist:
# Skip directories
if zip_info.is_dir():
continue
# Read file from ZIP
file_data = zf.read(zip_info.filename)
# Determine file path (preserve structure within folder)
# zip_info.filename might be like "Auto_0001/measurement.wav"
file_path = base_dir / zip_info.filename
file_path.parent.mkdir(parents=True, exist_ok=True)
# Write file to disk
with open(file_path, 'wb') as f: with open(file_path, 'wb') as f:
f.write(file_content) f.write(file_data)
# Calculate checksum # Calculate checksum
checksum = hashlib.sha256(file_content).hexdigest() checksum = hashlib.sha256(file_data).hexdigest()
# Determine file type
ext = os.path.splitext(zip_info.filename)[1].lower()
file_type = file_type_map.get(ext, 'data')
# Create DataFile record # Create DataFile record
data_file = DataFile( data_file = DataFile(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
session_id=session.id, session_id=session.id,
file_path=str(file_path.relative_to("data")), # Store relative to data/ file_path=str(file_path.relative_to("data")),
file_type='archive', # ZIP archives file_type=file_type,
file_size_bytes=len(file_content), file_size_bytes=len(file_data),
downloaded_at=datetime.utcnow(), downloaded_at=datetime.utcnow(),
checksum=checksum, checksum=checksum,
file_metadata=json.dumps({ file_metadata=json.dumps({
@@ -843,18 +861,27 @@ async def ftp_download_folder_to_server(
"unit_id": unit_id, "unit_id": unit_id,
"location_id": location_id, "location_id": location_id,
"folder_name": folder_name, "folder_name": folder_name,
"relative_path": zip_info.filename,
}) })
) )
db.add(data_file) db.add(data_file)
created_files.append({
"filename": zip_info.filename,
"size": len(file_data),
"type": file_type
})
total_size += len(file_data)
db.commit() db.commit()
return { return {
"success": True, "success": True,
"message": f"Downloaded folder {folder_name} to server as ZIP", "message": f"Downloaded folder {folder_name} with {len(created_files)} files",
"file_id": data_file.id, "folder_name": folder_name,
"file_path": str(file_path), "file_count": len(created_files),
"file_size": len(file_content), "total_size": total_size,
"files": created_files,
} }
except httpx.TimeoutException: except httpx.TimeoutException:
@@ -862,6 +889,11 @@ async def ftp_download_folder_to_server(
status_code=504, status_code=504,
detail="Timeout downloading folder from SLM (large folders may take a while)" detail="Timeout downloading folder from SLM (large folders may take a while)"
) )
except zipfile.BadZipFile:
raise HTTPException(
status_code=500,
detail="Downloaded file is not a valid ZIP archive"
)
except Exception as e: except Exception as e:
logger.error(f"Error downloading folder to server: {e}") logger.error(f"Error downloading folder to server: {e}")
raise HTTPException( raise HTTPException(
@@ -874,6 +906,121 @@ async def ftp_download_folder_to_server(
# Project Types # Project Types
# ============================================================================ # ============================================================================
@router.get("/{project_id}/files-unified", response_class=HTMLResponse)
async def get_unified_files(
project_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Get unified view of all files in this project.
Groups files by recording session with full metadata.
Returns HTML partial with hierarchical file listing.
"""
from backend.models import DataFile
from pathlib import Path
import json
# Get all sessions for this project
sessions = db.query(RecordingSession).filter_by(
project_id=project_id
).order_by(RecordingSession.started_at.desc()).all()
sessions_data = []
for session in sessions:
# Get files for this session
files = db.query(DataFile).filter_by(session_id=session.id).all()
# Skip sessions with no files
if not files:
continue
# Get session context
unit = None
location = None
if session.unit_id:
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first()
if session.location_id:
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
files_data = []
for file in files:
# Check if file exists on disk
file_path = Path("data") / file.file_path
exists_on_disk = file_path.exists()
# Get actual file size if exists
actual_size = file_path.stat().st_size if exists_on_disk else None
# Parse metadata JSON
metadata = {}
try:
if file.file_metadata:
metadata = json.loads(file.file_metadata)
except Exception as e:
logger.warning(f"Failed to parse metadata for file {file.id}: {e}")
files_data.append({
"file": file,
"exists_on_disk": exists_on_disk,
"actual_size": actual_size,
"metadata": metadata,
})
sessions_data.append({
"session": session,
"unit": unit,
"location": location,
"files": files_data,
})
return templates.TemplateResponse("partials/projects/unified_files.html", {
"request": request,
"project_id": project_id,
"sessions": sessions_data,
})
@router.get("/{project_id}/files/{file_id}/download")
async def download_project_file(
project_id: str,
file_id: str,
db: Session = Depends(get_db),
):
"""
Download a data file from a project.
Returns the file for download.
"""
from backend.models import DataFile
from fastapi.responses import FileResponse
from pathlib import Path
# Get the file record
file_record = db.query(DataFile).filter_by(id=file_id).first()
if not file_record:
raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project")
# Build full file path
file_path = Path("data") / file_record.file_path
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
# Extract filename for download
filename = file_path.name
return FileResponse(
path=str(file_path),
filename=filename,
media_type="application/octet-stream"
)
@router.get("/types/list", response_class=HTMLResponse) @router.get("/types/list", response_class=HTMLResponse)
async def get_project_types(request: Request, db: Session = Depends(get_db)): async def get_project_types(request: Request, db: Session = Depends(get_db)):
""" """

View File

@@ -458,16 +458,20 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g
@router.delete("/{unit_id}") @router.delete("/{unit_id}")
def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)): async def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
""" """
Permanently delete a unit from the database. Permanently delete a unit from the database.
Checks roster, emitters, and ignored_units tables and deletes from any table where the unit exists. Checks roster, emitters, and ignored_units tables and deletes from any table where the unit exists.
For SLM devices, also removes from SLMM to stop background polling.
""" """
deleted = False deleted = False
was_slm = False
# Try to delete from roster table # Try to delete from roster table
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if roster_unit: if roster_unit:
was_slm = roster_unit.device_type == "slm"
db.delete(roster_unit) db.delete(roster_unit)
deleted = True deleted = True
@@ -488,6 +492,19 @@ def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Unit not found") raise HTTPException(status_code=404, detail="Unit not found")
db.commit() db.commit()
# If it was an SLM, also delete from SLMM
if was_slm:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.delete(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config")
if response.status_code in [200, 404]:
logger.info(f"Deleted SLM {unit_id} from SLMM")
else:
logger.warning(f"Failed to delete SLM {unit_id} from SLMM: {response.status_code}")
except Exception as e:
logger.error(f"Error deleting SLM {unit_id} from SLMM: {e}")
return {"message": "Unit deleted", "id": unit_id} return {"message": "Unit deleted", "id": unit_id}

View File

@@ -477,3 +477,75 @@ async def upload_snapshot(file: UploadFile = File(...)):
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
# ============================================================================
# SLMM SYNC ENDPOINTS
# ============================================================================
@router.post("/slmm/sync-all")
async def sync_all_slms(db: Session = Depends(get_db)):
"""
Manually trigger full sync of all SLM devices from Terra-View roster to SLMM.
This ensures SLMM database matches Terra-View roster (source of truth).
Also cleans up orphaned devices in SLMM that are not in Terra-View.
"""
from backend.services.slmm_sync import sync_all_slms_to_slmm, cleanup_orphaned_slmm_devices
try:
# Sync all SLMs
sync_results = await sync_all_slms_to_slmm(db)
# Clean up orphaned devices
cleanup_results = await cleanup_orphaned_slmm_devices(db)
return {
"status": "ok",
"sync": sync_results,
"cleanup": cleanup_results
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
@router.get("/slmm/status")
async def get_slmm_sync_status(db: Session = Depends(get_db)):
"""
Get status of SLMM synchronization.
Shows which devices are in Terra-View roster vs SLMM database.
"""
from backend.services.slmm_sync import get_slmm_devices
try:
# Get devices from both systems
roster_slms = db.query(RosterUnit).filter_by(device_type="slm").all()
slmm_devices = await get_slmm_devices()
if slmm_devices is None:
raise HTTPException(status_code=503, detail="SLMM service unavailable")
roster_unit_ids = {unit.unit_type for unit in roster_slms}
slmm_unit_ids = set(slmm_devices)
# Find differences
in_roster_only = roster_unit_ids - slmm_unit_ids
in_slmm_only = slmm_unit_ids - roster_unit_ids
in_both = roster_unit_ids & slmm_unit_ids
return {
"status": "ok",
"terra_view_total": len(roster_unit_ids),
"slmm_total": len(slmm_unit_ids),
"synced": len(in_both),
"missing_from_slmm": list(in_roster_only),
"orphaned_in_slmm": list(in_slmm_only),
"in_sync": len(in_roster_only) == 0 and len(in_slmm_only) == 0
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Status check failed: {str(e)}")

View File

@@ -0,0 +1,227 @@
"""
SLMM Synchronization Service
This service ensures Terra-View roster is the single source of truth for SLM device configuration.
When SLM devices are added, edited, or deleted in Terra-View, changes are automatically synced to SLMM.
"""
import logging
import httpx
import os
from typing import Optional
from sqlalchemy.orm import Session
from backend.models import RosterUnit
logger = logging.getLogger(__name__)
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
"""
Sync a single SLM device from Terra-View roster to SLMM.
Args:
unit: RosterUnit with device_type="slm"
Returns:
True if sync successful, False otherwise
"""
if unit.device_type != "slm":
logger.warning(f"Attempted to sync non-SLM unit {unit.id} to SLMM")
return False
if not unit.slm_host:
logger.warning(f"SLM {unit.id} has no host configured, skipping SLMM sync")
return False
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.put(
f"{SLMM_BASE_URL}/api/nl43/{unit.id}/config",
json={
"host": unit.slm_host,
"tcp_port": unit.slm_tcp_port or 2255,
"tcp_enabled": True,
"ftp_enabled": True,
"ftp_username": "USER", # Default NL43 credentials
"ftp_password": "0000",
"poll_enabled": not unit.retired, # Disable polling for retired units
"poll_interval_seconds": 60, # Default interval
}
)
if response.status_code in [200, 201]:
logger.info(f"✓ Synced SLM {unit.id} to SLMM at {unit.slm_host}:{unit.slm_tcp_port or 2255}")
return True
else:
logger.error(f"Failed to sync SLM {unit.id} to SLMM: {response.status_code} {response.text}")
return False
except httpx.TimeoutException:
logger.error(f"Timeout syncing SLM {unit.id} to SLMM")
return False
except Exception as e:
logger.error(f"Error syncing SLM {unit.id} to SLMM: {e}")
return False
async def delete_slm_from_slmm(unit_id: str) -> bool:
"""
Delete a device from SLMM database.
Args:
unit_id: The unit ID to delete
Returns:
True if deletion successful or device doesn't exist, False on error
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.delete(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config"
)
if response.status_code == 200:
logger.info(f"✓ Deleted SLM {unit_id} from SLMM")
return True
elif response.status_code == 404:
logger.info(f"SLM {unit_id} not found in SLMM (already deleted)")
return True
else:
logger.error(f"Failed to delete SLM {unit_id} from SLMM: {response.status_code} {response.text}")
return False
except httpx.TimeoutException:
logger.error(f"Timeout deleting SLM {unit_id} from SLMM")
return False
except Exception as e:
logger.error(f"Error deleting SLM {unit_id} from SLMM: {e}")
return False
async def sync_all_slms_to_slmm(db: Session) -> dict:
"""
Sync all SLM devices from Terra-View roster to SLMM.
This ensures SLMM database matches Terra-View roster as the source of truth.
Should be called on Terra-View startup and optionally via admin endpoint.
Args:
db: Database session
Returns:
Dictionary with sync results
"""
logger.info("Starting full SLM sync to SLMM...")
# Get all SLM units from roster
slm_units = db.query(RosterUnit).filter_by(device_type="slm").all()
results = {
"total": len(slm_units),
"synced": 0,
"skipped": 0,
"failed": 0
}
for unit in slm_units:
# Skip units without host configured
if not unit.slm_host:
results["skipped"] += 1
logger.debug(f"Skipped {unit.unit_type} - no host configured")
continue
# Sync to SLMM
success = await sync_slm_to_slmm(unit)
if success:
results["synced"] += 1
else:
results["failed"] += 1
logger.info(
f"SLM sync complete: {results['synced']} synced, "
f"{results['skipped']} skipped, {results['failed']} failed"
)
return results
async def get_slmm_devices() -> Optional[list]:
"""
Get list of all devices currently in SLMM database.
Returns:
List of device unit_ids, or None on error
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/_polling/status")
if response.status_code == 200:
data = response.json()
return [device["unit_id"] for device in data["data"]["devices"]]
else:
logger.error(f"Failed to get SLMM devices: {response.status_code}")
return None
except Exception as e:
logger.error(f"Error getting SLMM devices: {e}")
return None
async def cleanup_orphaned_slmm_devices(db: Session) -> dict:
"""
Remove devices from SLMM that are not in Terra-View roster.
This cleans up orphaned test devices or devices that were manually added to SLMM.
Args:
db: Database session
Returns:
Dictionary with cleanup results
"""
logger.info("Checking for orphaned devices in SLMM...")
# Get all device IDs from SLMM
slmm_devices = await get_slmm_devices()
if slmm_devices is None:
return {"error": "Failed to get SLMM device list"}
# Get all SLM unit IDs from Terra-View roster
roster_units = db.query(RosterUnit.id).filter_by(device_type="slm").all()
roster_unit_ids = {unit.id for unit in roster_units}
# Find orphaned devices (in SLMM but not in roster)
orphaned = [uid for uid in slmm_devices if uid not in roster_unit_ids]
results = {
"total_in_slmm": len(slmm_devices),
"total_in_roster": len(roster_unit_ids),
"orphaned": len(orphaned),
"deleted": 0,
"failed": 0,
"orphaned_devices": orphaned
}
if not orphaned:
logger.info("No orphaned devices found in SLMM")
return results
logger.info(f"Found {len(orphaned)} orphaned devices in SLMM: {orphaned}")
# Delete orphaned devices
for unit_id in orphaned:
success = await delete_slm_from_slmm(unit_id)
if success:
results["deleted"] += 1
else:
results["failed"] += 1
logger.info(
f"Cleanup complete: {results['deleted']} deleted, {results['failed']} failed"
)
return results

View File

@@ -1,126 +0,0 @@
<!-- Data Files List -->
{% if files %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
File Name
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Size
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Created
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Session
</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for item in files %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ item.file.file_path.split('/')[-1] if item.file.file_path else 'Unknown' }}
</div>
{% if item.file.file_path %}
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">
{{ item.file.file_path }}
</div>
{% endif %}
</div>
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-medium rounded-full
{% if item.file.file_type == 'audio' %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
{% elif item.file.file_type == 'data' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif item.file.file_type == 'log' %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300
{% else %}bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300{% endif %}">
{{ item.file.file_type or 'unknown' }}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{% if item.file.file_size_bytes %}
{% if item.file.file_size_bytes < 1024 %}
{{ item.file.file_size_bytes }} B
{% elif item.file.file_size_bytes < 1048576 %}
{{ "%.1f"|format(item.file.file_size_bytes / 1024) }} KB
{% elif item.file.file_size_bytes < 1073741824 %}
{{ "%.1f"|format(item.file.file_size_bytes / 1048576) }} MB
{% else %}
{{ "%.2f"|format(item.file.file_size_bytes / 1073741824) }} GB
{% endif %}
{% else %}
-
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{{ item.file.created_at.strftime('%Y-%m-%d %H:%M') if item.file.created_at else 'N/A' }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">
{% if item.session %}
<span class="text-gray-900 dark:text-white font-mono text-xs">
{{ item.session.id[:8] }}...
</span>
{% else %}
<span class="text-gray-400">-</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
<div class="flex items-center justify-end gap-2">
<button onclick="downloadFile('{{ item.file.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"
title="Download file">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
</button>
<button onclick="viewFileDetails('{{ item.file.id }}')"
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title="View details">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-12">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400 mb-2">No data files yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500">Files will appear here after recording sessions</p>
</div>
{% endif %}
<script>
function downloadFile(fileId) {
// TODO: Implement file download
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
}
function viewFileDetails(fileId) {
// TODO: Implement file details modal
alert('File details coming soon: ' + fileId);
}
</script>

View File

@@ -77,13 +77,6 @@
</div> </div>
<script> <script>
// Check FTP status for all units on load
document.addEventListener('DOMContentLoaded', function() {
{% for unit_item in units %}
checkFTPStatus('{{ unit_item.unit.id }}');
{% endfor %}
});
async function checkFTPStatus(unitId) { async function checkFTPStatus(unitId) {
const statusSpan = document.getElementById(`ftp-status-${unitId}`); const statusSpan = document.getElementById(`ftp-status-${unitId}`);
const enableBtn = document.getElementById(`enable-ftp-${unitId}`); const enableBtn = document.getElementById(`enable-ftp-${unitId}`);
@@ -211,39 +204,23 @@ async function loadFTPFiles(unitId, path) {
${file.is_dir ? ` ${file.is_dir ? `
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${file.path}', '${file.name}')" <button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${file.path}', '${file.name}')"
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors" class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors"
title="Download entire folder to server and add to database"> title="Download folder from device to server and add to project database">
<svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
</svg>
To Server (ZIP)
</button>
<button onclick="event.stopPropagation(); downloadFTPFolder('${unitId}', '${file.path}', '${file.name}')"
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Download entire folder as ZIP to your computer">
<svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg> </svg>
To Browser (ZIP) Download & Save
</button> </button>
</div> </div>
` : ` ` : `
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button onclick="downloadToServer('${unitId}', '${file.path}', '${file.name}')" <button onclick="downloadToServer('${unitId}', '${file.path}', '${file.name}')"
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
title="Download to server and add to database">
<svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
</svg>
To Server
</button>
<button onclick="downloadFTPFile('${unitId}', '${file.path}', '${file.name}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors" class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors"
title="Download directly to your computer"> title="Download file from device to server and add to project database">
<svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg> </svg>
To Browser Download & Save
</button> </button>
</div> </div>
`} `}
@@ -365,10 +342,10 @@ async function downloadFolderToServer(unitId, remotePath, folderName) {
if (response.ok) { if (response.ok) {
// Show success message // Show success message
alert(`✓ Folder "${folderName}" downloaded to server successfully as ZIP!\n\nFile ID: ${data.file_id}\nSize: ${formatFileSize(data.file_size)}`); alert(`✓ Folder "${folderName}" downloaded successfully!\n\n${data.file_count} files extracted\nTotal size: ${formatFileSize(data.total_size)}\n\nFiles are now available in the Project Files section below.`);
// Refresh the downloaded files list // Refresh the unified files list
htmx.trigger('#project-files', 'refresh'); htmx.trigger('#unified-files', 'refresh');
} else { } else {
alert('Folder download to server failed: ' + (data.detail || 'Unknown error')); alert('Folder download to server failed: ' + (data.detail || 'Unknown error'));
} }
@@ -409,8 +386,8 @@ async function downloadToServer(unitId, remotePath, fileName) {
// Show success message // Show success message
alert(`${fileName} downloaded to server successfully!\n\nFile ID: ${data.file_id}\nSize: ${formatFileSize(data.file_size)}`); alert(`${fileName} downloaded to server successfully!\n\nFile ID: ${data.file_id}\nSize: ${formatFileSize(data.file_size)}`);
// Refresh the downloaded files list // Refresh the unified files list
htmx.trigger('#project-files', 'refresh'); htmx.trigger('#unified-files', 'refresh');
} else { } else {
alert('Download to server failed: ' + (data.detail || 'Unknown error')); alert('Download to server failed: ' + (data.detail || 'Unknown error'));
} }
@@ -428,4 +405,12 @@ function formatFileSize(bytes) {
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(2) + ' GB'; return (bytes / 1073741824).toFixed(2) + ' GB';
} }
// Check FTP status for all units on load
// Use setTimeout to ensure DOM elements exist when HTMX loads this partial
setTimeout(function() {
{% for unit_item in units %}
checkFTPStatus('{{ unit_item.unit.id }}');
{% endfor %}
}, 100);
</script> </script>

View File

@@ -0,0 +1,173 @@
<!-- Unified Files View - Database + Filesystem -->
{% if sessions %}
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for session_data in sessions %}
{% set session = session_data.session %}
{% set unit = session_data.unit %}
{% set location = session_data.location %}
{% set files = session_data.files %}
{% if files %}
<!-- Session Header -->
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900/50">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path>
</svg>
<div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ session.started_at.strftime('%Y-%m-%d %H:%M') if session.started_at else 'Unknown Date' }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
{% if location %} @ {{ location.name }}{% endif %}
<span class="mx-2"></span>
{{ files|length }} file{{ 's' if files|length != 1 else '' }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-xs rounded-full
{% if session.status == 'recording' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif session.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
{% elif session.status == 'paused' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ session.status or 'unknown' }}
</span>
</div>
</div>
</div>
<!-- Files List -->
<div class="px-6 py-2">
<div class="space-y-1">
{% for file_data in files %}
{% set file = file_data.file %}
{% set exists = file_data.exists_on_disk %}
{% set metadata = file_data.metadata %}
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg transition-colors group">
<!-- File Icon -->
{% if file.file_type == 'audio' %}
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
</svg>
{% elif file.file_type == 'archive' %}
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
{% elif file.file_type == 'log' %}
<svg class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
{% elif file.file_type == 'image' %}
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
{% else %}
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
{% endif %}
<!-- File Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<div class="font-medium text-gray-900 dark:text-white truncate">
{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}
</div>
{% if not exists %}
<span class="px-2 py-0.5 text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded">
Missing on disk
</span>
{% endif %}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
<!-- File Type Badge -->
<span class="px-1.5 py-0.5 rounded font-medium
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ file.file_type or 'unknown' }}
</span>
<!-- File Size -->
<span class="mx-1"></span>
{% if file.file_size_bytes %}
{% if file.file_size_bytes < 1024 %}
{{ file.file_size_bytes }} B
{% elif file.file_size_bytes < 1048576 %}
{{ "%.1f"|format(file.file_size_bytes / 1024) }} KB
{% elif file.file_size_bytes < 1073741824 %}
{{ "%.1f"|format(file.file_size_bytes / 1048576) }} MB
{% else %}
{{ "%.2f"|format(file.file_size_bytes / 1073741824) }} GB
{% endif %}
{% else %}
Unknown size
{% endif %}
<!-- Download Time -->
{% if file.downloaded_at %}
<span class="mx-1"></span>
{{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
<!-- Source Info from Metadata -->
{% if metadata.unit_id %}
<span class="mx-1"></span>
from {{ metadata.unit_id }}
{% endif %}
<!-- Checksum Indicator -->
{% if file.checksum %}
<span class="mx-1" title="SHA256: {{ file.checksum[:16] }}...">
<svg class="w-3 h-3 inline text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
</span>
{% endif %}
</div>
</div>
<!-- Download Button -->
{% if exists %}
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
<button onclick="downloadFile('{{ file.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download
</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<!-- Empty State -->
<div class="px-6 py-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500">
Use the FTP browser above to download files from your sound level meters
</p>
</div>
{% endif %}
<script>
function downloadFile(fileId) {
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
}
</script>

View File

@@ -180,43 +180,40 @@
<!-- Data Files Tab --> <!-- Data Files Tab -->
<div id="data-tab" class="tab-panel hidden"> <div id="data-tab" class="tab-panel hidden">
<!-- FTP File Browser --> <!-- FTP File Browser (Download from Devices) -->
<div id="ftp-browser" <div id="ftp-browser" class="mb-6"
hx-get="/api/projects/{{ project_id }}/ftp-browser" hx-get="/api/projects/{{ project_id }}/ftp-browser"
hx-trigger="load" hx-trigger="load"
hx-swap="innerHTML"> hx-swap="innerHTML">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="text-center py-8 text-gray-500">Loading FTP browser...</div> <div class="text-center py-8 text-gray-500">Loading FTP browser...</div>
</div> </div>
</div> </div>
<!-- Downloaded Files List --> <!-- Unified Files View (Database + Filesystem) -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg">
<div class="flex items-center justify-between mb-6"> <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Downloaded Files</h2> <div class="flex items-center justify-between">
<div class="flex items-center gap-4"> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">
<select id="files-filter" onchange="filterFiles()" Project Files
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"> </h2>
<option value="all">All Files</option> <div class="flex items-center gap-3">
<option value="audio">Audio</option> <button onclick="htmx.trigger('#unified-files', 'refresh')"
<option value="data">Data</option> class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
<option value="log">Logs</option> <svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</select> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
<button onclick="exportProjectData()"
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg> </svg>
Export All Refresh
</button> </button>
</div> </div>
</div> </div>
</div>
<div id="project-files" <div id="unified-files"
hx-get="/api/projects/{{ project_id }}/files" hx-get="/api/projects/{{ project_id }}/files-unified"
hx-trigger="load" hx-trigger="load, refresh from:#unified-files"
hx-swap="innerHTML"> hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading data files...</div> <div class="px-6 py-12 text-center text-gray-500">Loading files...</div>
</div> </div>
</div> </div>
</div> </div>