Update main to 0.5.1. See changelog. #18
@@ -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"""
|
||||||
|
|||||||
@@ -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,49 +795,93 @@ 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
|
||||||
|
|
||||||
with open(file_path, 'wb') as f:
|
# File type mapping for classification
|
||||||
f.write(file_content)
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
# Calculate checksum
|
with zipfile.ZipFile(io.BytesIO(zip_content)) as zf:
|
||||||
checksum = hashlib.sha256(file_content).hexdigest()
|
for zip_info in zf.filelist:
|
||||||
|
# Skip directories
|
||||||
|
if zip_info.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
# Create DataFile record
|
# Read file from ZIP
|
||||||
data_file = DataFile(
|
file_data = zf.read(zip_info.filename)
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
session_id=session.id,
|
# Determine file path (preserve structure within folder)
|
||||||
file_path=str(file_path.relative_to("data")), # Store relative to data/
|
# zip_info.filename might be like "Auto_0001/measurement.wav"
|
||||||
file_type='archive', # ZIP archives
|
file_path = base_dir / zip_info.filename
|
||||||
file_size_bytes=len(file_content),
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
downloaded_at=datetime.utcnow(),
|
|
||||||
checksum=checksum,
|
# Write file to disk
|
||||||
file_metadata=json.dumps({
|
with open(file_path, 'wb') as f:
|
||||||
"source": "ftp_folder",
|
f.write(file_data)
|
||||||
"remote_path": remote_path,
|
|
||||||
"unit_id": unit_id,
|
# Calculate checksum
|
||||||
"location_id": location_id,
|
checksum = hashlib.sha256(file_data).hexdigest()
|
||||||
"folder_name": folder_name,
|
|
||||||
})
|
# Determine file type
|
||||||
)
|
ext = os.path.splitext(zip_info.filename)[1].lower()
|
||||||
|
file_type = file_type_map.get(ext, 'data')
|
||||||
|
|
||||||
|
# Create DataFile record
|
||||||
|
data_file = DataFile(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
session_id=session.id,
|
||||||
|
file_path=str(file_path.relative_to("data")),
|
||||||
|
file_type=file_type,
|
||||||
|
file_size_bytes=len(file_data),
|
||||||
|
downloaded_at=datetime.utcnow(),
|
||||||
|
checksum=checksum,
|
||||||
|
file_metadata=json.dumps({
|
||||||
|
"source": "ftp_folder",
|
||||||
|
"remote_path": remote_path,
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"location_id": location_id,
|
||||||
|
"folder_name": folder_name,
|
||||||
|
"relative_path": zip_info.filename,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(data_file)
|
||||||
|
created_files.append({
|
||||||
|
"filename": zip_info.filename,
|
||||||
|
"size": len(file_data),
|
||||||
|
"type": file_type
|
||||||
|
})
|
||||||
|
total_size += len(file_data)
|
||||||
|
|
||||||
db.add(data_file)
|
|
||||||
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)):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
|||||||
227
backend/services/slmm_sync.py
Normal file
227
backend/services/slmm_sync.py
Normal 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
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
173
templates/partials/projects/unified_files.html
Normal file
173
templates/partials/projects/unified_files.html
Normal 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>
|
||||||
@@ -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()"
|
</svg>
|
||||||
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">
|
Refresh
|
||||||
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</button>
|
||||||
<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>
|
</div>
|
||||||
</svg>
|
|
||||||
Export All
|
|
||||||
</button>
|
|
||||||
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user