bunch of changes

This commit is contained in:
2026-04-23 17:40:56 -04:00
parent 283b46fad0
commit 90d2e07e7e
33 changed files with 4980 additions and 947 deletions
+9 -6
View File
@@ -10,8 +10,11 @@ develop-eggs/
downloads/
eggs/
.eggs/
/lib/
/lib64/
# /lib/ <-- This was causing the issue, it's too broad
# /lib64/ <-- This was causing the issue, it's too broad
# Use more specific patterns if needed for python builds
/python-lib/
/python-lib64/
parts/
sdist/
var/
@@ -32,10 +35,10 @@ env.bak/
venv.bak/
# Node / JS
node_modules/
/dist/
/build/
.svelte-kit/
/frontend/node_modules/
/frontend/build/
/frontend/.svelte-kit/
/frontend/dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+31 -1
View File
@@ -1,8 +1,38 @@
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from sqlalchemy.orm import Session
from app.db.database import get_db, SessionLocal
from app.db import models
from app.services.archiver import archiver_manager
from app.services.scanner import JobManager
router = APIRouter(prefix="/backups", tags=["Backups"])
@router.post("/trigger/{media_id}")
def trigger_backup(
media_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)
):
media = db.query(models.StorageMedia).get(media_id)
if not media:
raise HTTPException(status_code=404, detail="Media not found")
if media.status != "active":
raise HTTPException(status_code=400, detail="Media is not active")
# Create the job record
job = JobManager.create_job(db, "BACKUP")
def run_backup_task():
db_inner = SessionLocal()
try:
archiver_manager.run_backup(db_inner, media_id=media_id, job_id=job.id)
finally:
db_inner.close()
background_tasks.add_task(run_backup_task)
return {"message": "Backup job initiated", "job_id": job.id}
@router.get("/")
def list_backups():
return []
+321 -57
View File
@@ -1,79 +1,343 @@
from fastapi import APIRouter, Depends
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Depends
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.db.database import get_db
from app.db import models
from datetime import datetime, timezone
import json
import os
router = APIRouter(prefix="/inventory", tags=["Inventory"])
# --- Helpers ---
def get_source_roots(db: Session) -> List[str]:
setting = (
db.query(models.SystemSetting)
.filter(models.SystemSetting.key == "source_roots")
.first()
)
if setting:
try:
return json.loads(setting.value)
except Exception:
return [setting.value]
local_source = os.path.abspath(os.path.join(os.getcwd(), "..", "source_data"))
if os.path.exists(local_source):
return [local_source]
return ["/source_data"]
# --- Schemas ---
class FileVersionSchema(BaseModel):
media_identifier: str
media_type: str
file_number: str
timestamp: datetime
class ItemMetadataSchema(BaseModel):
id: Optional[int] = None # Added for file operations
file_path: str
type: str
size: int
mtime: float
last_seen_timestamp: datetime
sha256_hash: Optional[str] = None
versions: List[FileVersionSchema] = []
child_count: Optional[int] = None
class FileItemSchema(BaseModel):
name: str
path: str
type: str
size: Optional[int] = None
mtime: Optional[float] = None
media: List[str] = [] # List of media identifiers this file is on
media: List[str] = []
class TreeNodeSchema(BaseModel):
name: str
path: str
has_children: bool = False
class MediaCreateSchema(BaseModel):
media_type: str # tape, hdd, cloud
identifier: str
generation_tier: Optional[str] = None
capacity: int # in bytes
location: Optional[str] = None
config: Dict[str, Any] = {}
class MediaUpdateSchema(BaseModel):
status: Optional[str] = None
location: Optional[str] = None
config: Optional[Dict[str, Any]] = None
class MediaSchema(BaseModel):
id: int
media_type: str
identifier: str
generation_tier: Optional[str]
capacity: int
bytes_used: int
location: Optional[str]
status: str
config: Dict[str, Any]
@classmethod
def from_orm_custom(cls, obj: models.StorageMedia):
config_data = {}
if obj.extra_config:
try:
config_data = json.loads(obj.extra_config)
except Exception:
pass
return cls(
id=obj.id,
media_type=obj.media_type,
identifier=obj.identifier,
generation_tier=obj.generation_tier,
capacity=obj.capacity,
bytes_used=obj.bytes_used,
location=obj.location,
status=obj.status,
config=config_data,
)
# --- Media Endpoints ---
@router.get("/media", response_model=List[MediaSchema])
def list_media(db: Session = Depends(get_db)):
all_media = db.query(models.StorageMedia).all()
return [MediaSchema.from_orm_custom(m) for m in all_media]
@router.post("/media", response_model=MediaSchema)
def register_media(req: MediaCreateSchema, db: Session = Depends(get_db)):
existing = (
db.query(models.StorageMedia)
.filter(models.StorageMedia.identifier == req.identifier)
.first()
)
if existing:
raise HTTPException(
status_code=400, detail="Media with this identifier already exists"
)
new_media = models.StorageMedia(
media_type=req.media_type,
identifier=req.identifier,
generation_tier=req.generation_tier,
capacity=req.capacity,
location=req.location,
status="active",
extra_config=json.dumps(req.config),
)
db.add(new_media)
db.commit()
db.refresh(new_media)
return MediaSchema.from_orm_custom(new_media)
@router.patch("/media/{media_id}", response_model=MediaSchema)
def update_media(media_id: int, req: MediaUpdateSchema, db: Session = Depends(get_db)):
media = db.query(models.StorageMedia).get(media_id)
if not media:
raise HTTPException(status_code=404, detail="Media not found")
if req.status:
media.status = req.status
if req.location:
media.location = req.location
if req.config is not None:
current_config = {}
if media.extra_config:
try:
current_config = json.loads(media.extra_config)
except Exception:
pass
current_config.update(req.config)
media.extra_config = json.dumps(current_config)
db.commit()
db.refresh(media)
return MediaSchema.from_orm_custom(media)
@router.delete("/media/{media_id}")
def delete_media(media_id: int, db: Session = Depends(get_db)):
media = db.query(models.StorageMedia).get(media_id)
if not media:
raise HTTPException(status_code=404, detail="Media not found")
if media.versions:
raise HTTPException(status_code=400, detail="Cannot delete media with files")
db.delete(media)
db.commit()
return {"message": "Media deleted"}
# --- Browsing Endpoints (Optimized) ---
@router.get("/browse", response_model=List[FileItemSchema])
def browse_index(path: str = "/", db: Session = Depends(get_db)):
# This is trickier because we store full paths.
# We need to find all unique "first level" children of the given path.
if not path.endswith("/"):
path += "/"
# Files directly in this path
# We can use a regex or just string manipulation in SQLite
# For simplicity, let's get all files starting with path and then parse
# Query for files that are in this directory
# A file is in /dir/ if its path starts with /dir/ and doesn't contain another / after that
# Actually, a better way for a Virtual FS is to query for all paths starting with 'path'
# and then find the next component.
all_files = (
db.query(models.FilesystemState)
.filter(models.FilesystemState.file_path.like(f"{path}%"))
.all()
)
results_map = {}
for f in all_files:
relative = f.file_path[len(path) :]
if not relative:
continue
parts = relative.split("/")
name = parts[0]
full_item_path = path + name
if len(parts) > 1:
# It's a directory
if name not in results_map:
results_map[name] = FileItemSchema(
name=name, path=full_item_path, type="directory", size=0, mtime=0
def browse_index(
path: Optional[str] = None,
include_ignored: bool = False,
db: Session = Depends(get_db),
):
if path is None or path == "ROOT":
roots = get_source_roots(db)
results = []
for root in roots:
sql = text(
"SELECT COUNT(*), SUM(size), MAX(mtime) FROM filesystem_state WHERE file_path LIKE :prefix"
+ (" AND is_ignored = 0" if not include_ignored else "")
)
row = db.execute(sql, {"prefix": f"{root}%"}).fetchone()
if row and row[0] > 0:
results.append(
FileItemSchema(
name=root,
path=root,
type="directory",
size=row[1] or 0,
mtime=row[2] or 0,
)
)
return results
prefix = path if path.endswith("/") else path + "/"
ignore_filter = " AND is_ignored = 0" if not include_ignored else ""
results = []
# Subdirectories
subdir_sql = text(
f"""
SELECT DISTINCT SUBSTR(file_path, LENGTH(:prefix) + 1, INSTR(SUBSTR(file_path, LENGTH(:prefix) + 1), '/') - 1) as dirname
FROM filesystem_state WHERE file_path LIKE :search_prefix AND SUBSTR(file_path, LENGTH(:prefix) + 1) LIKE '%/%' {ignore_filter}
"""
)
subdirs = db.execute(
subdir_sql, {"prefix": prefix, "search_prefix": f"{prefix}%"}
).fetchall()
for sd in subdirs:
if sd[0]:
results.append(
FileItemSchema(
name=sd[0], path=prefix + sd[0], type="directory", size=0, mtime=0
)
results_map[name].size += f.size
if f.mtime > results_map[name].mtime:
results_map[name].mtime = f.mtime
else:
# It's a file
media_list = [v.media.identifier for v in f.versions]
results_map[name] = FileItemSchema(
name=name,
path=f.file_path,
type="file",
size=f.size,
mtime=f.mtime,
media=media_list,
)
results = list(results_map.values())
results.sort(key=lambda x: (x.type != "directory", x.name.lower()))
# Files
file_sql = text(
f"""
SELECT name, file_path, size, mtime, id FROM (
SELECT SUBSTR(file_path, LENGTH(:prefix) + 1) as name, file_path, size, mtime, id
FROM filesystem_state WHERE file_path LIKE :search_prefix {ignore_filter}
) WHERE name NOT LIKE '%/%'
"""
)
files = db.execute(
file_sql, {"prefix": prefix, "search_prefix": f"{prefix}%"}
).fetchall()
for f in files:
media_sql = text(
"SELECT m.identifier FROM storage_media m JOIN file_versions v ON v.media_id = m.id WHERE v.filesystem_state_id = :fid"
)
media_list = [m[0] for m in db.execute(media_sql, {"fid": f[4]}).fetchall()]
results.append(
FileItemSchema(
name=f[0],
path=f[1],
type="file",
size=f[2],
mtime=f[3],
media=media_list,
)
)
results.sort(key=lambda x: (x.type != "directory", x.name.lower()))
return results
@router.get("/tree", response_model=List[TreeNodeSchema])
def get_index_tree(
path: Optional[str] = None,
include_ignored: bool = False,
db: Session = Depends(get_db),
):
if path is None or path == "ROOT":
roots = get_source_roots(db)
return [TreeNodeSchema(name=r, path=r, has_children=True) for r in roots]
prefix = path if path.endswith("/") else path + "/"
ignore_filter = " AND is_ignored = 0" if not include_ignored else ""
subdir_sql = text(
f"SELECT DISTINCT SUBSTR(file_path, LENGTH(:prefix) + 1, INSTR(SUBSTR(file_path, LENGTH(:prefix) + 1), '/') - 1) as dirname FROM filesystem_state WHERE file_path LIKE :search_prefix AND SUBSTR(file_path, LENGTH(:prefix) + 1) LIKE '%/%' {ignore_filter}"
)
subdirs = db.execute(
subdir_sql, {"prefix": prefix, "search_prefix": f"{prefix}%"}
).fetchall()
results = [
TreeNodeSchema(name=sd[0], path=prefix + sd[0], has_children=True)
for sd in subdirs
if sd[0]
]
results.sort(key=lambda x: x.name.lower())
return results
@router.get("/metadata", response_model=ItemMetadataSchema)
def get_item_metadata(path: str, db: Session = Depends(get_db)):
file_state = (
db.query(models.FilesystemState)
.filter(models.FilesystemState.file_path == path)
.first()
)
if file_state:
versions = [
FileVersionSchema(
media_identifier=v.media.identifier,
media_type=v.media.media_type,
file_number=v.file_number,
timestamp=file_state.last_seen_timestamp,
)
for v in file_state.versions
]
return ItemMetadataSchema(
id=file_state.id, # Now included
file_path=file_state.file_path,
type="file",
size=file_state.size,
mtime=file_state.mtime,
last_seen_timestamp=file_state.last_seen_timestamp,
sha256_hash=file_state.sha256_hash,
versions=versions,
)
prefix = path if path.endswith("/") else path + "/"
sql = text(
"SELECT COUNT(*), SUM(size), MAX(mtime), MAX(last_seen_timestamp) FROM filesystem_state WHERE file_path LIKE :prefix AND is_ignored = 0"
)
row = db.execute(sql, {"prefix": f"{prefix}%"}).fetchone()
if row and row[0] > 0:
return ItemMetadataSchema(
file_path=path,
type="directory",
size=row[1] or 0,
mtime=row[2] or 0,
last_seen_timestamp=row[3] or datetime.now(timezone.utc),
child_count=row[0],
)
raise HTTPException(status_code=404, detail="Item not found")
@router.get("/")
def list_inventory():
return []
+157
View File
@@ -0,0 +1,157 @@
from fastapi import APIRouter, HTTPException, Depends
from typing import List
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.db import models
router = APIRouter(prefix="/restores", tags=["Restores"])
# --- Schemas ---
class CartItemSchema(BaseModel):
id: int
file_path: str
size: int
media_identifiers: List[str]
class Config:
from_attributes = True
class ManifestMediaRequirement(BaseModel):
identifier: str
media_type: str
file_count: int
total_size: int
class RestoreManifestSchema(BaseModel):
total_files: int
total_size: int
media_required: List[ManifestMediaRequirement]
class DirectoryCartRequest(BaseModel):
path: str
# --- Endpoints ---
@router.get("/cart", response_model=List[CartItemSchema])
def list_cart(db: Session = Depends(get_db)):
items = db.query(models.RestoreCart).all()
results = []
for item in items:
media_ids = [v.media.identifier for v in item.file_state.versions]
results.append(
CartItemSchema(
id=item.id,
file_path=item.file_state.file_path,
size=item.file_state.size,
media_identifiers=media_ids,
)
)
return results
@router.post("/cart/{file_id}")
def add_to_cart(file_id: int, db: Session = Depends(get_db)):
existing = (
db.query(models.RestoreCart)
.filter(models.RestoreCart.filesystem_state_id == file_id)
.first()
)
if existing:
return {"message": "Already in cart"}
file_state = db.query(models.FilesystemState).get(file_id)
if not file_state or not file_state.versions:
raise HTTPException(status_code=400, detail="File has no backed up versions")
new_item = models.RestoreCart(filesystem_state_id=file_id)
db.add(new_item)
db.commit()
return {"message": "Added to cart"}
@router.post("/cart/directory")
def add_directory_to_cart(req: DirectoryCartRequest, db: Session = Depends(get_db)):
prefix = req.path if req.path.endswith("/") else req.path + "/"
# Find all files under this path that have at least one version
eligible_files = (
db.query(models.FilesystemState)
.filter(
models.FilesystemState.file_path.like(f"{prefix}%"),
models.FilesystemState.versions.any(),
)
.all()
)
if not eligible_files:
raise HTTPException(
status_code=404, detail="No restorable files found in this directory"
)
# Get current cart to avoid duplicates
in_cart = {c.filesystem_state_id for c in db.query(models.RestoreCart).all()}
added_count = 0
for f in eligible_files:
if f.id not in in_cart:
db.add(models.RestoreCart(filesystem_state_id=f.id))
added_count += 1
db.commit()
return {"message": f"Added {added_count} files from {req.path} to cart"}
@router.delete("/cart/{item_id}")
def remove_from_cart(item_id: int, db: Session = Depends(get_db)):
item = db.query(models.RestoreCart).get(item_id)
if item:
db.delete(item)
db.commit()
return {"message": "Removed from cart"}
@router.post("/cart/clear")
def clear_cart(db: Session = Depends(get_db)):
db.query(models.RestoreCart).delete()
db.commit()
return {"message": "Cart cleared"}
@router.get("/manifest", response_model=RestoreManifestSchema)
def get_manifest(db: Session = Depends(get_db)):
cart_items = db.query(models.RestoreCart).all()
if not cart_items:
return RestoreManifestSchema(total_files=0, total_size=0, media_required=[])
total_size = sum(item.file_state.size for item in cart_items)
media_map = {}
for item in cart_items:
if not item.file_state.versions:
continue
primary_v = item.file_state.versions[0]
ident = primary_v.media.identifier
m_type = primary_v.media.media_type
if ident not in media_map:
media_map[ident] = {
"identifier": ident,
"media_type": m_type,
"file_count": 0,
"total_size": 0,
}
media_map[ident]["file_count"] += 1
media_map[ident]["total_size"] += item.file_state.size
requirements = [ManifestMediaRequirement(**m) for m in media_map.values()]
requirements.sort(key=lambda x: x.identifier)
return RestoreManifestSchema(
total_files=len(cart_items), total_size=total_size, media_required=requirements
)
+324 -133
View File
@@ -1,21 +1,58 @@
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from fastapi.responses import StreamingResponse
import os
from typing import List, Optional
import json
import asyncio
from datetime import datetime
from typing import List, Optional, Dict, Tuple
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db.database import get_db
from sqlalchemy import func, text
from app.db.database import get_db, SessionLocal
from app.db import models
from app.services.scanner import scanner_manager, JobManager
import pathspec
router = APIRouter(prefix="/system", tags=["System"])
# --- Models ---
class DashboardStatsSchema(BaseModel):
total_files_indexed: int
total_data_size: int
tracked_paths_count: int
unprotected_files_count: int
unprotected_data_size: int
ignored_files_count: int
ignored_data_size: int
redundancy_ratio: float
last_scan_time: Optional[datetime]
media_distribution: Dict[str, int]
class JobSchema(BaseModel):
id: int
job_type: str
status: str
progress: float
current_task: Optional[str]
error_message: Optional[str]
started_at: Optional[datetime]
completed_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class FileItemSchema(BaseModel):
name: str
path: str
type: str # file, directory, link
type: str
size: Optional[int] = None
mtime: Optional[float] = None
tracked: bool = False
ignored: bool = False
class TrackToggleRequest(BaseModel):
@@ -23,147 +60,301 @@ class TrackToggleRequest(BaseModel):
is_directory: bool = True
@router.get("/browse", response_model=List[FileItemSchema])
def browse_path(path: str = "/source_data", db: Session = Depends(get_db)):
# If absolute path doesn't exist, try relative to project root
if not os.path.exists(path):
local_source = os.path.abspath(os.path.join(os.getcwd(), "..", "source_data"))
if path == "/source_data" and os.path.exists(local_source):
path = local_source
else:
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
if not os.path.isdir(path):
raise HTTPException(status_code=400, detail="Path is not a directory")
# Get all tracked paths to mark items as tracked
tracked_paths = {t.path for t in db.query(models.TrackedSource).all()}
results = []
try:
with os.scandir(path) as it:
for entry in it:
try:
stats = entry.stat(follow_symlinks=False)
item_type = "file"
if entry.is_dir():
item_type = "directory"
elif entry.is_symlink():
item_type = "link"
results.append(
FileItemSchema(
name=entry.name,
path=entry.path,
type=item_type,
size=stats.st_size,
mtime=stats.st_mtime,
tracked=entry.path in tracked_paths,
)
)
except Exception:
continue
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Sort: directories first, then name
results.sort(key=lambda x: (x.type != "directory", x.name.lower()))
return results
class BatchTrackRequest(BaseModel):
tracks: List[str] = []
untracks: List[str] = []
@router.post("/track")
def track_path(req: TrackToggleRequest, db: Session = Depends(get_db)):
existing = (
db.query(models.TrackedSource)
.filter(models.TrackedSource.path == req.path)
class ScanStatusSchema(BaseModel):
is_running: bool
files_processed: int
files_hashed: int
total_files_found: int
current_path: str
last_run_time: Optional[datetime] = None
class SettingSchema(BaseModel):
key: str
value: str
# --- Helpers ---
def get_source_roots(db: Session) -> List[str]:
setting = (
db.query(models.SystemSetting)
.filter(models.SystemSetting.key == "source_roots")
.first()
)
if existing:
return {"message": "Already tracked"}
new_track = models.TrackedSource(path=req.path, is_directory=req.is_directory)
db.add(new_track)
db.commit()
return {"message": "Path tracked"}
if setting:
try:
return json.loads(setting.value)
except Exception:
return [setting.value]
local_source = os.path.abspath(os.path.join(os.getcwd(), "..", "source_data"))
if os.path.exists(local_source):
return [local_source]
return ["/source_data"]
class BatchTrackRequest(BaseModel):
tracks: List[str] = [] # Paths to track
untracks: List[str] = [] # Paths to untrack
def get_exclusion_spec(db: Session) -> Optional[pathspec.PathSpec]:
setting = (
db.query(models.SystemSetting)
.filter(models.SystemSetting.key == "global_exclusions")
.first()
)
if not setting or not setting.value.strip():
return None
patterns = [p.strip() for p in setting.value.splitlines() if p.strip()]
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
def get_tracking_status(
path: str, tracking_map: Dict[str, str], spec: Optional[pathspec.PathSpec]
) -> Tuple[bool, bool]:
is_ignored = False
if spec and spec.match_file(path):
is_ignored = True
applicable_rules = []
for rule_path, action in tracking_map.items():
if path == rule_path or path.startswith(rule_path + "/"):
applicable_rules.append((len(rule_path), action))
if not applicable_rules:
return not is_ignored, is_ignored
applicable_rules.sort(key=lambda x: x[0], reverse=True)
is_tracked = applicable_rules[0][1] == "include"
return is_tracked, is_ignored
# --- Endpoints ---
@router.get("/dashboard/stats", response_model=DashboardStatsSchema)
def get_dashboard_stats(db: Session = Depends(get_db)):
agg_sql = text("""
SELECT
COUNT(*) as total_count,
SUM(size) as total_size,
SUM(CASE WHEN is_ignored = 1 THEN 1 ELSE 0 END) as ignored_count,
SUM(CASE WHEN is_ignored = 1 THEN size ELSE 0 END) as ignored_size,
SUM(CASE WHEN is_ignored = 0 AND is_indexed = 1 AND id NOT IN (SELECT filesystem_state_id FROM file_versions) THEN 1 ELSE 0 END) as unprotected_count,
SUM(CASE WHEN is_ignored = 0 AND is_indexed = 1 AND id NOT IN (SELECT filesystem_state_id FROM file_versions) THEN size ELSE 0 END) as unprotected_size
FROM filesystem_state
""")
result = db.execute(agg_sql).fetchone()
if result:
total_count = result[0] or 0
total_size = result[1] or 0
ignored_count = result[2] or 0
ignored_size = result[3] or 0
unprotected_count = result[4] or 0
unprotected_size = result[5] or 0
else:
total_count = total_size = ignored_count = ignored_size = unprotected_count = (
unprotected_size
) = 0
tracked_paths = (
db.query(func.count(models.TrackedSource.id))
.filter(models.TrackedSource.action == "include")
.scalar()
or 0
)
total_versions = db.query(func.count(models.FileVersion.id)).scalar() or 0
eligible_count = total_count - ignored_count
redundancy = total_versions / eligible_count if eligible_count > 0 else 0.0
media_dist = {"LTO": 0, "HDD": 0, "Cloud": 0}
media_counts = (
db.query(models.StorageMedia.media_type, func.count(models.StorageMedia.id))
.group_by(models.StorageMedia.media_type)
.all()
)
for mtype, count in media_counts:
media_dist[mtype.upper()] = count
return DashboardStatsSchema(
total_files_indexed=total_count,
total_data_size=total_size,
tracked_paths_count=tracked_paths,
unprotected_files_count=unprotected_count,
unprotected_data_size=unprotected_size,
ignored_files_count=ignored_count,
ignored_data_size=ignored_size,
redundancy_ratio=round(redundancy, 2),
last_scan_time=scanner_manager.last_run_time,
media_distribution=media_dist,
)
@router.get("/jobs", response_model=List[JobSchema])
def list_jobs(limit: int = 50, db: Session = Depends(get_db)):
return (
db.query(models.Job).order_by(models.Job.created_at.desc()).limit(limit).all()
)
@router.post("/jobs/{job_id}/cancel")
def cancel_job(job_id: int, db: Session = Depends(get_db)):
JobManager.cancel_job(job_id)
return {"message": "Cancellation request submitted"}
@router.get("/jobs/stream")
async def stream_jobs():
async def event_generator():
while True:
db = SessionLocal()
try:
active_jobs = (
db.query(models.Job)
.filter(models.Job.status.in_(["RUNNING", "PENDING"]))
.all()
)
data = [JobSchema.model_validate(j).model_dump() for j in active_jobs]
for job in data:
for key in ["started_at", "completed_at", "created_at"]:
if job[key] and isinstance(job[key], datetime):
job[key] = job[key].isoformat()
yield f"data: {json.dumps(data)}\n\n"
finally:
db.close()
await asyncio.sleep(1)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.post("/scan")
def trigger_scan(background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
if scanner_manager.is_running:
raise HTTPException(status_code=400, detail="Scan already in progress")
job = JobManager.create_job(db, "SCAN")
def run_scan():
db_inner = SessionLocal()
try:
scanner_manager.scan_sources(db_inner, job_id=job.id)
finally:
db_inner.close()
background_tasks.add_task(run_scan)
return {"message": "Scan started", "job_id": job.id}
@router.get("/scan/status", response_model=ScanStatusSchema)
def get_scan_status():
return ScanStatusSchema(
is_running=scanner_manager.is_running,
files_processed=scanner_manager.files_processed,
files_hashed=scanner_manager.files_hashed,
total_files_found=scanner_manager.total_files_found,
current_path=scanner_manager.current_path,
last_run_time=scanner_manager.last_run_time,
)
@router.get("/browse", response_model=List[FileItemSchema])
def browse_path(path: Optional[str] = None, db: Session = Depends(get_db)):
roots = get_source_roots(db)
tracking_map = {s.path: s.action for s in db.query(models.TrackedSource).all()}
spec = get_exclusion_spec(db)
if path is None or path == "ROOT":
results = []
for root in roots:
if not os.path.exists(root):
continue
stats = os.stat(root)
tracked, ignored = get_tracking_status(root, tracking_map, spec)
results.append(
FileItemSchema(
name=root,
path=root,
type="directory",
size=stats.st_size,
mtime=stats.st_mtime,
tracked=tracked,
ignored=ignored,
)
)
return results
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="Not found")
results = []
with os.scandir(path) as it:
for entry in it:
try:
stats = entry.stat(follow_symlinks=False)
tracked, ignored = get_tracking_status(entry.path, tracking_map, spec)
results.append(
FileItemSchema(
name=entry.name,
path=entry.path,
type="directory" if entry.is_dir() else "file",
size=stats.st_size,
mtime=stats.st_mtime,
tracked=tracked,
ignored=ignored,
)
)
except Exception:
continue
results.sort(key=lambda x: (x.type != "directory", x.name.lower()))
return results
@router.post("/track/batch")
def track_batch(req: BatchTrackRequest, db: Session = Depends(get_db)):
# Handle untracks
if req.untracks:
db.query(models.TrackedSource).filter(
models.TrackedSource.path.in_(req.untracks)
).delete(synchronize_session=False)
# Handle tracks
if req.tracks:
# Get existing to avoid duplicates
existing = {
t.path
for t in db.query(models.TrackedSource)
.filter(models.TrackedSource.path.in_(req.tracks))
.all()
}
new_paths = [path for path in req.tracks if path not in existing]
for path in new_paths:
# Note: In a real app we'd verify if it's a directory
new_track = models.TrackedSource(path=path, is_directory=True)
db.add(new_track)
db.commit()
return {
"message": f"Processed {len(req.tracks)} tracks and {len(req.untracks)} untracks"
}
class TreeNodeSchema(BaseModel):
name: str
path: str
has_children: bool = False
@router.get("/tree", response_model=List[TreeNodeSchema])
def get_tree(path: str = "/source_data"):
if not os.path.exists(path):
local_source = os.path.abspath(os.path.join(os.getcwd(), "..", "source_data"))
if path == "/source_data" and os.path.exists(local_source):
path = local_source
for path in req.tracks:
existing = (
db.query(models.TrackedSource)
.filter(models.TrackedSource.path == path)
.first()
)
if existing:
existing.action = "include"
else:
raise HTTPException(status_code=404, detail="Path not found")
db.add(models.TrackedSource(path=path, action="include"))
for path in req.untracks:
existing = (
db.query(models.TrackedSource)
.filter(models.TrackedSource.path == path)
.first()
)
if existing:
existing.action = "exclude"
else:
db.add(models.TrackedSource(path=path, action="exclude"))
db.commit()
return {"message": "Updated"}
if not os.path.isdir(path):
return []
results = []
try:
with os.scandir(path) as it:
for entry in it:
if entry.is_dir():
# Check if it has subdirectories for the expander icon
has_subdirs = False
try:
with os.scandir(entry.path) as sub_it:
for sub_entry in sub_it:
if sub_entry.is_dir():
has_subdirs = True
break
except Exception:
pass
@router.get("/settings", response_model=Dict[str, str])
def get_settings(db: Session = Depends(get_db)):
all_settings = db.query(models.SystemSetting).all()
return {s.key: s.value for s in all_settings}
results.append(
TreeNodeSchema(
name=entry.name, path=entry.path, has_children=has_subdirs
)
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
results.sort(key=lambda x: x.name.lower())
return results
@router.post("/settings")
def update_setting(req: SettingSchema, db: Session = Depends(get_db)):
setting = (
db.query(models.SystemSetting)
.filter(models.SystemSetting.key == req.key)
.first()
)
if setting:
setting.value = req.value
else:
db.add(models.SystemSetting(key=req.key, value=req.value))
db.commit()
return {"message": "Updated"}
@router.get("/tree")
def get_tree(path: Optional[str] = None, db: Session = Depends(get_db)):
roots = get_source_roots(db)
if path is None or path == "ROOT":
return [{"name": r, "path": r, "has_children": True} for r in roots]
return []
+68 -10
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from datetime import datetime, timezone
from typing import Optional, List
from sqlalchemy import Integer, String, Float, ForeignKey, DateTime, Boolean
from sqlalchemy import Integer, String, Float, ForeignKey, DateTime, Boolean, BigInteger
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -14,10 +14,18 @@ class FilesystemState(Base):
id: Mapped[int] = mapped_column(primary_key=True)
file_path: Mapped[str] = mapped_column(String, index=True, unique=True)
size: Mapped[int] = mapped_column(Integer)
size: Mapped[int] = mapped_column(BigInteger)
mtime: Mapped[float] = mapped_column(Float)
sha256_hash: Mapped[Optional[str]] = mapped_column(String, index=True)
last_seen_timestamp: Mapped[datetime] = mapped_column(DateTime)
sha256_hash: Mapped[Optional[str]] = mapped_column(
String, index=True, nullable=True
)
is_indexed: Mapped[bool] = mapped_column(Boolean, default=False) # True if hashed
is_ignored: Mapped[bool] = mapped_column(
Boolean, default=False
) # True if matches exclusion
last_seen_timestamp: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
versions: Mapped[List["FileVersion"]] = relationship(back_populates="file_state")
@@ -33,12 +41,15 @@ class StorageMedia(Base):
generation_tier: Mapped[Optional[str]] = mapped_column(
String
) # e.g., LTO-6, S3 Standard
capacity: Mapped[int] = mapped_column(Integer) # Native capacity in bytes
bytes_used: Mapped[int] = mapped_column(Integer, default=0)
capacity: Mapped[int] = mapped_column(BigInteger) # Native capacity in bytes
bytes_used: Mapped[int] = mapped_column(BigInteger, default=0)
location: Mapped[Optional[str]] = mapped_column(String)
status: Mapped[str] = mapped_column(
String, default="active"
) # active, full, retired, offline
extra_config: Mapped[Optional[str]] = mapped_column(
String
) # JSON config for type-specific details
versions: Mapped[List["FileVersion"]] = relationship(back_populates="media")
@@ -75,7 +86,52 @@ class TrackedSource(Base):
id: Mapped[int] = mapped_column(primary_key=True)
path: Mapped[str] = mapped_column(String, unique=True, index=True)
is_directory: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
action: Mapped[str] = mapped_column(String, default="include") # include, exclude
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
class RestoreCart(Base):
__tablename__ = "restore_cart"
id: Mapped[int] = mapped_column(primary_key=True)
filesystem_state_id: Mapped[int] = mapped_column(ForeignKey("filesystem_state.id"))
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
file_state: Mapped["FilesystemState"] = relationship()
class Job(Base):
__tablename__ = "jobs"
id: Mapped[int] = mapped_column(primary_key=True)
job_type: Mapped[str] = mapped_column(String) # SCAN, BACKUP, RESTORE
status: Mapped[str] = mapped_column(
String, default="PENDING"
) # PENDING, RUNNING, COMPLETED, FAILED
progress: Mapped[float] = mapped_column(Float, default=0.0)
current_task: Mapped[Optional[str]] = mapped_column(String, nullable=True)
error_message: Mapped[Optional[str]] = mapped_column(String, nullable=True)
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
class SystemSetting(Base):
__tablename__ = "system_settings"
key: Mapped[str] = mapped_column(String, primary_key=True)
value: Mapped[str] = mapped_column(String)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
class JobLog(Base):
@@ -83,8 +139,10 @@ class JobLog(Base):
id: Mapped[int] = mapped_column(primary_key=True)
backup_id: Mapped[int] = mapped_column(ForeignKey("backups.id"))
timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
log_level: Mapped[str] = mapped_column(String) # INFO, WARN, ERROR
timestamp: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
log_level: Mapped[str] = mapped_column(String) # INFO, ERROR, WARN
message: Mapped[str] = mapped_column(String)
backup: Mapped["BackupJob"] = relationship(back_populates="logs")
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import system, inventory, backups
from app.api import system, inventory, backups, restores
from app.db.database import engine
from app.db import models
@@ -26,6 +26,7 @@ app.add_middleware(
app.include_router(system.router)
app.include_router(inventory.router)
app.include_router(backups.router)
app.include_router(restores.router)
@app.get("/")
+45
View File
@@ -0,0 +1,45 @@
from abc import ABC, abstractmethod
from typing import Optional, BinaryIO
class AbstractStorageProvider(ABC):
@abstractmethod
def get_name(self) -> str:
"""Returns the human-readable name of the provider (e.g., 'LTO Tape')"""
pass
@abstractmethod
def identify_media(self) -> Optional[str]:
"""
Attempts to read the identifier (barcode/UUID) from the currently inserted media.
Returns None if no media is inserted or it's unidentifiable.
"""
pass
@abstractmethod
def prepare_for_write(self, media_id: str) -> bool:
"""
Performs any necessary setup (e.g., mounting, winding) before writing.
Returns True if ready.
"""
pass
@abstractmethod
def write_archive(self, media_id: str, stream: BinaryIO) -> str:
"""
Writes a tar stream to the media.
Returns a 'file_number' or 'object_path' used to locate this archive later.
"""
pass
@abstractmethod
def finalize_media(self, media_id: str):
"""Finalizes the media (e.g., writing index, ejecting)"""
pass
@abstractmethod
def read_archive(self, media_id: str, location_id: str) -> BinaryIO:
"""
Retrieves a specific archive stream from the media.
"""
pass
+55
View File
@@ -0,0 +1,55 @@
import boto3
from typing import Optional, BinaryIO, Dict, Any
from .base import AbstractStorageProvider
from loguru import logger
class CloudStorageProvider(AbstractStorageProvider):
def __init__(self, config: Dict[str, Any]):
self.provider_type = config.get("provider", "S3")
self.bucket_name = config.get("bucket_name")
self.region = config.get("region", "us-east-1")
self.endpoint_url = config.get("endpoint_url") # For non-AWS S3 (B2, Wasabi)
# We assume credentials are in env or handled by the host
self.s3 = boto3.client(
"s3", region_name=self.region, endpoint_url=self.endpoint_url
)
def get_name(self) -> str:
return f"Cloud ({self.provider_type})"
def identify_media(self) -> Optional[str]:
"""Checks if the bucket exists and we have access"""
try:
self.s3.head_bucket(Bucket=self.bucket_name)
return self.bucket_name
except Exception as e:
logger.error(f"Failed to identify cloud bucket {self.bucket_name}: {e}")
return None
def prepare_for_write(self, media_id: str) -> bool:
return self.identify_media() == media_id
def write_archive(self, media_id: str, stream: BinaryIO) -> str:
"""Uploads the stream as a new object in the bucket"""
# Generate a unique object key (future: use job timestamp)
import uuid
object_key = f"archives/{uuid.uuid4().hex}.tar"
logger.info(f"Uploading archive to cloud: {media_id}/{object_key}")
try:
self.s3.upload_fileobj(stream, self.bucket_name, object_key)
return object_key
except Exception as e:
logger.error(f"Cloud upload failed: {e}")
raise
def finalize_media(self, media_id: str):
logger.info(f"Finalized cloud media {media_id}")
def read_archive(self, media_id: str, location_id: str) -> BinaryIO:
"""Returns a streaming body for the S3 object"""
response = self.s3.get_object(Bucket=self.bucket_name, Key=location_id)
return response["Body"]
+92
View File
@@ -0,0 +1,92 @@
import os
import shutil
from typing import Optional, BinaryIO
from .base import AbstractStorageProvider
from loguru import logger
class OfflineHDDProvider(AbstractStorageProvider):
def __init__(self, mount_base: str = "/mnt/backup_disk"):
self.mount_base = mount_base
def get_name(self) -> str:
return "Offline HDD"
def identify_media(self) -> Optional[str]:
"""Reads the hidden identifier file from the disk root"""
id_file = os.path.join(self.mount_base, ".tapehoard_id")
logger.info(f"HDD Provider: Checking for ID file at: {id_file}")
if os.path.exists(id_file):
try:
with open(id_file, "r") as f:
content = f.read().strip()
logger.info(
f"HDD Provider: Found identifier file. Content: '{content}'"
)
return content
except Exception as e:
logger.error(
f"HDD Provider: Failed to read identifier at {id_file}: {e}"
)
else:
logger.warning(f"HDD Provider: Identifier file NOT FOUND at: {id_file}")
if os.path.exists(self.mount_base):
try:
logger.info(
f"HDD Provider: Base directory {self.mount_base} exists. Contents: {os.listdir(self.mount_base)}"
)
except Exception as e:
logger.error(f"HDD Provider: Failed to list base dir: {e}")
else:
logger.error(
f"HDD Provider: Base directory {self.mount_base} DOES NOT EXIST."
)
return None
def prepare_for_write(self, media_id: str) -> bool:
"""Verifies the disk is mounted and the identifier matches"""
current_id = self.identify_media()
if current_id != media_id:
logger.error(f"Media mismatch. Expected {media_id}, found {current_id}")
return False
# Ensure visible data directory exists
archive_dir = os.path.join(self.mount_base, "tapehoard_backups", "archives")
os.makedirs(archive_dir, exist_ok=True)
return True
def write_archive(self, media_id: str, stream: BinaryIO) -> str:
"""Writes the stream to a new numbered file in a visible folder"""
archive_dir = os.path.join(self.mount_base, "tapehoard_backups", "archives")
# Determine next file number
existing = os.listdir(archive_dir)
archives = [int(f.split(".")[0]) for f in existing if f.endswith(".tar")]
next_num = max(archives, default=-1) + 1
file_name = f"{next_num:06d}.tar"
target_path = os.path.join(archive_dir, file_name)
logger.info(f"Writing HDD archive {file_name} to {media_id}")
with open(target_path, "wb") as f:
shutil.copyfileobj(stream, f)
return str(next_num)
def finalize_media(self, media_id: str):
"""Standard HDD finalization (flush caches)"""
os.sync()
logger.info(f"Finalized HDD media {media_id}")
def read_archive(self, media_id: str, location_id: str) -> BinaryIO:
file_name = f"{int(location_id):06d}.tar"
target_path = os.path.join(
self.mount_base, "tapehoard_backups", "archives", file_name
)
if not os.path.exists(target_path):
raise FileNotFoundError(
f"Archive {location_id} not found on media {media_id}"
)
return open(target_path, "rb")
+92
View File
@@ -0,0 +1,92 @@
import subprocess
from typing import Optional, BinaryIO, cast
from .base import AbstractStorageProvider
from loguru import logger
class LTOProvider(AbstractStorageProvider):
def __init__(self, device_path: str = "/dev/nst0"):
self.device_path = device_path
def get_name(self) -> str:
return "LTO Tape"
def _run_mt(self, command: str):
try:
subprocess.run(["mt", "-f", self.device_path, command], check=True)
except subprocess.CalledProcessError as e:
logger.error(f"Tape command 'mt {command}' failed: {e}")
raise
def identify_media(self) -> Optional[str]:
"""Reads the label from the beginning of the tape (File Mark 0)"""
try:
self._run_mt("rewind")
# Try to read the label file
result = subprocess.run(
["tar", "-xf", self.device_path, "-O", ".tapehoard_label"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception as e:
logger.error(f"Failed to identify tape: {e}")
return None
def prepare_for_write(self, media_id: str) -> bool:
"""Fast-forwards to the end of the data to prepare for appending"""
current_id = self.identify_media()
if current_id != media_id:
logger.error(f"Tape mismatch. Expected {media_id}, found {current_id}")
return False
# Move to end of data
self._run_mt("eod")
return True
def write_archive(self, media_id: str, stream: BinaryIO) -> str:
"""Writes the stream to tape and returns the file number index"""
logger.info(f"Streaming archive to LTO {media_id} at current head position")
proc = subprocess.Popen(
["dd", f"of={self.device_path}", "bs=256k"], stdin=subprocess.PIPE
)
if proc.stdin:
# Copy stream to dd stdin
while True:
chunk = stream.read(1024 * 1024)
if not chunk:
break
proc.stdin.write(chunk)
proc.stdin.close()
proc.wait()
return "unknown" # To be refined with 'mt status' parsing
def finalize_media(self, media_id: str):
self._run_mt("offline") # Rewind and eject
def read_archive(self, media_id: str, location_id: str) -> BinaryIO:
# Seek to FM index
self._run_mt("rewind")
try:
loc_int = int(location_id)
if loc_int > 0:
self._run_mt(f"fsf {loc_int}")
except ValueError:
pass
# Return a pipe from dd
proc = subprocess.Popen(
["dd", f"if={self.device_path}", "bs=256k"], stdout=subprocess.PIPE
)
if proc.stdout is None:
raise RuntimeError("Failed to open pipe from dd")
return cast(BinaryIO, proc.stdout)
+189
View File
@@ -0,0 +1,189 @@
import os
import tarfile
import json
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any
from loguru import logger
from sqlalchemy.orm import Session
from sqlalchemy import not_
from app.db import models
from app.services.scanner import JobManager
from app.providers.hdd import OfflineHDDProvider
from app.providers.tape import LTOProvider
from app.providers.cloud import CloudStorageProvider
class ArchiverService:
def __init__(self, staging_dir: str = "/staging"):
self.staging_dir = staging_dir
if not os.path.exists(self.staging_dir):
try:
os.makedirs(self.staging_dir, exist_ok=True)
except Exception:
# Fallback for local dev without /staging
self.staging_dir = os.path.join(os.getcwd(), "staging_area")
os.makedirs(self.staging_dir, exist_ok=True)
def _get_provider(self, media: models.StorageMedia):
config: Dict[str, Any] = {}
if media.extra_config:
try:
config = json.loads(media.extra_config)
except Exception:
pass
if media.media_type == "tape":
return LTOProvider(device_path=config.get("device_path", "/dev/nst0"))
elif media.media_type == "hdd":
return OfflineHDDProvider(
mount_base=config.get("mount_path", "/mnt/backup")
)
elif media.media_type == "cloud":
return CloudStorageProvider(config=config)
return None
def get_eligible_files(self, db: Session) -> List[models.FilesystemState]:
"""Returns files that are indexed but have no version on any media"""
return (
db.query(models.FilesystemState)
.filter(
models.FilesystemState.is_indexed,
not_(models.FilesystemState.is_ignored),
not_(models.FilesystemState.versions.any()),
)
.all()
)
def create_backup_set(
self, db: Session, media_id: int, max_bytes: Optional[int] = None
) -> List[models.FilesystemState]:
"""Selects a batch of files that fit on the media's remaining capacity"""
media = db.query(models.StorageMedia).get(media_id)
if not media:
return []
remaining_capacity = media.capacity - media.bytes_used
if max_bytes:
remaining_capacity = min(remaining_capacity, max_bytes)
eligible = self.get_eligible_files(db)
# Simple Greedy Bin-Packing
backup_set = []
current_size = 0
for f in eligible:
if current_size + f.size <= remaining_capacity:
backup_set.append(f)
current_size += f.size
return backup_set
def run_backup(self, db: Session, media_id: int, job_id: int):
media = db.query(models.StorageMedia).get(media_id)
if not media:
JobManager.fail_job(job_id, "Media not found")
return
JobManager.start_job(job_id)
JobManager.update_job(
job_id, 5.0, f"Preparing backup set for {media.identifier}..."
)
backup_set = self.create_backup_set(db, media_id)
if not backup_set:
JobManager.complete_job(job_id)
logger.info("No eligible files for backup")
return
total_bytes = sum(f.size for f in backup_set)
JobManager.update_job(
job_id,
10.0,
f"Backing up {len(backup_set)} files ({total_bytes / 1e9:.2f} GB)...",
)
provider = self._get_provider(media)
if not provider:
JobManager.fail_job(job_id, f"Unsupported media type: {media.media_type}")
return
# 1. Identify Media
current_id = provider.identify_media()
if current_id != media.identifier:
JobManager.fail_job(
job_id,
f"Media mismatch! Insert {media.identifier} (Found: {current_id})",
)
return
if not provider.prepare_for_write(media.identifier):
JobManager.fail_job(job_id, "Failed to prepare media for writing")
return
# 2. Create Archive in Staging
# For now, we package everything into one tar for this job
archive_name = (
f"backup_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.tar"
)
staging_path = os.path.join(self.staging_dir, archive_name)
try:
processed_bytes = 0
with tarfile.open(staging_path, "w") as tar:
for f_state in backup_set:
if JobManager.is_cancelled(job_id):
break
JobManager.update_job(
job_id,
15.0 + (70.0 * (processed_bytes / total_bytes)),
f"Archiving: {os.path.basename(f_state.file_path)}",
)
if os.path.exists(f_state.file_path):
tar.add(
f_state.file_path, arcname=f_state.file_path.lstrip("/")
)
processed_bytes += f_state.size
if JobManager.is_cancelled(job_id):
if os.path.exists(staging_path):
os.remove(staging_path)
return
# 3. Stream to Provider
JobManager.update_job(
job_id, 85.0, f"Streaming archive to {media.media_type}..."
)
with open(staging_path, "rb") as archive_stream:
location_id = provider.write_archive(media.identifier, archive_stream)
# 4. Finalize & Record
provider.finalize_media(media.identifier)
# Update database records
for f_state in backup_set:
version = models.FileVersion(
filesystem_state_id=f_state.id,
media_id=media.id,
file_number=location_id,
)
db.add(version)
media.bytes_used += os.path.getsize(staging_path)
db.commit()
JobManager.complete_job(job_id)
logger.info(f"Backup job {job_id} completed successfully")
except Exception as e:
logger.exception(f"Backup failed: {e}")
JobManager.fail_job(job_id, str(e))
finally:
if os.path.exists(staging_path):
os.remove(staging_path)
archiver_manager = ArchiverService()
+448
View File
@@ -0,0 +1,448 @@
import os
import hashlib
from datetime import datetime, timezone
from typing import Dict, List, Optional, Set, Tuple, Any, cast
from loguru import logger
from sqlalchemy.orm import Session
from app.db import models
from app.db.database import SessionLocal
import concurrent.futures
import pathspec
import json
class JobManager:
# Set of job IDs that have been requested to cancel
_cancelled_jobs: Set[int] = set()
@staticmethod
def create_job(db: Session, job_type: str) -> models.Job:
job = models.Job(job_type=job_type, status="PENDING")
db.add(job)
db.commit()
db.refresh(job)
return job
@staticmethod
def start_job(job_id: int):
db = SessionLocal()
try:
job = db.get(models.Job, job_id)
if job:
job.status = "RUNNING"
job.started_at = datetime.now(timezone.utc)
db.commit()
finally:
db.close()
@staticmethod
def update_job(job_id: int, progress: float, current_task: str):
db = SessionLocal()
try:
job = db.get(models.Job, job_id)
if job:
job.progress = progress
job.current_task = current_task
db.commit()
finally:
db.close()
@staticmethod
def complete_job(job_id: int):
db = SessionLocal()
try:
job = db.get(models.Job, job_id)
if job:
job.status = "COMPLETED"
job.progress = 100.0
job.completed_at = datetime.now(timezone.utc)
db.commit()
if job_id in JobManager._cancelled_jobs:
JobManager._cancelled_jobs.remove(job_id)
finally:
db.close()
@staticmethod
def fail_job(job_id: int, error_message: str):
db = SessionLocal()
try:
job = db.get(models.Job, job_id)
if job:
job.status = "FAILED"
job.error_message = error_message
job.completed_at = datetime.now(timezone.utc)
db.commit()
if job_id in JobManager._cancelled_jobs:
JobManager._cancelled_jobs.remove(job_id)
finally:
db.close()
@staticmethod
def cancel_job(job_id: int):
JobManager._cancelled_jobs.add(job_id)
db = SessionLocal()
try:
job = db.get(models.Job, job_id)
if job and job.status in ["PENDING", "RUNNING"]:
job.status = "FAILED"
job.error_message = "Cancelled by user"
job.completed_at = datetime.now(timezone.utc)
db.commit()
finally:
db.close()
@staticmethod
def is_cancelled(job_id: int) -> bool:
return job_id in JobManager._cancelled_jobs
class ScannerService:
def __init__(self):
self.is_running = False
self.last_run_time: Optional[datetime] = None
self.files_processed = 0
self.files_hashed = 0
self.total_files_found = 0
self.current_path = ""
def compute_sha256(self, file_path: str, job_id: Optional[int] = None) -> str:
sha256_hash = hashlib.sha256()
try:
with open(file_path, "rb") as f:
for byte_block in iter(lambda: f.read(1048576), b""):
# Check for cancellation during block read
if job_id is not None and JobManager.is_cancelled(job_id):
return ""
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
except Exception as e:
logger.error(f"Failed to hash {file_path}: {e}")
return ""
def _get_exclusion_spec(self, db: Session) -> Optional[pathspec.PathSpec]:
setting = (
db.query(models.SystemSetting)
.filter(models.SystemSetting.key == "global_exclusions")
.first()
)
if not setting or not setting.value.strip():
return None
patterns = [p.strip() for p in setting.value.splitlines() if p.strip()]
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
def _get_source_roots(self, db: Session) -> List[str]:
setting = (
db.query(models.SystemSetting)
.filter(models.SystemSetting.key == "source_roots")
.first()
)
if setting:
try:
val = json.loads(setting.value)
if isinstance(val, list):
return [str(v) for v in val]
return [str(val)]
except Exception:
return [str(setting.value)]
local_source = os.path.abspath(os.path.join(os.getcwd(), "..", "source_data"))
if os.path.exists(local_source):
return [local_source]
return ["/source_data"]
def scan_sources(self, db: Session, job_id: Optional[int] = None):
if self.is_running:
logger.warning("Scan already in progress")
return
self.is_running = True
self.files_processed = 0
self.files_hashed = 0
self.total_files_found = 0
if job_id is not None:
JobManager.start_job(job_id)
try:
# 1. Load Rules & Roots
spec = self._get_exclusion_spec(db)
roots = self._get_source_roots(db)
tracking_rules = db.query(models.TrackedSource).all()
# Type-safe list extraction to help 'ty' inference
raw_include: List[str] = [
r.path for r in tracking_rules if r.action == "include"
]
raw_exclude: List[str] = [
r.path for r in tracking_rules if r.action == "exclude"
]
# Using cast because ty incorrectly infers result of sorted(List[str], key=len) as List[Sized]
include_rules = cast(List[str], sorted(raw_include, key=len, reverse=True))
exclude_rules = cast(List[str], sorted(raw_exclude, key=len, reverse=True))
def get_tracking_status(path: str) -> Tuple[bool, bool]:
# returns (is_tracked, is_ignored)
is_ignored = False
if spec and spec.match_file(path):
is_ignored = True
# Check explicit rules
# If a rule matches, it determines the tracking state
for ex in exclude_rules:
if path == ex or path.startswith(ex + "/"):
# Now check if there's a more specific include under this exclude
for inc in include_rules:
if len(inc) > len(ex) and (
path == inc or path.startswith(inc + "/")
):
return True, is_ignored
return False, is_ignored
for inc in include_rules:
if path == inc or path.startswith(inc + "/"):
return True, is_ignored
# NEW DEFAULT: If no explicit rule and NOT globally ignored, track it!
return not is_ignored, is_ignored
if job_id is not None:
JobManager.update_job(job_id, 2.0, "Initiating metadata discovery...")
# 2. Optimized Discovery & Sync Phase
BATCH_SIZE = 1000
pending_files: List[Dict[str, Any]] = []
now = datetime.now(timezone.utc)
with concurrent.futures.ThreadPoolExecutor(
max_workers=os.cpu_count()
) as executor:
hashing_futures: List[concurrent.futures.Future] = []
for root_path in roots:
if job_id is not None and JobManager.is_cancelled(job_id):
break
if not os.path.exists(root_path):
continue
for root, dirs, files in os.walk(root_path):
if job_id is not None and JobManager.is_cancelled(job_id):
break
original_dirs = list(dirs)
for d in original_dirs:
d_path = os.path.join(root, d)
if spec and spec.match_file(d_path + "/"):
dirs.remove(d)
for file in files:
if job_id is not None and JobManager.is_cancelled(job_id):
break
full_path = os.path.join(root, file)
try:
stats = os.stat(full_path)
tracked, ignored = get_tracking_status(full_path)
pending_files.append(
{
"path": full_path,
"size": stats.st_size,
"mtime": stats.st_mtime,
"tracked": tracked,
"ignored": ignored,
}
)
except Exception:
continue
if len(pending_files) >= BATCH_SIZE:
self._sync_batch(
db,
pending_files,
executor,
hashing_futures,
now,
job_id,
)
pending_files = []
if job_id is not None and JobManager.is_cancelled(job_id):
logger.info("Scan task detected cancellation. Stopping.")
return
if pending_files:
self._sync_batch(
db, pending_files, executor, hashing_futures, now, job_id
)
if hashing_futures:
if job_id is not None:
JobManager.update_job(
job_id,
50.0,
f"Processing {len(hashing_futures)} hashing tasks...",
)
for future in concurrent.futures.as_completed(hashing_futures):
if job_id is not None and JobManager.is_cancelled(job_id):
break
result = future.result()
if result:
f_path, f_size, f_mtime, f_hash, f_ignored, f_new = result
if (
f_hash == ""
and job_id is not None
and JobManager.is_cancelled(job_id)
):
continue # Cancelled during hash
ext = (
db.query(models.FilesystemState)
.filter(models.FilesystemState.file_path == f_path)
.first()
)
if f_new and not ext:
db.add(
models.FilesystemState(
file_path=f_path,
size=f_size,
mtime=f_mtime,
sha256_hash=f_hash,
is_indexed=f_hash is not None,
is_ignored=f_ignored,
last_seen_timestamp=now,
)
)
elif ext:
ext.size = f_size
ext.mtime = f_mtime
ext.sha256_hash = f_hash
ext.is_indexed = f_hash is not None
ext.is_ignored = f_ignored
ext.last_seen_timestamp = now
if f_hash:
self.files_hashed += 1
self.files_processed += 1
if self.files_processed % 50 == 0:
db.commit()
if job_id is not None and self.total_files_found > 0:
prog = 10.0 + (
90.0
* (
self.files_processed
/ self.total_files_found
)
)
JobManager.update_job(
job_id,
round(prog, 1),
f"Hashing & Indexing: {self.files_processed}/{self.total_files_found}...",
)
if job_id is not None and JobManager.is_cancelled(job_id):
return
db.commit()
self.last_run_time = datetime.now(timezone.utc)
if job_id is not None:
JobManager.complete_job(job_id)
except Exception as e:
logger.exception(f"Scan failed: {e}")
db.rollback()
if job_id is not None:
JobManager.fail_job(job_id, str(e))
finally:
self.is_running = False
self.current_path = ""
def _sync_batch(
self, db: Session, files: List[Dict[str, Any]], executor, futures, now, job_id
):
if job_id is not None and JobManager.is_cancelled(job_id):
return
paths = [f["path"] for f in files]
existing_records = {
r.file_path: r
for r in db.query(models.FilesystemState)
.filter(models.FilesystemState.file_path.in_(paths))
.all()
}
for f in files:
if job_id is not None and JobManager.is_cancelled(job_id):
return
self.current_path = f["path"]
ext = existing_records.get(f["path"])
needs_metadata_update = (
not ext
or ext.size != f["size"]
or ext.mtime != f["mtime"]
or ext.is_ignored != f["ignored"]
)
# We hash if tracked AND NOT ignored
needs_hashing = (
f["tracked"]
and not f["ignored"]
and (not ext or not ext.sha256_hash or needs_metadata_update)
)
if needs_hashing:
futures.append(
executor.submit(
self._process_file,
f["path"],
f["size"],
f["mtime"],
f["tracked"],
f["ignored"],
True,
ext is None,
job_id,
)
)
elif needs_metadata_update or ext:
if not ext:
db.add(
models.FilesystemState(
file_path=f["path"],
size=f["size"],
mtime=f["mtime"],
sha256_hash=None,
is_indexed=False,
is_ignored=f["ignored"],
last_seen_timestamp=now,
)
)
else:
ext.size = f["size"]
ext.mtime = f["mtime"]
ext.is_ignored = f["ignored"]
ext.is_indexed = ext.sha256_hash is not None
ext.last_seen_timestamp = now
self.files_processed += 1
db.commit()
if job_id is not None:
JobManager.update_job(
job_id, 10.0, f"Discovered and synced {self.files_processed} items..."
)
def _process_file(
self,
file_path: str,
size: int,
mtime: float,
tracked: bool,
ignored: bool,
hash_it: bool,
is_new: bool,
job_id: Optional[int] = None,
):
if job_id is not None and JobManager.is_cancelled(job_id):
return (file_path, size, mtime, "", ignored, is_new)
sha_hash = self.compute_sha256(file_path, job_id) if hash_it else None
return (file_path, size, mtime, sha_hash, ignored, is_new)
scanner_manager = ScannerService()
+2
View File
@@ -14,6 +14,8 @@ dependencies = [
"apprise",
"loguru>=0.7.3",
"pydantic-settings>=2.14.0",
"pathspec>=1.1.0",
"boto3>=1.42.94",
]
[build-system]
+83
View File
@@ -79,6 +79,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
]
[[package]]
name = "boto3"
version = "1.42.94"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/6a/95302333208830de932ad1d0b69599ee13e936349a44981fb72632507861/boto3-1.42.94.tar.gz", hash = "sha256:5b6056a661c19e974aaea3cb97690ddbe30d10c31e4f887df3bff06574f34510", size = 113211, upload-time = "2026-04-22T20:36:19.167Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/6f/4e175604f3168befcb413c95bf45eada67d12042f92f76a9305d6a817ea9/boto3-1.42.94-py3-none-any.whl", hash = "sha256:56d53bce75629cc7c78a32da8b62de74cee3e2a3d54a2b60ba1a65f9f1b129da", size = 140555, upload-time = "2026-04-22T20:36:16.182Z" },
]
[[package]]
name = "botocore"
version = "1.42.94"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/90/1a4d0e81b325d38e37f81d907ceacac3b8f509ad38b495bb95086ecb609d/botocore-1.42.94.tar.gz", hash = "sha256:41c6b3b11b073221a41f52b222ba387be34459fb77cdc506e8b74cdaf24bdcce", size = 15260901, upload-time = "2026-04-22T20:36:00.853Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/313af9ee02ac0155247bcf3f04fcf54fcae2e33250bb437528c18aeefd81/botocore-1.42.94-py3-none-any.whl", hash = "sha256:a2143742132ed0f6cdb90204d667b89d0301068b1045e8bc099efa267bf1b348", size = 14942938, upload-time = "2026-04-22T20:35:55.663Z" },
]
[[package]]
name = "certifi"
version = "2026.4.22"
@@ -414,6 +442,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "loguru"
version = "0.7.3"
@@ -560,6 +597,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
]
[[package]]
name = "pathspec"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/17/9c3094b822982b9f1ea666d8580ce59000f61f87c1663556fb72031ad9ec/pathspec-1.1.0.tar.gz", hash = "sha256:f5d7c555da02fd8dde3e4a2354b6aba817a89112fa8f333f7917a2a4834dd080", size = 133918, upload-time = "2026-04-23T01:46:22.298Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264, upload-time = "2026-04-23T01:46:20.606Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
@@ -775,6 +821,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-discovery"
version = "1.2.2"
@@ -914,6 +972,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
]
[[package]]
name = "s3transfer"
version = "0.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/19/90d7d4ed51932c022d53f1d02d564b62d10e272692a1f9b76425c1ad2a02/s3transfer-0.16.1-py3-none-any.whl", hash = "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", size = 86825, upload-time = "2026-04-22T20:36:04.992Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.49"
@@ -995,8 +1074,10 @@ dependencies = [
{ name = "alembic" },
{ name = "apprise" },
{ name = "apscheduler" },
{ name = "boto3" },
{ name = "fastapi" },
{ name = "loguru" },
{ name = "pathspec" },
{ name = "prometheus-client" },
{ name = "pydantic-settings" },
{ name = "sqlalchemy" },
@@ -1016,8 +1097,10 @@ requires-dist = [
{ name = "alembic" },
{ name = "apprise" },
{ name = "apscheduler" },
{ name = "boto3", specifier = ">=1.42.94" },
{ name = "fastapi" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "pathspec", specifier = ">=1.1.0" },
{ name = "prometheus-client" },
{ name = "pydantic-settings", specifier = ">=2.14.0" },
{ name = "sqlalchemy" },
+1 -47
View File
@@ -76,56 +76,10 @@
background-color: var(--color-bg-primary);
}
nav {
width: 240px;
background-color: var(--color-bg-secondary);
border-right: 1px solid var(--color-border-color);
display: flex;
flex-direction: column;
padding: var(--spacing-lg);
flex-shrink: 0;
}
nav h2 {
font-size: 1.5rem;
margin-bottom: var(--spacing-xl);
font-weight: 700;
display: flex;
align-items: center;
gap: var(--spacing-sm);
color: var(--color-text-primary);
}
nav ul {
list-style: none;
padding: 0;
margin: 0;
}
nav ul li {
margin-bottom: var(--spacing-sm);
}
nav ul li a {
color: var(--color-text-secondary);
text-decoration: none;
font-weight: 500;
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
transition: all 0.2s ease;
}
nav ul li a:hover, nav ul li a.active {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
/* Removed legacy 'nav' styles that were causing sidebar layout issues */
main {
flex: 1;
padding: var(--spacing-xl);
overflow-y: auto;
}
+2 -2
View File
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts
export { browseIndexInventoryBrowseGet, browsePathSystemBrowseGet, getTreeSystemTreeGet, listBackupsBackupsGet, type Options, readRootGet, trackBatchSystemTrackBatchPost, trackPathSystemTrackPost } from './sdk.gen';
export type { AppApiInventoryFileItemSchema, AppApiSystemFileItemSchema, BatchTrackRequest, BrowseIndexInventoryBrowseGetData, BrowseIndexInventoryBrowseGetError, BrowseIndexInventoryBrowseGetErrors, BrowseIndexInventoryBrowseGetResponse, BrowseIndexInventoryBrowseGetResponses, BrowsePathSystemBrowseGetData, BrowsePathSystemBrowseGetError, BrowsePathSystemBrowseGetErrors, BrowsePathSystemBrowseGetResponse, BrowsePathSystemBrowseGetResponses, ClientOptions, GetTreeSystemTreeGetData, GetTreeSystemTreeGetError, GetTreeSystemTreeGetErrors, GetTreeSystemTreeGetResponse, GetTreeSystemTreeGetResponses, HttpValidationError, ListBackupsBackupsGetData, ListBackupsBackupsGetResponses, ReadRootGetData, ReadRootGetResponses, TrackBatchSystemTrackBatchPostData, TrackBatchSystemTrackBatchPostError, TrackBatchSystemTrackBatchPostErrors, TrackBatchSystemTrackBatchPostResponses, TrackPathSystemTrackPostData, TrackPathSystemTrackPostError, TrackPathSystemTrackPostErrors, TrackPathSystemTrackPostResponses, TrackToggleRequest, TreeNodeSchema, ValidationError } from './types.gen';
export { addDirectoryToCartRestoresCartDirectoryPost, addToCartRestoresCartFileIdPost, browseIndexInventoryBrowseGet, browsePathSystemBrowseGet, cancelJobSystemJobsJobIdCancelPost, clearCartRestoresCartClearPost, deleteMediaInventoryMediaMediaIdDelete, getDashboardStatsSystemDashboardStatsGet, getIndexTreeInventoryTreeGet, getItemMetadataInventoryMetadataGet, getManifestRestoresManifestGet, getScanStatusSystemScanStatusGet, getSettingsSystemSettingsGet, getTreeSystemTreeGet, listBackupsBackupsGet, listCartRestoresCartGet, listInventoryInventoryGet, listJobsSystemJobsGet, listMediaInventoryMediaGet, type Options, readRootGet, registerMediaInventoryMediaPost, removeFromCartRestoresCartItemIdDelete, streamJobsSystemJobsStreamGet, trackBatchSystemTrackBatchPost, triggerBackupBackupsTriggerMediaIdPost, triggerScanSystemScanPost, updateMediaInventoryMediaMediaIdPatch, updateSettingSystemSettingsPost } from './sdk.gen';
export type { AddDirectoryToCartRestoresCartDirectoryPostData, AddDirectoryToCartRestoresCartDirectoryPostError, AddDirectoryToCartRestoresCartDirectoryPostErrors, AddDirectoryToCartRestoresCartDirectoryPostResponses, AddToCartRestoresCartFileIdPostData, AddToCartRestoresCartFileIdPostError, AddToCartRestoresCartFileIdPostErrors, AddToCartRestoresCartFileIdPostResponses, AppApiInventoryFileItemSchema, AppApiSystemFileItemSchema, BatchTrackRequest, BrowseIndexInventoryBrowseGetData, BrowseIndexInventoryBrowseGetError, BrowseIndexInventoryBrowseGetErrors, BrowseIndexInventoryBrowseGetResponse, BrowseIndexInventoryBrowseGetResponses, BrowsePathSystemBrowseGetData, BrowsePathSystemBrowseGetError, BrowsePathSystemBrowseGetErrors, BrowsePathSystemBrowseGetResponse, BrowsePathSystemBrowseGetResponses, CancelJobSystemJobsJobIdCancelPostData, CancelJobSystemJobsJobIdCancelPostError, CancelJobSystemJobsJobIdCancelPostErrors, CancelJobSystemJobsJobIdCancelPostResponses, CartItemSchema, ClearCartRestoresCartClearPostData, ClearCartRestoresCartClearPostResponses, ClientOptions, DashboardStatsSchema, DeleteMediaInventoryMediaMediaIdDeleteData, DeleteMediaInventoryMediaMediaIdDeleteError, DeleteMediaInventoryMediaMediaIdDeleteErrors, DeleteMediaInventoryMediaMediaIdDeleteResponses, DirectoryCartRequest, FileVersionSchema, GetDashboardStatsSystemDashboardStatsGetData, GetDashboardStatsSystemDashboardStatsGetResponse, GetDashboardStatsSystemDashboardStatsGetResponses, GetIndexTreeInventoryTreeGetData, GetIndexTreeInventoryTreeGetError, GetIndexTreeInventoryTreeGetErrors, GetIndexTreeInventoryTreeGetResponse, GetIndexTreeInventoryTreeGetResponses, GetItemMetadataInventoryMetadataGetData, GetItemMetadataInventoryMetadataGetError, GetItemMetadataInventoryMetadataGetErrors, GetItemMetadataInventoryMetadataGetResponse, GetItemMetadataInventoryMetadataGetResponses, GetManifestRestoresManifestGetData, GetManifestRestoresManifestGetResponse, GetManifestRestoresManifestGetResponses, GetScanStatusSystemScanStatusGetData, GetScanStatusSystemScanStatusGetResponse, GetScanStatusSystemScanStatusGetResponses, GetSettingsSystemSettingsGetData, GetSettingsSystemSettingsGetResponse, GetSettingsSystemSettingsGetResponses, GetTreeSystemTreeGetData, GetTreeSystemTreeGetError, GetTreeSystemTreeGetErrors, GetTreeSystemTreeGetResponses, HttpValidationError, ItemMetadataSchema, JobSchema, ListBackupsBackupsGetData, ListBackupsBackupsGetResponses, ListCartRestoresCartGetData, ListCartRestoresCartGetResponse, ListCartRestoresCartGetResponses, ListInventoryInventoryGetData, ListInventoryInventoryGetResponses, ListJobsSystemJobsGetData, ListJobsSystemJobsGetError, ListJobsSystemJobsGetErrors, ListJobsSystemJobsGetResponse, ListJobsSystemJobsGetResponses, ListMediaInventoryMediaGetData, ListMediaInventoryMediaGetResponse, ListMediaInventoryMediaGetResponses, ManifestMediaRequirement, MediaCreateSchema, MediaSchema, MediaUpdateSchema, ReadRootGetData, ReadRootGetResponses, RegisterMediaInventoryMediaPostData, RegisterMediaInventoryMediaPostError, RegisterMediaInventoryMediaPostErrors, RegisterMediaInventoryMediaPostResponse, RegisterMediaInventoryMediaPostResponses, RemoveFromCartRestoresCartItemIdDeleteData, RemoveFromCartRestoresCartItemIdDeleteError, RemoveFromCartRestoresCartItemIdDeleteErrors, RemoveFromCartRestoresCartItemIdDeleteResponses, RestoreManifestSchema, ScanStatusSchema, SettingSchema, StreamJobsSystemJobsStreamGetData, StreamJobsSystemJobsStreamGetResponses, TrackBatchSystemTrackBatchPostData, TrackBatchSystemTrackBatchPostError, TrackBatchSystemTrackBatchPostErrors, TrackBatchSystemTrackBatchPostResponses, TreeNodeSchema, TriggerBackupBackupsTriggerMediaIdPostData, TriggerBackupBackupsTriggerMediaIdPostError, TriggerBackupBackupsTriggerMediaIdPostErrors, TriggerBackupBackupsTriggerMediaIdPostResponses, TriggerScanSystemScanPostData, TriggerScanSystemScanPostResponses, UpdateMediaInventoryMediaMediaIdPatchData, UpdateMediaInventoryMediaMediaIdPatchError, UpdateMediaInventoryMediaMediaIdPatchErrors, UpdateMediaInventoryMediaMediaIdPatchResponse, UpdateMediaInventoryMediaMediaIdPatchResponses, UpdateSettingSystemSettingsPostData, UpdateSettingSystemSettingsPostError, UpdateSettingSystemSettingsPostErrors, UpdateSettingSystemSettingsPostResponses, ValidationError } from './types.gen';
+139 -13
View File
@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseIndexInventoryBrowseGetData, BrowseIndexInventoryBrowseGetErrors, BrowseIndexInventoryBrowseGetResponses, BrowsePathSystemBrowseGetData, BrowsePathSystemBrowseGetErrors, BrowsePathSystemBrowseGetResponses, GetTreeSystemTreeGetData, GetTreeSystemTreeGetErrors, GetTreeSystemTreeGetResponses, ListBackupsBackupsGetData, ListBackupsBackupsGetResponses, ReadRootGetData, ReadRootGetResponses, TrackBatchSystemTrackBatchPostData, TrackBatchSystemTrackBatchPostErrors, TrackBatchSystemTrackBatchPostResponses, TrackPathSystemTrackPostData, TrackPathSystemTrackPostErrors, TrackPathSystemTrackPostResponses } from './types.gen';
import type { AddDirectoryToCartRestoresCartDirectoryPostData, AddDirectoryToCartRestoresCartDirectoryPostErrors, AddDirectoryToCartRestoresCartDirectoryPostResponses, AddToCartRestoresCartFileIdPostData, AddToCartRestoresCartFileIdPostErrors, AddToCartRestoresCartFileIdPostResponses, BrowseIndexInventoryBrowseGetData, BrowseIndexInventoryBrowseGetErrors, BrowseIndexInventoryBrowseGetResponses, BrowsePathSystemBrowseGetData, BrowsePathSystemBrowseGetErrors, BrowsePathSystemBrowseGetResponses, CancelJobSystemJobsJobIdCancelPostData, CancelJobSystemJobsJobIdCancelPostErrors, CancelJobSystemJobsJobIdCancelPostResponses, ClearCartRestoresCartClearPostData, ClearCartRestoresCartClearPostResponses, DeleteMediaInventoryMediaMediaIdDeleteData, DeleteMediaInventoryMediaMediaIdDeleteErrors, DeleteMediaInventoryMediaMediaIdDeleteResponses, GetDashboardStatsSystemDashboardStatsGetData, GetDashboardStatsSystemDashboardStatsGetResponses, GetIndexTreeInventoryTreeGetData, GetIndexTreeInventoryTreeGetErrors, GetIndexTreeInventoryTreeGetResponses, GetItemMetadataInventoryMetadataGetData, GetItemMetadataInventoryMetadataGetErrors, GetItemMetadataInventoryMetadataGetResponses, GetManifestRestoresManifestGetData, GetManifestRestoresManifestGetResponses, GetScanStatusSystemScanStatusGetData, GetScanStatusSystemScanStatusGetResponses, GetSettingsSystemSettingsGetData, GetSettingsSystemSettingsGetResponses, GetTreeSystemTreeGetData, GetTreeSystemTreeGetErrors, GetTreeSystemTreeGetResponses, ListBackupsBackupsGetData, ListBackupsBackupsGetResponses, ListCartRestoresCartGetData, ListCartRestoresCartGetResponses, ListInventoryInventoryGetData, ListInventoryInventoryGetResponses, ListJobsSystemJobsGetData, ListJobsSystemJobsGetErrors, ListJobsSystemJobsGetResponses, ListMediaInventoryMediaGetData, ListMediaInventoryMediaGetResponses, ReadRootGetData, ReadRootGetResponses, RegisterMediaInventoryMediaPostData, RegisterMediaInventoryMediaPostErrors, RegisterMediaInventoryMediaPostResponses, RemoveFromCartRestoresCartItemIdDeleteData, RemoveFromCartRestoresCartItemIdDeleteErrors, RemoveFromCartRestoresCartItemIdDeleteResponses, StreamJobsSystemJobsStreamGetData, StreamJobsSystemJobsStreamGetResponses, TrackBatchSystemTrackBatchPostData, TrackBatchSystemTrackBatchPostErrors, TrackBatchSystemTrackBatchPostResponses, TriggerBackupBackupsTriggerMediaIdPostData, TriggerBackupBackupsTriggerMediaIdPostErrors, TriggerBackupBackupsTriggerMediaIdPostResponses, TriggerScanSystemScanPostData, TriggerScanSystemScanPostResponses, UpdateMediaInventoryMediaMediaIdPatchData, UpdateMediaInventoryMediaMediaIdPatchErrors, UpdateMediaInventoryMediaMediaIdPatchResponses, UpdateSettingSystemSettingsPostData, UpdateSettingSystemSettingsPostErrors, UpdateSettingSystemSettingsPostResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
@@ -18,23 +18,41 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
meta?: Record<string, unknown>;
};
/**
* Get Dashboard Stats
*/
export const getDashboardStatsSystemDashboardStatsGet = <ThrowOnError extends boolean = false>(options?: Options<GetDashboardStatsSystemDashboardStatsGetData, ThrowOnError>) => (options?.client ?? client).get<GetDashboardStatsSystemDashboardStatsGetResponses, unknown, ThrowOnError>({ url: '/system/dashboard/stats', ...options });
/**
* List Jobs
*/
export const listJobsSystemJobsGet = <ThrowOnError extends boolean = false>(options?: Options<ListJobsSystemJobsGetData, ThrowOnError>) => (options?.client ?? client).get<ListJobsSystemJobsGetResponses, ListJobsSystemJobsGetErrors, ThrowOnError>({ url: '/system/jobs', ...options });
/**
* Cancel Job
*/
export const cancelJobSystemJobsJobIdCancelPost = <ThrowOnError extends boolean = false>(options: Options<CancelJobSystemJobsJobIdCancelPostData, ThrowOnError>) => (options.client ?? client).post<CancelJobSystemJobsJobIdCancelPostResponses, CancelJobSystemJobsJobIdCancelPostErrors, ThrowOnError>({ url: '/system/jobs/{job_id}/cancel', ...options });
/**
* Stream Jobs
*/
export const streamJobsSystemJobsStreamGet = <ThrowOnError extends boolean = false>(options?: Options<StreamJobsSystemJobsStreamGetData, ThrowOnError>) => (options?.client ?? client).get<StreamJobsSystemJobsStreamGetResponses, unknown, ThrowOnError>({ url: '/system/jobs/stream', ...options });
/**
* Trigger Scan
*/
export const triggerScanSystemScanPost = <ThrowOnError extends boolean = false>(options?: Options<TriggerScanSystemScanPostData, ThrowOnError>) => (options?.client ?? client).post<TriggerScanSystemScanPostResponses, unknown, ThrowOnError>({ url: '/system/scan', ...options });
/**
* Get Scan Status
*/
export const getScanStatusSystemScanStatusGet = <ThrowOnError extends boolean = false>(options?: Options<GetScanStatusSystemScanStatusGetData, ThrowOnError>) => (options?.client ?? client).get<GetScanStatusSystemScanStatusGetResponses, unknown, ThrowOnError>({ url: '/system/scan/status', ...options });
/**
* Browse Path
*/
export const browsePathSystemBrowseGet = <ThrowOnError extends boolean = false>(options?: Options<BrowsePathSystemBrowseGetData, ThrowOnError>) => (options?.client ?? client).get<BrowsePathSystemBrowseGetResponses, BrowsePathSystemBrowseGetErrors, ThrowOnError>({ url: '/system/browse', ...options });
/**
* Track Path
*/
export const trackPathSystemTrackPost = <ThrowOnError extends boolean = false>(options: Options<TrackPathSystemTrackPostData, ThrowOnError>) => (options.client ?? client).post<TrackPathSystemTrackPostResponses, TrackPathSystemTrackPostErrors, ThrowOnError>({
url: '/system/track',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Track Batch
*/
@@ -47,21 +65,129 @@ export const trackBatchSystemTrackBatchPost = <ThrowOnError extends boolean = fa
}
});
/**
* Get Settings
*/
export const getSettingsSystemSettingsGet = <ThrowOnError extends boolean = false>(options?: Options<GetSettingsSystemSettingsGetData, ThrowOnError>) => (options?.client ?? client).get<GetSettingsSystemSettingsGetResponses, unknown, ThrowOnError>({ url: '/system/settings', ...options });
/**
* Update Setting
*/
export const updateSettingSystemSettingsPost = <ThrowOnError extends boolean = false>(options: Options<UpdateSettingSystemSettingsPostData, ThrowOnError>) => (options.client ?? client).post<UpdateSettingSystemSettingsPostResponses, UpdateSettingSystemSettingsPostErrors, ThrowOnError>({
url: '/system/settings',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Get Tree
*/
export const getTreeSystemTreeGet = <ThrowOnError extends boolean = false>(options?: Options<GetTreeSystemTreeGetData, ThrowOnError>) => (options?.client ?? client).get<GetTreeSystemTreeGetResponses, GetTreeSystemTreeGetErrors, ThrowOnError>({ url: '/system/tree', ...options });
/**
* List Media
*/
export const listMediaInventoryMediaGet = <ThrowOnError extends boolean = false>(options?: Options<ListMediaInventoryMediaGetData, ThrowOnError>) => (options?.client ?? client).get<ListMediaInventoryMediaGetResponses, unknown, ThrowOnError>({ url: '/inventory/media', ...options });
/**
* Register Media
*/
export const registerMediaInventoryMediaPost = <ThrowOnError extends boolean = false>(options: Options<RegisterMediaInventoryMediaPostData, ThrowOnError>) => (options.client ?? client).post<RegisterMediaInventoryMediaPostResponses, RegisterMediaInventoryMediaPostErrors, ThrowOnError>({
url: '/inventory/media',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Delete Media
*/
export const deleteMediaInventoryMediaMediaIdDelete = <ThrowOnError extends boolean = false>(options: Options<DeleteMediaInventoryMediaMediaIdDeleteData, ThrowOnError>) => (options.client ?? client).delete<DeleteMediaInventoryMediaMediaIdDeleteResponses, DeleteMediaInventoryMediaMediaIdDeleteErrors, ThrowOnError>({ url: '/inventory/media/{media_id}', ...options });
/**
* Update Media
*/
export const updateMediaInventoryMediaMediaIdPatch = <ThrowOnError extends boolean = false>(options: Options<UpdateMediaInventoryMediaMediaIdPatchData, ThrowOnError>) => (options.client ?? client).patch<UpdateMediaInventoryMediaMediaIdPatchResponses, UpdateMediaInventoryMediaMediaIdPatchErrors, ThrowOnError>({
url: '/inventory/media/{media_id}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Browse Index
*/
export const browseIndexInventoryBrowseGet = <ThrowOnError extends boolean = false>(options?: Options<BrowseIndexInventoryBrowseGetData, ThrowOnError>) => (options?.client ?? client).get<BrowseIndexInventoryBrowseGetResponses, BrowseIndexInventoryBrowseGetErrors, ThrowOnError>({ url: '/inventory/browse', ...options });
/**
* Get Index Tree
*/
export const getIndexTreeInventoryTreeGet = <ThrowOnError extends boolean = false>(options?: Options<GetIndexTreeInventoryTreeGetData, ThrowOnError>) => (options?.client ?? client).get<GetIndexTreeInventoryTreeGetResponses, GetIndexTreeInventoryTreeGetErrors, ThrowOnError>({ url: '/inventory/tree', ...options });
/**
* Get Item Metadata
*/
export const getItemMetadataInventoryMetadataGet = <ThrowOnError extends boolean = false>(options: Options<GetItemMetadataInventoryMetadataGetData, ThrowOnError>) => (options.client ?? client).get<GetItemMetadataInventoryMetadataGetResponses, GetItemMetadataInventoryMetadataGetErrors, ThrowOnError>({ url: '/inventory/metadata', ...options });
/**
* List Inventory
*/
export const listInventoryInventoryGet = <ThrowOnError extends boolean = false>(options?: Options<ListInventoryInventoryGetData, ThrowOnError>) => (options?.client ?? client).get<ListInventoryInventoryGetResponses, unknown, ThrowOnError>({ url: '/inventory/', ...options });
/**
* Trigger Backup
*/
export const triggerBackupBackupsTriggerMediaIdPost = <ThrowOnError extends boolean = false>(options: Options<TriggerBackupBackupsTriggerMediaIdPostData, ThrowOnError>) => (options.client ?? client).post<TriggerBackupBackupsTriggerMediaIdPostResponses, TriggerBackupBackupsTriggerMediaIdPostErrors, ThrowOnError>({ url: '/backups/trigger/{media_id}', ...options });
/**
* List Backups
*/
export const listBackupsBackupsGet = <ThrowOnError extends boolean = false>(options?: Options<ListBackupsBackupsGetData, ThrowOnError>) => (options?.client ?? client).get<ListBackupsBackupsGetResponses, unknown, ThrowOnError>({ url: '/backups/', ...options });
/**
* List Cart
*/
export const listCartRestoresCartGet = <ThrowOnError extends boolean = false>(options?: Options<ListCartRestoresCartGetData, ThrowOnError>) => (options?.client ?? client).get<ListCartRestoresCartGetResponses, unknown, ThrowOnError>({ url: '/restores/cart', ...options });
/**
* Add To Cart
*/
export const addToCartRestoresCartFileIdPost = <ThrowOnError extends boolean = false>(options: Options<AddToCartRestoresCartFileIdPostData, ThrowOnError>) => (options.client ?? client).post<AddToCartRestoresCartFileIdPostResponses, AddToCartRestoresCartFileIdPostErrors, ThrowOnError>({ url: '/restores/cart/{file_id}', ...options });
/**
* Add Directory To Cart
*/
export const addDirectoryToCartRestoresCartDirectoryPost = <ThrowOnError extends boolean = false>(options: Options<AddDirectoryToCartRestoresCartDirectoryPostData, ThrowOnError>) => (options.client ?? client).post<AddDirectoryToCartRestoresCartDirectoryPostResponses, AddDirectoryToCartRestoresCartDirectoryPostErrors, ThrowOnError>({
url: '/restores/cart/directory',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Remove From Cart
*/
export const removeFromCartRestoresCartItemIdDelete = <ThrowOnError extends boolean = false>(options: Options<RemoveFromCartRestoresCartItemIdDeleteData, ThrowOnError>) => (options.client ?? client).delete<RemoveFromCartRestoresCartItemIdDeleteResponses, RemoveFromCartRestoresCartItemIdDeleteErrors, ThrowOnError>({ url: '/restores/cart/{item_id}', ...options });
/**
* Clear Cart
*/
export const clearCartRestoresCartClearPost = <ThrowOnError extends boolean = false>(options?: Options<ClearCartRestoresCartClearPostData, ThrowOnError>) => (options?.client ?? client).post<ClearCartRestoresCartClearPostResponses, unknown, ThrowOnError>({ url: '/restores/cart/clear', ...options });
/**
* Get Manifest
*/
export const getManifestRestoresManifestGet = <ThrowOnError extends boolean = false>(options?: Options<GetManifestRestoresManifestGetData, ThrowOnError>) => (options?.client ?? client).get<GetManifestRestoresManifestGetResponses, unknown, ThrowOnError>({ url: '/restores/manifest', ...options });
/**
* Read Root
*/
+867 -35
View File
@@ -18,6 +18,108 @@ export type BatchTrackRequest = {
untracks?: Array<string>;
};
/**
* CartItemSchema
*/
export type CartItemSchema = {
/**
* Id
*/
id: number;
/**
* File Path
*/
file_path: string;
/**
* Size
*/
size: number;
/**
* Media Identifiers
*/
media_identifiers: Array<string>;
};
/**
* DashboardStatsSchema
*/
export type DashboardStatsSchema = {
/**
* Total Files Indexed
*/
total_files_indexed: number;
/**
* Total Data Size
*/
total_data_size: number;
/**
* Tracked Paths Count
*/
tracked_paths_count: number;
/**
* Unprotected Files Count
*/
unprotected_files_count: number;
/**
* Unprotected Data Size
*/
unprotected_data_size: number;
/**
* Ignored Files Count
*/
ignored_files_count: number;
/**
* Ignored Data Size
*/
ignored_data_size: number;
/**
* Redundancy Ratio
*/
redundancy_ratio: number;
/**
* Last Scan Time
*/
last_scan_time: string | null;
/**
* Media Distribution
*/
media_distribution: {
[key: string]: number;
};
};
/**
* DirectoryCartRequest
*/
export type DirectoryCartRequest = {
/**
* Path
*/
path: string;
};
/**
* FileVersionSchema
*/
export type FileVersionSchema = {
/**
* Media Identifier
*/
media_identifier: string;
/**
* Media Type
*/
media_type: string;
/**
* File Number
*/
file_number: string;
/**
* Timestamp
*/
timestamp: string;
};
/**
* HTTPValidationError
*/
@@ -29,17 +131,267 @@ export type HttpValidationError = {
};
/**
* TrackToggleRequest
* ItemMetadataSchema
*/
export type TrackToggleRequest = {
export type ItemMetadataSchema = {
/**
* Path
* Id
*/
path: string;
id?: number | null;
/**
* Is Directory
* File Path
*/
is_directory?: boolean;
file_path: string;
/**
* Type
*/
type: string;
/**
* Size
*/
size: number;
/**
* Mtime
*/
mtime: number;
/**
* Last Seen Timestamp
*/
last_seen_timestamp: string;
/**
* Sha256 Hash
*/
sha256_hash?: string | null;
/**
* Versions
*/
versions?: Array<FileVersionSchema>;
/**
* Child Count
*/
child_count?: number | null;
};
/**
* JobSchema
*/
export type JobSchema = {
/**
* Id
*/
id: number;
/**
* Job Type
*/
job_type: string;
/**
* Status
*/
status: string;
/**
* Progress
*/
progress: number;
/**
* Current Task
*/
current_task: string | null;
/**
* Error Message
*/
error_message: string | null;
/**
* Started At
*/
started_at: string | null;
/**
* Completed At
*/
completed_at: string | null;
/**
* Created At
*/
created_at: string;
};
/**
* ManifestMediaRequirement
*/
export type ManifestMediaRequirement = {
/**
* Identifier
*/
identifier: string;
/**
* Media Type
*/
media_type: string;
/**
* File Count
*/
file_count: number;
/**
* Total Size
*/
total_size: number;
};
/**
* MediaCreateSchema
*/
export type MediaCreateSchema = {
/**
* Media Type
*/
media_type: string;
/**
* Identifier
*/
identifier: string;
/**
* Generation Tier
*/
generation_tier?: string | null;
/**
* Capacity
*/
capacity: number;
/**
* Location
*/
location?: string | null;
/**
* Config
*/
config?: {
[key: string]: unknown;
};
};
/**
* MediaSchema
*/
export type MediaSchema = {
/**
* Id
*/
id: number;
/**
* Media Type
*/
media_type: string;
/**
* Identifier
*/
identifier: string;
/**
* Generation Tier
*/
generation_tier: string | null;
/**
* Capacity
*/
capacity: number;
/**
* Bytes Used
*/
bytes_used: number;
/**
* Location
*/
location: string | null;
/**
* Status
*/
status: string;
/**
* Config
*/
config: {
[key: string]: unknown;
};
};
/**
* MediaUpdateSchema
*/
export type MediaUpdateSchema = {
/**
* Status
*/
status?: string | null;
/**
* Location
*/
location?: string | null;
/**
* Config
*/
config?: {
[key: string]: unknown;
} | null;
};
/**
* RestoreManifestSchema
*/
export type RestoreManifestSchema = {
/**
* Total Files
*/
total_files: number;
/**
* Total Size
*/
total_size: number;
/**
* Media Required
*/
media_required: Array<ManifestMediaRequirement>;
};
/**
* ScanStatusSchema
*/
export type ScanStatusSchema = {
/**
* Is Running
*/
is_running: boolean;
/**
* Files Processed
*/
files_processed: number;
/**
* Files Hashed
*/
files_hashed: number;
/**
* Total Files Found
*/
total_files_found: number;
/**
* Current Path
*/
current_path: string;
/**
* Last Run Time
*/
last_run_time?: string | null;
};
/**
* SettingSchema
*/
export type SettingSchema = {
/**
* Key
*/
key: string;
/**
* Value
*/
value: string;
};
/**
@@ -146,8 +498,132 @@ export type AppApiSystemFileItemSchema = {
* Tracked
*/
tracked?: boolean;
/**
* Ignored
*/
ignored?: boolean;
};
export type GetDashboardStatsSystemDashboardStatsGetData = {
body?: never;
path?: never;
query?: never;
url: '/system/dashboard/stats';
};
export type GetDashboardStatsSystemDashboardStatsGetResponses = {
/**
* Successful Response
*/
200: DashboardStatsSchema;
};
export type GetDashboardStatsSystemDashboardStatsGetResponse = GetDashboardStatsSystemDashboardStatsGetResponses[keyof GetDashboardStatsSystemDashboardStatsGetResponses];
export type ListJobsSystemJobsGetData = {
body?: never;
path?: never;
query?: {
/**
* Limit
*/
limit?: number;
};
url: '/system/jobs';
};
export type ListJobsSystemJobsGetErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ListJobsSystemJobsGetError = ListJobsSystemJobsGetErrors[keyof ListJobsSystemJobsGetErrors];
export type ListJobsSystemJobsGetResponses = {
/**
* Response List Jobs System Jobs Get
*
* Successful Response
*/
200: Array<JobSchema>;
};
export type ListJobsSystemJobsGetResponse = ListJobsSystemJobsGetResponses[keyof ListJobsSystemJobsGetResponses];
export type CancelJobSystemJobsJobIdCancelPostData = {
body?: never;
path: {
/**
* Job Id
*/
job_id: number;
};
query?: never;
url: '/system/jobs/{job_id}/cancel';
};
export type CancelJobSystemJobsJobIdCancelPostErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CancelJobSystemJobsJobIdCancelPostError = CancelJobSystemJobsJobIdCancelPostErrors[keyof CancelJobSystemJobsJobIdCancelPostErrors];
export type CancelJobSystemJobsJobIdCancelPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type StreamJobsSystemJobsStreamGetData = {
body?: never;
path?: never;
query?: never;
url: '/system/jobs/stream';
};
export type StreamJobsSystemJobsStreamGetResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type TriggerScanSystemScanPostData = {
body?: never;
path?: never;
query?: never;
url: '/system/scan';
};
export type TriggerScanSystemScanPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type GetScanStatusSystemScanStatusGetData = {
body?: never;
path?: never;
query?: never;
url: '/system/scan/status';
};
export type GetScanStatusSystemScanStatusGetResponses = {
/**
* Successful Response
*/
200: ScanStatusSchema;
};
export type GetScanStatusSystemScanStatusGetResponse = GetScanStatusSystemScanStatusGetResponses[keyof GetScanStatusSystemScanStatusGetResponses];
export type BrowsePathSystemBrowseGetData = {
body?: never;
path?: never;
@@ -155,7 +631,7 @@ export type BrowsePathSystemBrowseGetData = {
/**
* Path
*/
path?: string;
path?: string | null;
};
url: '/system/browse';
};
@@ -180,29 +656,6 @@ export type BrowsePathSystemBrowseGetResponses = {
export type BrowsePathSystemBrowseGetResponse = BrowsePathSystemBrowseGetResponses[keyof BrowsePathSystemBrowseGetResponses];
export type TrackPathSystemTrackPostData = {
body: TrackToggleRequest;
path?: never;
query?: never;
url: '/system/track';
};
export type TrackPathSystemTrackPostErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type TrackPathSystemTrackPostError = TrackPathSystemTrackPostErrors[keyof TrackPathSystemTrackPostErrors];
export type TrackPathSystemTrackPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type TrackBatchSystemTrackBatchPostData = {
body: BatchTrackRequest;
path?: never;
@@ -226,6 +679,49 @@ export type TrackBatchSystemTrackBatchPostResponses = {
200: unknown;
};
export type GetSettingsSystemSettingsGetData = {
body?: never;
path?: never;
query?: never;
url: '/system/settings';
};
export type GetSettingsSystemSettingsGetResponses = {
/**
* Response Get Settings System Settings Get
*
* Successful Response
*/
200: {
[key: string]: string;
};
};
export type GetSettingsSystemSettingsGetResponse = GetSettingsSystemSettingsGetResponses[keyof GetSettingsSystemSettingsGetResponses];
export type UpdateSettingSystemSettingsPostData = {
body: SettingSchema;
path?: never;
query?: never;
url: '/system/settings';
};
export type UpdateSettingSystemSettingsPostErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateSettingSystemSettingsPostError = UpdateSettingSystemSettingsPostErrors[keyof UpdateSettingSystemSettingsPostErrors];
export type UpdateSettingSystemSettingsPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type GetTreeSystemTreeGetData = {
body?: never;
path?: never;
@@ -233,7 +729,7 @@ export type GetTreeSystemTreeGetData = {
/**
* Path
*/
path?: string;
path?: string | null;
};
url: '/system/tree';
};
@@ -249,14 +745,111 @@ export type GetTreeSystemTreeGetError = GetTreeSystemTreeGetErrors[keyof GetTree
export type GetTreeSystemTreeGetResponses = {
/**
* Response Get Tree System Tree Get
* Successful Response
*/
200: unknown;
};
export type ListMediaInventoryMediaGetData = {
body?: never;
path?: never;
query?: never;
url: '/inventory/media';
};
export type ListMediaInventoryMediaGetResponses = {
/**
* Response List Media Inventory Media Get
*
* Successful Response
*/
200: Array<TreeNodeSchema>;
200: Array<MediaSchema>;
};
export type GetTreeSystemTreeGetResponse = GetTreeSystemTreeGetResponses[keyof GetTreeSystemTreeGetResponses];
export type ListMediaInventoryMediaGetResponse = ListMediaInventoryMediaGetResponses[keyof ListMediaInventoryMediaGetResponses];
export type RegisterMediaInventoryMediaPostData = {
body: MediaCreateSchema;
path?: never;
query?: never;
url: '/inventory/media';
};
export type RegisterMediaInventoryMediaPostErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RegisterMediaInventoryMediaPostError = RegisterMediaInventoryMediaPostErrors[keyof RegisterMediaInventoryMediaPostErrors];
export type RegisterMediaInventoryMediaPostResponses = {
/**
* Successful Response
*/
200: MediaSchema;
};
export type RegisterMediaInventoryMediaPostResponse = RegisterMediaInventoryMediaPostResponses[keyof RegisterMediaInventoryMediaPostResponses];
export type DeleteMediaInventoryMediaMediaIdDeleteData = {
body?: never;
path: {
/**
* Media Id
*/
media_id: number;
};
query?: never;
url: '/inventory/media/{media_id}';
};
export type DeleteMediaInventoryMediaMediaIdDeleteErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DeleteMediaInventoryMediaMediaIdDeleteError = DeleteMediaInventoryMediaMediaIdDeleteErrors[keyof DeleteMediaInventoryMediaMediaIdDeleteErrors];
export type DeleteMediaInventoryMediaMediaIdDeleteResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type UpdateMediaInventoryMediaMediaIdPatchData = {
body: MediaUpdateSchema;
path: {
/**
* Media Id
*/
media_id: number;
};
query?: never;
url: '/inventory/media/{media_id}';
};
export type UpdateMediaInventoryMediaMediaIdPatchErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateMediaInventoryMediaMediaIdPatchError = UpdateMediaInventoryMediaMediaIdPatchErrors[keyof UpdateMediaInventoryMediaMediaIdPatchErrors];
export type UpdateMediaInventoryMediaMediaIdPatchResponses = {
/**
* Successful Response
*/
200: MediaSchema;
};
export type UpdateMediaInventoryMediaMediaIdPatchResponse = UpdateMediaInventoryMediaMediaIdPatchResponses[keyof UpdateMediaInventoryMediaMediaIdPatchResponses];
export type BrowseIndexInventoryBrowseGetData = {
body?: never;
@@ -265,7 +858,11 @@ export type BrowseIndexInventoryBrowseGetData = {
/**
* Path
*/
path?: string;
path?: string | null;
/**
* Include Ignored
*/
include_ignored?: boolean;
};
url: '/inventory/browse';
};
@@ -290,6 +887,114 @@ export type BrowseIndexInventoryBrowseGetResponses = {
export type BrowseIndexInventoryBrowseGetResponse = BrowseIndexInventoryBrowseGetResponses[keyof BrowseIndexInventoryBrowseGetResponses];
export type GetIndexTreeInventoryTreeGetData = {
body?: never;
path?: never;
query?: {
/**
* Path
*/
path?: string | null;
/**
* Include Ignored
*/
include_ignored?: boolean;
};
url: '/inventory/tree';
};
export type GetIndexTreeInventoryTreeGetErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetIndexTreeInventoryTreeGetError = GetIndexTreeInventoryTreeGetErrors[keyof GetIndexTreeInventoryTreeGetErrors];
export type GetIndexTreeInventoryTreeGetResponses = {
/**
* Response Get Index Tree Inventory Tree Get
*
* Successful Response
*/
200: Array<TreeNodeSchema>;
};
export type GetIndexTreeInventoryTreeGetResponse = GetIndexTreeInventoryTreeGetResponses[keyof GetIndexTreeInventoryTreeGetResponses];
export type GetItemMetadataInventoryMetadataGetData = {
body?: never;
path?: never;
query: {
/**
* Path
*/
path: string;
};
url: '/inventory/metadata';
};
export type GetItemMetadataInventoryMetadataGetErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetItemMetadataInventoryMetadataGetError = GetItemMetadataInventoryMetadataGetErrors[keyof GetItemMetadataInventoryMetadataGetErrors];
export type GetItemMetadataInventoryMetadataGetResponses = {
/**
* Successful Response
*/
200: ItemMetadataSchema;
};
export type GetItemMetadataInventoryMetadataGetResponse = GetItemMetadataInventoryMetadataGetResponses[keyof GetItemMetadataInventoryMetadataGetResponses];
export type ListInventoryInventoryGetData = {
body?: never;
path?: never;
query?: never;
url: '/inventory/';
};
export type ListInventoryInventoryGetResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type TriggerBackupBackupsTriggerMediaIdPostData = {
body?: never;
path: {
/**
* Media Id
*/
media_id: number;
};
query?: never;
url: '/backups/trigger/{media_id}';
};
export type TriggerBackupBackupsTriggerMediaIdPostErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type TriggerBackupBackupsTriggerMediaIdPostError = TriggerBackupBackupsTriggerMediaIdPostErrors[keyof TriggerBackupBackupsTriggerMediaIdPostErrors];
export type TriggerBackupBackupsTriggerMediaIdPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type ListBackupsBackupsGetData = {
body?: never;
path?: never;
@@ -304,6 +1009,133 @@ export type ListBackupsBackupsGetResponses = {
200: unknown;
};
export type ListCartRestoresCartGetData = {
body?: never;
path?: never;
query?: never;
url: '/restores/cart';
};
export type ListCartRestoresCartGetResponses = {
/**
* Response List Cart Restores Cart Get
*
* Successful Response
*/
200: Array<CartItemSchema>;
};
export type ListCartRestoresCartGetResponse = ListCartRestoresCartGetResponses[keyof ListCartRestoresCartGetResponses];
export type AddToCartRestoresCartFileIdPostData = {
body?: never;
path: {
/**
* File Id
*/
file_id: number;
};
query?: never;
url: '/restores/cart/{file_id}';
};
export type AddToCartRestoresCartFileIdPostErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type AddToCartRestoresCartFileIdPostError = AddToCartRestoresCartFileIdPostErrors[keyof AddToCartRestoresCartFileIdPostErrors];
export type AddToCartRestoresCartFileIdPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type AddDirectoryToCartRestoresCartDirectoryPostData = {
body: DirectoryCartRequest;
path?: never;
query?: never;
url: '/restores/cart/directory';
};
export type AddDirectoryToCartRestoresCartDirectoryPostErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type AddDirectoryToCartRestoresCartDirectoryPostError = AddDirectoryToCartRestoresCartDirectoryPostErrors[keyof AddDirectoryToCartRestoresCartDirectoryPostErrors];
export type AddDirectoryToCartRestoresCartDirectoryPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type RemoveFromCartRestoresCartItemIdDeleteData = {
body?: never;
path: {
/**
* Item Id
*/
item_id: number;
};
query?: never;
url: '/restores/cart/{item_id}';
};
export type RemoveFromCartRestoresCartItemIdDeleteErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RemoveFromCartRestoresCartItemIdDeleteError = RemoveFromCartRestoresCartItemIdDeleteErrors[keyof RemoveFromCartRestoresCartItemIdDeleteErrors];
export type RemoveFromCartRestoresCartItemIdDeleteResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type ClearCartRestoresCartClearPostData = {
body?: never;
path?: never;
query?: never;
url: '/restores/cart/clear';
};
export type ClearCartRestoresCartClearPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type GetManifestRestoresManifestGetData = {
body?: never;
path?: never;
query?: never;
url: '/restores/manifest';
};
export type GetManifestRestoresManifestGetResponses = {
/**
* Successful Response
*/
200: RestoreManifestSchema;
};
export type GetManifestRestoresManifestGetResponse = GetManifestRestoresManifestGetResponses[keyof GetManifestRestoresManifestGetResponses];
export type ReadRootGetData = {
body?: never;
path?: never;
@@ -0,0 +1,88 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { RotateCw, Activity } from 'lucide-svelte';
import { getScanStatusSystemScanStatusGet, type ScanStatusSchema } from '$lib/api';
import { toast } from 'svelte-sonner';
let scanStatus = $state<ScanStatusSchema | null>(null);
let pollInterval: any;
async function updateScanStatus() {
try {
const response = await getScanStatusSystemScanStatusGet();
if (response.data) {
const wasRunning = scanStatus?.is_running;
scanStatus = response.data;
if (wasRunning && !scanStatus.is_running) {
toast.success("Filesystem scan completed");
}
}
} catch (error) {
console.error("Failed to get scan status:", error);
}
}
onMount(() => {
updateScanStatus();
pollInterval = setInterval(updateScanStatus, 2000);
});
onDestroy(() => {
if (pollInterval) clearInterval(pollInterval);
});
const scanProgress = $derived(
scanStatus?.total_files_found
? Math.round((scanStatus.files_processed / scanStatus.total_files_found) * 100)
: 0
);
</script>
{#if scanStatus?.is_running}
<div class="fixed bottom-8 right-8 z-[100] bg-bg-secondary border border-blue-500/30 rounded-xl p-6 shadow-[0_25px_60px_rgba(0,0,0,0.6)] w-[450px] animate-in fade-in slide-in-from-bottom-8 border-l-4 border-l-blue-500 overflow-hidden group">
<div class="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-transparent pointer-events-none"></div>
<div class="relative z-10">
<div class="flex items-center gap-4 mb-6">
<div class="p-3 bg-blue-500/10 rounded-xl border border-blue-500/20 group-hover:scale-110 transition-transform duration-500">
<RotateCw size={24} class="animate-spin text-blue-500" />
</div>
<div class="flex-1">
<div class="flex justify-between items-center mb-1">
<span class="text-xs font-black uppercase tracking-widest text-text-primary">System Scanner Active</span>
<span class="text-sm font-black mono text-blue-400">{scanProgress}%</span>
</div>
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse"></div>
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary opacity-60">Optimizing Unified Index</p>
</div>
</div>
</div>
<div class="space-y-4">
<div class="w-full bg-bg-primary h-2.5 rounded-full overflow-hidden shadow-inner border border-white/5">
<div class="bg-gradient-to-r from-blue-600 to-blue-400 h-full transition-all duration-1000 shadow-[0_0_15px_rgba(59,130,246,0.4)]" style="width: {scanProgress}%"></div>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center text-[10px] font-black uppercase tracking-widest text-text-secondary">
<span class="flex items-center gap-2">
<Activity size={12} class="opacity-50" />
Throughput
</span>
<span class="mono text-text-primary">
{scanStatus.files_processed.toLocaleString()} <span class="opacity-40">/</span> {scanStatus.total_files_found.toLocaleString()} ITEMS
</span>
</div>
<div class="bg-bg-primary/80 px-4 py-2.5 rounded-lg border border-white/5 shadow-inner">
<p class="text-[10px] text-blue-300/80 truncate mono italic leading-relaxed">
{scanStatus.current_path || 'Initializing crawler...'}
</p>
</div>
</div>
</div>
</div>
</div>
{/if}
@@ -6,20 +6,8 @@
RotateCw,
Search,
Folder,
Home,
ArrowUpDown,
MoreHorizontal,
Plus,
Scissors,
Copy,
Clipboard,
Trash2,
Type,
LayoutGrid,
CheckSquare,
HardDrive,
ShieldCheck,
ShieldAlert,
Square
} from "lucide-svelte";
import { Button } from "$lib/components/ui/button";
@@ -28,20 +16,22 @@
import { Input } from "$lib/components/ui/input";
import FileBrowserTreeItem from "./FileBrowserTreeItem.svelte";
import FileBrowserRowItem from "./FileBrowserRowItem.svelte";
import type { FileItem, Breadcrumb, TreeNode } from "$lib/types";
import type { FileItem, Breadcrumb } from "$lib/types";
import { cn } from "$lib/utils";
let {
currentPath = $bindable("/source_data"),
currentPath = $bindable("ROOT"),
files = [],
onNavigate = (path: string) => {},
onToggleTrack = (item: FileItem) => {},
onSelect = (item: FileItem) => {},
mode = "host"
} = $props<{
currentPath: string;
files: FileItem[];
onNavigate?: (path: string) => void;
onToggleTrack?: (item: FileItem) => void;
onSelect?: (item: FileItem) => void;
mode?: "host" | "index";
}>();
@@ -97,16 +87,16 @@
// --- Navigation Tree Definition ---
const sourceDataRoot = $derived({
name: "Source Data",
path: "/source_data",
name: "All Sources",
path: "ROOT",
expanded: true,
children: [],
hasChildren: true
});
const virtualIndexRoot = $derived({
name: "Virtual Index",
path: "/",
name: "Index Browser",
path: "ROOT",
expanded: true,
children: [],
hasChildren: true
@@ -117,37 +107,32 @@
// --- Logic ---
const breadcrumbs = $derived.by(() => {
if (currentPath === "ROOT") {
return [{ name: mode === "host" ? "All Sources" : "Index Browser", path: "ROOT" }];
}
const parts = currentPath.split("/").filter(Boolean);
const crumbs: Breadcrumb[] = [];
if (mode === "host") {
crumbs.push({ name: "Source Data", path: "/source_data" });
let current = "/source_data";
const subParts = parts[0] === "source_data" ? parts.slice(1) : parts;
for (const part of subParts) {
current += `/${part}`;
crumbs.push({ name: part, path: current });
}
} else {
crumbs.push({ name: "Virtual Index", path: "/" });
let current = "";
for (const part of parts) {
current += `/${part}`;
crumbs.push({ name: part, path: current });
}
crumbs.push({ name: mode === "host" ? "All Sources" : "Index Browser", path: "ROOT" });
let current = "";
for (const part of parts) {
current += `/${part}`;
crumbs.push({ name: part, path: current });
}
return crumbs;
});
const filteredFiles = $derived.by(() => {
let result = files.filter((f) => f.name.toLowerCase().includes(searchQuery.toLowerCase()));
let result = files.filter((f: FileItem) => f.name.toLowerCase().includes(searchQuery.toLowerCase()));
result.sort((a, b) => {
result.sort((a: FileItem, b: FileItem) => {
const valA = sortColumn === "type" ? a.type : a[sortColumn as keyof FileItem] || 0;
const valB = sortColumn === "type" ? b.type : b[sortColumn as keyof FileItem] || 0;
if (valA < valB) return sortDirection === "asc" ? -1 : 1;
if (valA > valB) return sortDirection === "asc" ? 1 : -1;
if (valA < (valB as any)) return sortDirection === "asc" ? -1 : 1;
if (valA > (valB as any)) return sortDirection === "asc" ? 1 : -1;
return 0;
});
@@ -165,8 +150,8 @@
function handleRowClick(e: MouseEvent, item: FileItem) {
if (e.shiftKey && lastSelectedPath) {
const lastIndex = filteredFiles.findIndex(f => f.path === lastSelectedPath);
const currentIndex = filteredFiles.findIndex(f => f.path === item.path);
const lastIndex = filteredFiles.findIndex((f: FileItem) => f.path === lastSelectedPath);
const currentIndex = filteredFiles.findIndex((f: FileItem) => f.path === item.path);
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
@@ -188,6 +173,9 @@
selectedPaths = new Set([item.path]);
lastSelectedPath = item.path;
}
// Signal selection to parent
onSelect(item);
}
function handleRowDoubleClick(item: FileItem) {
@@ -198,32 +186,32 @@
}
}
function handleSelectAll(checked: boolean) {
if (checked) {
selectedPaths = new Set(filteredFiles.map(f => f.path));
function handleSelectAll(checked: boolean | "indeterminate") {
if (checked === true) {
selectedPaths = new Set(filteredFiles.map((f: FileItem) => f.path));
} else {
selectedPaths = new Set();
}
}
function bulkToggle(track: boolean) {
const selectedItems = files.filter(f => selectedPaths.has(f.path) && f.tracked !== track);
selectedItems.forEach(item => onToggleTrack(item));
const selectedItems = files.filter((f: FileItem) => selectedPaths.has(f.path) && f.tracked !== track);
selectedItems.forEach((item: FileItem) => onToggleTrack(item));
}
</script>
<div
class="file-browser flex h-full flex-col overflow-hidden rounded-lg border border-border-color bg-bg-secondary shadow-2xl"
class="file-browser flex h-full flex-col overflow-hidden rounded-lg border border-border-color bg-bg-secondary shadow-2xl min-w-0"
>
<!-- ZONE A: TOP BAR -->
<div class="flex h-14 shrink-0 items-center justify-between border-b border-border-color bg-bg-tertiary/50 px-6 shadow-sm">
<div class="flex items-center gap-4 flex-1">
<div class="flex items-center gap-4 flex-1 min-w-0">
<!-- Navigation Buttons -->
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8 text-text-secondary hover:text-text-primary hover:bg-white/5">
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" class="h-8 w-8 text-text-secondary hover:text-text-primary hover:bg-white/5" onclick={() => window.history.back()}>
<ChevronLeft size={18}></ChevronLeft>
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8 text-text-secondary hover:text-text-primary hover:bg-white/5">
<Button variant="ghost" size="icon" class="h-8 w-8 text-text-secondary hover:text-text-primary hover:bg-white/5" onclick={() => window.history.forward()}>
<ChevronRight size={18}></ChevronRight>
</Button>
<Button
@@ -231,10 +219,13 @@
size="icon"
class="h-8 w-8 text-text-secondary hover:text-text-primary hover:bg-white/5"
onclick={() => {
if (mode === "host" && currentPath === "/source_data") return;
if (mode === "index" && currentPath === "/") return;
const parent = currentPath.split("/").slice(0, -1).join("/") || "/";
onNavigate(parent);
if (currentPath === "ROOT") return;
const parts = currentPath.split("/").filter(Boolean);
if (parts.length === 1) {
onNavigate("ROOT");
} else {
onNavigate("/" + parts.slice(0, -1).join("/"));
}
}}
>
<ChevronUp size={18}></ChevronUp>
@@ -242,7 +233,7 @@
</div>
<!-- Address Bar -->
<div class="flex-1 flex items-center bg-bg-primary border border-border-color/40 rounded-md px-3 h-9 shadow-inner overflow-hidden max-w-3xl group transition-all focus-within:border-action-color/50">
<div class="flex-1 flex items-center bg-bg-primary border border-border-color/40 rounded-md px-3 h-9 shadow-inner overflow-hidden max-w-3xl group transition-all focus-within:border-action-color/50 min-w-0">
<Folder size={16} class="text-yellow-500/80 mr-2 shrink-0"></Folder>
<div class="flex-1 flex items-center overflow-x-auto scrollbar-hide">
{#each breadcrumbs as crumb, i}
@@ -260,7 +251,7 @@
</button>
{/each}
</div>
<button class="ml-2 text-text-secondary hover:text-text-primary p-1 transition-colors cursor-pointer" onclick={() => onNavigate(currentPath)}>
<button class="ml-2 text-text-secondary hover:text-text-primary p-1 transition-colors cursor-pointer shrink-0" onclick={() => onNavigate(currentPath)}>
<RotateCw size={14}></RotateCw>
</button>
</div>
@@ -268,7 +259,7 @@
<!-- Search Input -->
<div class="flex items-center shrink-0 ml-12">
<div class="relative w-64 sm:w-80 group">
<div class="relative w-48 sm:w-64 group">
<Search
size={14}
class="absolute left-3 top-3 text-text-secondary group-focus-within:text-action-color transition-colors"
@@ -295,115 +286,115 @@
</aside>
<!-- ZONE C: DETAILS PANE -->
<div class="flex min-w-0 flex-1 flex-col bg-bg-primary shadow-inner">
<!-- Column Headers -->
<div class="flex h-10 items-center border-b border-border-color bg-bg-tertiary/30 shrink-0 select-none">
<div class="flex w-12 shrink-0 justify-center">
<Checkbox
checked={selectedPaths.size === filteredFiles.length && filteredFiles.length > 0}
onCheckedChange={handleSelectAll}
/>
</div>
<div class="flex min-w-0 flex-1 flex-col bg-bg-primary shadow-inner overflow-hidden">
<div class="flex flex-col flex-1 min-w-0 overflow-x-auto scrollbar-hide">
<div class="min-w-max flex flex-col flex-1">
<!-- Column Headers -->
<div class="flex h-10 items-center border-b border-border-color bg-bg-tertiary/30 shrink-0 select-none border-l-2 border-l-transparent">
<div class="flex w-12 shrink-0 justify-center">
<Checkbox
checked={selectedPaths.size === filteredFiles.length && filteredFiles.length > 0}
onCheckedChange={handleSelectAll}
/>
</div>
<div class="flex flex-1 items-center min-w-0 h-full relative group/col">
<button
class="flex w-full items-center justify-between text-[11px] font-semibold text-text-secondary hover:bg-white/5 px-4 h-full transition-colors"
onclick={() => toggleSort("name")}
>
Name
{#if sortColumn === "name"}
<ArrowUpDown size={10} class={cn(sortDirection === "desc" && "rotate-180")}></ArrowUpDown>
{/if}
</button>
<!-- Vertical Separator & Resizer -->
<div class="absolute right-0 top-0 w-px h-full bg-border-color/30"></div>
<div
class="absolute -right-1 top-0 w-2 h-full cursor-col-resize z-10"
role="none"
></div>
</div>
<div class="flex flex-auto min-w-[300px] items-center h-full relative group/col">
<button
class="flex w-full items-center justify-between text-[11px] font-semibold text-text-secondary hover:bg-white/5 px-4 h-full transition-colors"
onclick={() => toggleSort("name")}
>
Name
{#if sortColumn === "name"}
<ArrowUpDown size={10} class={cn(sortDirection === "desc" && "rotate-180")}></ArrowUpDown>
{/if}
</button>
<div class="absolute right-0 top-0 w-px h-full bg-border-color/30"></div>
<div
class="absolute -right-1 top-0 w-2 h-full cursor-col-resize z-10"
role="none"
></div>
</div>
<div class="flex items-center h-full relative group/col shrink-0" style="width: {mtimeWidth}px">
<button
class="flex w-full items-center justify-between text-[11px] font-semibold text-text-secondary hover:bg-white/5 px-4 h-full transition-colors"
onclick={() => toggleSort("mtime")}
>
Date modified
{#if sortColumn === "mtime"}
<ArrowUpDown size={10} class={cn(sortDirection === "desc" && "rotate-180")}></ArrowUpDown>
{/if}
</button>
<!-- Vertical Separator & Resizer -->
<div class="absolute right-0 top-0 w-px h-full bg-border-color/30"></div>
<div
class="absolute -right-1 top-0 w-2 h-full cursor-col-resize z-10"
onmousedown={(e) => startResize(e, 'mtime')}
role="none"
></div>
</div>
<div class="flex items-center h-full relative group/col shrink-0" style="width: {mtimeWidth}px">
<button
class="flex w-full items-center justify-between text-[11px] font-semibold text-text-secondary hover:bg-white/5 px-4 h-full transition-colors"
onclick={() => toggleSort("mtime")}
>
Date modified
{#if sortColumn === "mtime"}
<ArrowUpDown size={10} class={cn(sortDirection === "desc" && "rotate-180")}></ArrowUpDown>
{/if}
</button>
<div class="absolute right-0 top-0 w-px h-full bg-border-color/30"></div>
<div
class="absolute -right-1 top-0 w-2 h-full cursor-col-resize z-10"
onmousedown={(e) => startResize(e, 'mtime')}
role="none"
></div>
</div>
<div class="flex items-center h-full relative group/col shrink-0" style="width: {typeWidth}px">
<button
class="flex w-full items-center justify-between text-[11px] font-semibold text-text-secondary hover:bg-white/5 px-4 h-full transition-colors"
onclick={() => toggleSort("type")}
>
Type
{#if sortColumn === "type"}
<ArrowUpDown size={10} class={cn(sortDirection === "desc" && "rotate-180")}></ArrowUpDown>
{/if}
</button>
<!-- Vertical Separator & Resizer -->
<div class="absolute right-0 top-0 w-px h-full bg-border-color/30"></div>
<div
class="absolute -right-1 top-0 w-2 h-full cursor-col-resize z-10"
onmousedown={(e) => startResize(e, 'type')}
role="none"
></div>
</div>
<div class="flex items-center h-full relative group/col shrink-0" style="width: {typeWidth}px">
<button
class="flex w-full items-center justify-between text-[11px] font-semibold text-text-secondary hover:bg-white/5 px-4 h-full transition-colors"
onclick={() => toggleSort("type")}
>
Type
{#if sortColumn === "type"}
<ArrowUpDown size={10} class={cn(sortDirection === "desc" && "rotate-180")}></ArrowUpDown>
{/if}
</button>
<div class="absolute right-0 top-0 w-px h-full bg-border-color/30"></div>
<div
class="absolute -right-1 top-0 w-2 h-full cursor-col-resize z-10"
onmousedown={(e) => startResize(e, 'type')}
role="none"
></div>
</div>
<div class="flex items-center h-full relative group/col shrink-0" style="width: {sizeWidth}px">
<button
class="flex w-full items-center justify-between text-[11px] font-semibold text-text-secondary hover:bg-white/5 px-4 h-full transition-colors text-right"
onclick={() => toggleSort("size")}
>
Size
{#if sortColumn === "size"}
<ArrowUpDown size={10} class={cn(sortDirection === "desc" && "rotate-180")}></ArrowUpDown>
{/if}
</button>
<!-- Vertical Separator & Resizer -->
<div class="absolute right-0 top-0 w-px h-full bg-border-color/30"></div>
<div
class="absolute -right-1 top-0 w-2 h-full cursor-col-resize z-10"
onmousedown={(e) => startResize(e, 'size')}
role="none"
></div>
</div>
<div class="flex items-center h-full relative group/col shrink-0" style="width: {sizeWidth}px">
<button
class="flex w-full items-center justify-between text-[11px] font-semibold text-text-secondary hover:bg-white/5 px-4 h-full transition-colors text-right"
onclick={() => toggleSort("size")}
>
Size
{#if sortColumn === "size"}
<ArrowUpDown size={10} class={cn(sortDirection === "desc" && "rotate-180")}></ArrowUpDown>
{/if}
</button>
<div class="absolute right-0 top-0 w-px h-full bg-border-color/30"></div>
<div
class="absolute -right-1 top-0 w-2 h-full cursor-col-resize z-10"
onmousedown={(e) => startResize(e, 'size')}
role="none"
></div>
</div>
<div class="w-10 shrink-0"></div>
</div>
<!-- Scrollable File List -->
<ScrollArea class="flex-1">
{#if filteredFiles.length === 0}
<div class="flex h-full flex-col items-center justify-center p-12 text-center opacity-30">
<Search size={48} class="mb-4" strokeWidth={1}></Search>
<p class="text-sm font-medium uppercase tracking-widest">Folder is empty</p>
<div class="w-10 shrink-0"></div>
</div>
{:else}
{#each filteredFiles as item}
<FileBrowserRowItem
{item}
{mode}
{colWidths}
isSelected={selectedPaths.has(item.path)}
onClick={(e) => handleRowClick(e, item)}
onDoubleClick={() => handleRowDoubleClick(item)}
onToggleTrack={() => onToggleTrack(item)}
/>
{/each}
{/if}
</ScrollArea>
<!-- Scrollable File List -->
<div class="flex-1 overflow-y-auto min-h-0">
{#if filteredFiles.length === 0}
<div class="flex h-full flex-col items-center justify-center p-12 text-center opacity-30">
<Search size={48} class="mb-4" strokeWidth={1}></Search>
<p class="text-sm font-medium uppercase tracking-widest">Folder is empty</p>
</div>
{:else}
{#each filteredFiles as item (item.path)}
<FileBrowserRowItem
{item}
{mode}
{colWidths}
isSelected={selectedPaths.has(item.path)}
onClick={(e) => handleRowClick(e, item)}
onDoubleClick={() => handleRowDoubleClick(item)}
onToggleTrack={() => onToggleTrack(item)}
/>
{/each}
{/if}
</div>
</div>
</div>
</div>
</div>
@@ -425,9 +416,9 @@
<CheckSquare size={10}></CheckSquare>
<span class="font-bold uppercase tracking-wider">
{#if mode === 'host'}
{files.filter((f) => f.tracked).length} Tracked
{files.filter((f: FileItem) => f.tracked).length} Tracked
{:else}
{files.filter((f) => f.selected).length} Selected
{files.filter((f: FileItem) => f.selected).length} Selected
{/if}
</span>
</div>
@@ -12,7 +12,8 @@
CassetteTape,
ShieldCheck,
ShieldAlert,
Square
Square,
EyeOff
} from "lucide-svelte";
import { Checkbox } from "$lib/components/ui/checkbox";
import { Button } from "$lib/components/ui/button";
@@ -108,7 +109,8 @@
"group flex h-10 items-center border-b border-border-color/10 transition-all cursor-pointer select-none",
isSelected
? "bg-blue-500/15 border-l-2 border-l-blue-500"
: "hover:bg-white/5 border-l-2 border-l-transparent"
: "hover:bg-white/5 border-l-2 border-l-transparent",
item.ignored && "opacity-40 grayscale-[0.5]"
)}
role="button"
tabindex="0"
@@ -121,12 +123,16 @@
class="flex h-10 w-12 shrink-0 items-center justify-center border-r border-border-color/10"
onclick={(e) => {
e.stopPropagation();
onToggleTrack();
if (!item.ignored) onToggleTrack();
}}
onkeydown={(e) => e.key === " " && e.stopPropagation()}
role="none"
>
{#if mode === 'host'}
{#if item.ignored}
<div class="text-text-secondary/40">
<EyeOff size={16} />
</div>
{:else if mode === 'host'}
{#if item.tracked}
<div class="text-success-color bg-success-color/10 p-1 rounded-md">
<ShieldCheck size={16} />
@@ -142,12 +148,13 @@
</div>
<!-- NAME & ICON -->
<div class="flex flex-1 items-center gap-3 min-w-0 px-4 h-full border-r border-border-color/10">
<div class="flex flex-auto min-w-[200px] items-center gap-3 px-4 h-full border-r border-border-color/10 overflow-hidden">
<div class="shrink-0 relative">
<FileIcon
size={18}
class={cn(
item.type === "directory" ? "text-yellow-500/80 fill-yellow-500/10" : "text-text-secondary"
item.type === "directory" ? "text-yellow-500/80 fill-yellow-500/10" : "text-text-secondary",
item.ignored && "text-text-secondary/40"
)}
></FileIcon>
{#if item.type === "link"}
@@ -164,17 +171,18 @@
<span
class={cn(
"truncate text-[13px] transition-colors",
item.tracked
!item.ignored && item.tracked
? "text-success-color font-bold"
: isSelected
? "text-text-primary font-medium"
: "text-text-secondary group-hover:text-text-primary"
: "text-text-secondary group-hover:text-text-primary",
item.ignored && "line-through decoration-text-secondary/40"
)}
>
{item.name}
</span>
{#if mode === "index" && item.media && item.media.length > 0}
<div class="flex gap-1 overflow-hidden">
<div class="flex gap-1 overflow-hidden shrink-0">
{#each item.media as m}
<span class="inline-flex items-center gap-1 bg-blue-500/10 text-blue-400 text-[9px] px-1.5 py-0.5 rounded border border-blue-500/20 font-bold uppercase tracking-wider">
<CassetteTape size={10} />
@@ -184,11 +192,6 @@
</div>
{/if}
</div>
{#if item.type === "link" && item.target}
<span class="text-[10px] text-text-secondary/50 truncate italic flex items-center gap-1">
<LinkIcon size={10} /> {item.target}
</span>
{/if}
</div>
</div>
@@ -1,9 +1,9 @@
<script lang="ts">
import { ChevronRight, Folder, Home, HardDrive, Monitor, Download, FileText, Image } from "lucide-svelte";
import { ChevronRight, Folder, HardDrive } from "lucide-svelte";
import type { TreeNode } from "$lib/types";
import { cn } from "$lib/utils";
import FileBrowserTreeItem from "./FileBrowserTreeItem.svelte";
import { getTreeSystemTreeGet } from "$lib/api/sdk.gen";
import { getTreeSystemTreeGet, getIndexTreeInventoryTreeGet, type TreeNodeSchema } from "$lib/api";
let {
node,
@@ -21,11 +21,19 @@
mode?: "host" | "index";
}>();
let expanded = $state(node.expanded || false);
let children = $state<TreeNode[]>(node.children || []);
let expanded = $state(false);
let children = $state<TreeNode[]>([]);
let loading = $state(false);
let loaded = $state(false);
// Initialize state from props once
onMount(() => {
if (node.expanded) expanded = true;
if (node.children) children = node.children;
});
import { onMount } from 'svelte';
// Auto-load if started expanded
$effect(() => {
if (expanded && !loaded) {
@@ -34,15 +42,19 @@
});
async function loadSubdirs() {
if (loaded || mode === "index") return; // Index mode lazy loading not yet implemented
if (loaded) return;
loading = true;
try {
const response = await getTreeSystemTreeGet({
const fetchFn = mode === "host" ? getTreeSystemTreeGet : getIndexTreeInventoryTreeGet;
const response = await fetchFn({
query: { path: node.path }
});
if (response.data) {
children = response.data.map(d => ({
const data = (response.data as any) as TreeNodeSchema[];
if (data && Array.isArray(data)) {
children = data.map((d: TreeNodeSchema) => ({
name: d.name,
path: d.path,
children: [],
@@ -71,15 +83,7 @@
const specialIcon = $derived.by(() => {
if (!isSpecial) return null;
switch (node.name.toLowerCase()) {
case "source data":
case "this pc":
case "root":
case "virtual index":
return HardDrive;
default:
return HardDrive;
}
return HardDrive;
});
const hasSubdirs = $derived((children && children.length > 0) || (node as any).hasChildren);
@@ -161,7 +165,7 @@
{#if expanded && children.length > 0}
<div role="group">
{#each children as child}
{#each children as child (child.path)}
<FileBrowserTreeItem node={child} {selectedPath} {onSelect} level={level + 1} {mode} />
{/each}
</div>
+5 -3
View File
@@ -3,11 +3,13 @@ export interface FileItem {
path: string;
type: 'file' | 'directory' | 'link';
target?: string; // For links
size?: number;
mtime?: number;
tracked?: boolean;
size?: number | null;
mtime?: number | null;
tracked?: boolean | null;
ignored?: boolean | null;
media?: string[]; // Media it's on (for index browsing)
selected?: boolean; // For restore cart
sha256_hash?: string | null;
}
export interface TreeNode {
+129 -28
View File
@@ -7,45 +7,146 @@
FolderTree,
History,
Settings,
Database
Database,
CassetteTape,
Activity,
ChevronLeft,
ChevronRight
} from 'lucide-svelte';
import { cn } from '$lib/utils';
import { Toaster } from 'svelte-sonner';
import ScanStatusOverlay from '$lib/components/ScanStatusOverlay.svelte';
let { children } = $props();
const navItems = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Inventory', href: '/inventory', icon: Library },
{ name: 'Index Browser', href: '/index-browser', icon: Library },
{ name: 'File Tracking', href: '/tracking', icon: FolderTree },
{ name: 'System Activity', href: '/jobs', icon: Activity },
{ name: 'Physical Media', href: '/inventory', icon: CassetteTape },
{ name: 'Restores', href: '/restores', icon: History }
];
let isSidebarOpen = $state(true);
</script>
<div class="app-container">
<nav>
<h2><Database size={24} color="#3498db" /> TapeHoard</h2>
<ul>
{#each navItems as item}
<li>
<a href={item.href} class:active={$page.url.pathname === item.href}>
<item.icon size={20} />
{item.name}
</a>
</li>
{/each}
</ul>
<div style="margin-top: auto;">
<ul>
<li>
<a href="/settings" class:active={$page.url.pathname === '/settings'}>
<Settings size={20} />
Settings
</a>
</li>
</ul>
<Toaster position="top-right" richColors />
<ScanStatusOverlay />
<div class="app-container flex h-screen w-full overflow-hidden bg-bg-primary text-text-primary font-sans selection:bg-action-color/30">
<!-- SIDEBAR -->
<aside
class={cn(
"sidebar flex flex-col border-r border-border-color bg-bg-secondary transition-all duration-300 relative z-50 shadow-2xl shrink-0",
isSidebarOpen ? "w-64" : "w-20"
)}
>
<!-- LOGO AREA -->
<div class={cn(
"flex h-20 items-center border-b border-border-color bg-bg-tertiary/30 shrink-0 overflow-hidden transition-all duration-300",
isSidebarOpen ? "px-6" : "px-0 justify-center"
)}>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-action-color text-white shadow-lg shadow-action-color/20 shrink-0">
<Database size={22} strokeWidth={2.5} />
</div>
{#if isSidebarOpen}
<div class="flex flex-col animate-in fade-in slide-in-from-left-2 duration-300">
<span class="text-lg font-black uppercase tracking-tighter leading-none">TapeHoard</span>
<span class="text-[9px] font-bold uppercase tracking-[0.2em] text-text-secondary opacity-60">LTO Archival</span>
</div>
{/if}
</div>
</div>
<!-- NAVIGATION -->
<nav class="flex-1 py-4 overflow-y-auto overflow-x-hidden">
{#each navItems as item}
{@const isActive = $page.url.pathname === item.href || ($page.url.pathname.startsWith(item.href) && item.href !== '/')}
<a
href={item.href}
class={cn(
"group flex items-center transition-all w-full border-l-4 h-12",
isSidebarOpen ? "px-6 gap-4" : "px-0 justify-center gap-0",
isActive
? "bg-action-color/10 text-text-primary border-l-action-color"
: "text-text-secondary hover:bg-white/[0.03] hover:text-text-primary border-l-transparent"
)}
title={!isSidebarOpen ? item.name : ''}
>
<item.icon size={18} class={cn("shrink-0", isActive ? "text-action-color" : "text-text-secondary group-hover:text-action-color")} />
{#if isSidebarOpen}
<span class="truncate text-[12px] font-bold uppercase tracking-wider animate-in fade-in slide-in-from-left-2 duration-300">{item.name}</span>
{/if}
</a>
{/each}
</nav>
<!-- FOOTER ACTIONS -->
<div class="border-t border-border-color bg-bg-tertiary/10 shrink-0 flex flex-col">
<a
href="/settings"
class={cn(
"group flex items-center transition-all w-full border-l-4 h-14",
isSidebarOpen ? "px-6 gap-4" : "px-0 justify-center gap-0",
$page.url.pathname === '/settings'
? "bg-bg-tertiary text-text-primary border-l-white"
: "text-text-secondary hover:bg-white/[0.03] hover:text-text-primary border-l-transparent"
)}
title={!isSidebarOpen ? "Settings" : ''}
>
<Settings size={18} class="shrink-0" />
{#if isSidebarOpen}
<span class="truncate text-[12px] font-bold uppercase tracking-wider animate-in fade-in slide-in-from-left-2 duration-300">Settings</span>
{/if}
</a>
<!-- COLLAPSE TOGGLE -->
<button
onclick={() => isSidebarOpen = !isSidebarOpen}
class="h-10 w-full flex items-center justify-center hover:bg-white/5 text-text-secondary hover:text-text-primary transition-colors border-t border-border-color/50"
>
{#if isSidebarOpen}
<ChevronLeft size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</button>
</div>
</aside>
<!-- MAIN CONTENT -->
<main class="flex-1 overflow-hidden relative flex flex-col min-w-0">
<!-- Subtle background gradient -->
<div class="absolute inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(59,130,246,0.03),transparent_50%)] pointer-events-none"></div>
<div class="flex-1 overflow-y-auto p-8 relative z-10">
<div class="max-w-[1600px] mx-auto h-full">
{@render children()}
</div>
</div>
</nav>
<main>
{@render children()}
</main>
</div>
<style>
:global(body) {
background-color: #000;
}
/* Custom scrollbar for brutalist look */
:global(::-webkit-scrollbar) {
width: 8px;
height: 8px;
}
:global(::-webkit-scrollbar-track) {
background: #0a0a0a;
}
:global(::-webkit-scrollbar-thumb) {
background: #1a1a1a;
border-radius: 4px;
}
:global(::-webkit-scrollbar-thumb:hover) {
background: #2a2a2a;
}
</style>
+312 -3
View File
@@ -1,9 +1,318 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
ShieldCheck,
ShieldAlert,
FileText,
Database,
Clock,
RotateCw,
Activity,
HardDrive,
Cloud,
ArrowRight,
EyeOff
} from 'lucide-svelte';
import { Card } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { getDashboardStatsSystemDashboardStatsGet, triggerScanSystemScanPost, type DashboardStatsSchema } from '$lib/api';
import { cn } from '$lib/utils';
import { toast } from 'svelte-sonner';
let stats = $state<DashboardStatsSchema | null>(null);
let loading = $state(true);
let scanning = $state(false);
async function loadStats() {
loading = true;
try {
const response = await getDashboardStatsSystemDashboardStatsGet();
if (response.data) {
stats = response.data;
}
} catch (error) {
console.error("Failed to load dashboard stats:", error);
} finally {
loading = false;
}
}
async function startScan() {
scanning = true;
try {
await triggerScanSystemScanPost();
toast.success("Scan job initiated successfully");
} catch (error: any) {
toast.error(error.body?.detail || "Failed to start scan");
} finally {
scanning = false;
}
}
onMount(loadStats);
function formatSize(bytes: number) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let unitIndex = 0;
let size = bytes;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
const protectionPercent = $derived.by(() => {
if (!stats || stats.total_files_indexed === 0) return 0;
const eligible_count = stats.total_files_indexed - stats.ignored_files_count;
if (eligible_count <= 0) return 0;
const protected_count = eligible_count - stats.unprotected_files_count;
return Math.round((protected_count / eligible_count) * 100);
});
const dataProtectionPercent = $derived.by(() => {
if (!stats || stats.total_data_size === 0) return 0;
const eligible_size = stats.total_data_size - stats.ignored_data_size;
if (eligible_size <= 0) return 0;
const protected_size = eligible_size - stats.unprotected_data_size;
return Math.round((protected_size / eligible_size) * 100);
});
</script>
<svelte:head>
<title>TapeHoard - Dashboard</title>
<title>Dashboard - TapeHoard</title>
</svelte:head>
<h1>Dashboard</h1>
<p>Welcome to TapeHoard.</p>
<div class="space-y-8 animate-in fade-in duration-700">
<!-- HERO SECTION -->
<div class="flex justify-between items-start">
<div>
<h1 class="text-4xl font-black uppercase tracking-tighter text-text-primary">Operational Status</h1>
<p class="text-text-secondary mt-1 font-bold uppercase tracking-widest text-[11px] opacity-70">Fleet-wide data protection & index health</p>
</div>
<div class="flex gap-3">
<Button variant="outline" class="h-10 px-6 font-black uppercase tracking-widest text-[10px] border-border-color" onclick={loadStats}>
<RotateCw size={14} class={cn("mr-2", loading && "animate-spin")} /> Refresh
</Button>
<Button variant="default" class="h-10 px-6 font-black uppercase tracking-widest text-[10px]" onclick={startScan} disabled={scanning}>
{#if scanning}
<RotateCw size={14} class="mr-2 animate-spin" /> Starting...
{:else}
<Activity size={14} class="mr-2" /> Start Full Scan
{/if}
</Button>
</div>
</div>
{#if loading && !stats}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{#each Array(4) as _}
<div class="h-32 bg-bg-secondary animate-pulse rounded-xl border border-border-color/50"></div>
{/each}
</div>
{:else if stats}
<!-- TOP STATS -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card class="p-6 bg-gradient-to-br from-bg-secondary to-bg-tertiary border-border-color hover:border-blue-500/30 transition-all group relative overflow-hidden">
<div class="absolute inset-0 bg-blue-500/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div class="flex items-center gap-4 relative z-10">
<div class="p-3 bg-blue-500/10 rounded-lg text-blue-500 border border-blue-500/20 shadow-inner">
<FileText size={24} />
</div>
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Total Files</span>
<span class="text-2xl font-black text-text-primary mono tracking-tight">{stats.total_files_indexed.toLocaleString()}</span>
</div>
</div>
</Card>
<Card class="p-6 bg-gradient-to-br from-bg-secondary to-bg-tertiary border-border-color hover:border-action-color/30 transition-all group relative overflow-hidden">
<div class="absolute inset-0 bg-action-color/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div class="flex items-center gap-4 relative z-10">
<div class="p-3 bg-action-color/10 rounded-lg text-action-color border border-action-color/20 shadow-inner">
<Database size={24} />
</div>
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Data Volume</span>
<span class="text-2xl font-black text-text-primary mono tracking-tight">{formatSize(stats.total_data_size)}</span>
</div>
</div>
</Card>
<Card class="p-6 bg-gradient-to-br from-bg-secondary to-bg-tertiary border-border-color hover:border-success-color/30 transition-all group relative overflow-hidden">
<div class="absolute inset-0 bg-success-color/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div class="flex items-center gap-4 relative z-10">
<div class="p-3 bg-success-color/10 rounded-lg text-success-color border border-success-color/20 shadow-inner">
<ShieldCheck size={24} />
</div>
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Redundancy Ratio</span>
<span class="text-2xl font-black text-text-primary mono tracking-tight">{stats.redundancy_ratio}x</span>
</div>
</div>
</Card>
<Card class="p-6 bg-gradient-to-br from-bg-secondary to-bg-tertiary border-border-color hover:border-orange-500/30 transition-all group relative overflow-hidden">
<div class="absolute inset-0 bg-orange-500/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div class="flex items-center gap-4 relative z-10">
<div class="p-3 bg-orange-500/10 rounded-lg text-orange-500 border border-orange-500/20 shadow-inner">
<Clock size={24} />
</div>
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Last Scan</span>
<span class="text-xl font-black text-text-primary tracking-tight">
{stats.last_scan_time ? new Date(stats.last_scan_time).toLocaleDateString() : 'Never'}
</span>
</div>
</div>
</Card>
</div>
<!-- MAIN GRID -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Protection Health -->
<Card class="lg:col-span-2 p-8 bg-bg-secondary border-border-color shadow-xl overflow-hidden relative">
<div class="absolute top-0 right-0 p-8 opacity-5">
<ShieldCheck size={200} />
</div>
<h3 class="text-lg font-black uppercase tracking-tighter text-text-primary mb-8 flex items-center gap-2">
<Activity size={18} class="text-blue-500" />
Protection Health Score
</h3>
<div class="space-y-12 relative z-10">
<div class="space-y-4">
<div class="flex justify-between items-end">
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary">Tracked File Coverage</span>
<h4 class="text-3xl font-black text-text-primary">{protectionPercent}%</h4>
</div>
<span class="text-xs font-bold mono text-text-secondary">
{stats.total_files_indexed - stats.ignored_files_count - stats.unprotected_files_count} / {stats.total_files_indexed - stats.ignored_files_count} ELIGIBLE FILES
</span>
</div>
<div class="w-full bg-bg-primary h-4 rounded-full border border-border-color shadow-inner overflow-hidden">
<div class="bg-gradient-to-r from-blue-600 to-blue-400 h-full transition-all duration-1000 shadow-[0_0_15px_rgba(59,130,246,0.3)]" style="width: {protectionPercent}%"></div>
</div>
</div>
<div class="space-y-4">
<div class="flex justify-between items-end">
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary">Active Data Redundancy</span>
<h4 class="text-3xl font-black text-text-primary">{dataProtectionPercent}%</h4>
</div>
<span class="text-xs font-bold mono text-text-secondary">
{formatSize(stats.total_data_size - stats.ignored_data_size - stats.unprotected_data_size)} / {formatSize(stats.total_data_size - stats.ignored_data_size)}
</span>
</div>
<div class="w-full bg-bg-primary h-4 rounded-full border border-border-color shadow-inner overflow-hidden">
<div class="bg-gradient-to-r from-success-color to-emerald-400 h-full transition-all duration-1000 shadow-[0_0_15px_rgba(46,204,113,0.3)]" style="width: {dataProtectionPercent}%"></div>
</div>
</div>
</div>
<div class="mt-12 grid grid-cols-1 md:grid-cols-3 gap-6 p-6 bg-bg-tertiary/50 rounded-xl border border-border-color">
<div class="flex gap-4">
<div class="p-2 bg-error-color/10 rounded-lg text-error-color h-fit shrink-0">
<ShieldAlert size={18} />
</div>
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block mb-1">Unprotected</span>
<span class="text-lg font-black text-error-color mono">{stats.unprotected_files_count.toLocaleString()}</span>
<p class="text-[9px] font-bold text-text-secondary uppercase tracking-tight mt-1">Files pending archival</p>
</div>
</div>
<div class="flex gap-4 border-l border-border-color/30 pl-4">
<div class="p-2 bg-text-secondary/10 rounded-lg text-text-secondary h-fit shrink-0">
<EyeOff size={18} />
</div>
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block mb-1">Ignored</span>
<span class="text-lg font-black text-text-secondary mono">{stats.ignored_files_count.toLocaleString()}</span>
<p class="text-[9px] font-bold text-text-secondary uppercase tracking-tight mt-1">Bypassed by policy</p>
</div>
</div>
<div class="flex gap-4 border-l border-border-color/30 pl-4">
<div class="p-2 bg-blue-500/10 rounded-lg text-blue-500 h-fit shrink-0">
<Database size={18} />
</div>
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block mb-1">Ignored Vol</span>
<span class="text-lg font-black text-blue-400 mono">{formatSize(stats.ignored_data_size)}</span>
<p class="text-[9px] font-bold text-text-secondary uppercase tracking-tight mt-1">Filtered from index</p>
</div>
</div>
</div>
</Card>
<!-- Quick Actions & Media -->
<div class="space-y-8">
<Card class="p-8 bg-bg-secondary border-border-color shadow-xl h-fit">
<h3 class="text-lg font-black uppercase tracking-tighter text-text-primary mb-6">Quick Directives</h3>
<div class="space-y-3">
<Button variant="outline" class="w-full justify-between h-12 font-black uppercase tracking-widest text-[10px] border-border-color hover:border-blue-500/50 hover:bg-blue-500/5 group" href="/tracking">
Review Tracking Rules <ArrowRight size={14} class="group-hover:translate-x-1 transition-transform" />
</Button>
<Button variant="outline" class="w-full justify-between h-12 font-black uppercase tracking-widest text-[10px] border-border-color hover:border-success-color/50 hover:bg-success-color/5 group" href="/index-browser">
Browse Indexed Files <ArrowRight size={14} class="group-hover:translate-x-1 transition-transform" />
</Button>
<Button variant="outline" class="w-full justify-between h-12 font-black uppercase tracking-widest text-[10px] border-border-color hover:border-action-color/50 hover:bg-action-color/5 group" href="/inventory">
Register New Media <ArrowRight size={14} class="group-hover:translate-x-1 transition-transform" />
</Button>
</div>
</Card>
<Card class="p-8 bg-bg-secondary border-border-color shadow-xl h-fit">
<h3 class="text-lg font-black uppercase tracking-tighter text-text-primary mb-6">Media distribution</h3>
<div class="space-y-6">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center text-blue-500">
<RotateCw size={20} />
</div>
<div class="flex-1">
<div class="flex justify-between mb-1">
<span class="text-[10px] font-black uppercase tracking-widest text-text-primary">LTO Tapes</span>
<span class="text-[10px] font-bold mono">0 Items</span>
</div>
<div class="w-full bg-bg-primary h-1.5 rounded-full overflow-hidden">
<div class="bg-blue-500 h-full w-0"></div>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-yellow-500/10 rounded-lg flex items-center justify-center text-yellow-500">
<HardDrive size={20} />
</div>
<div class="flex-1">
<div class="flex justify-between mb-1">
<span class="text-[10px] font-black uppercase tracking-widest text-text-primary">HDD Storage</span>
<span class="text-[10px] font-bold mono">0 Items</span>
</div>
<div class="w-full bg-bg-primary h-1.5 rounded-full overflow-hidden">
<div class="bg-yellow-500 h-full w-0"></div>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-green-500/10 rounded-lg flex items-center justify-center text-green-500">
<Cloud size={20} />
</div>
<div class="flex-1">
<div class="flex justify-between mb-1">
<span class="text-[10px] font-black uppercase tracking-widest text-text-primary">Cloud Vaults</span>
<span class="text-[10px] font-bold mono">0 Items</span>
</div>
<div class="w-full bg-bg-primary h-1.5 rounded-full overflow-hidden">
<div class="bg-green-500 h-full w-0"></div>
</div>
</div>
</div>
</div>
</Card>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,335 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Library,
RotateCw,
Info,
X,
ShieldCheck,
FileText,
Folder,
ListPlus,
FolderTree,
Clock
} from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import FileBrowser from '$lib/components/file-browser/FileBrowser.svelte';
import type { FileItem } from '$lib/types';
import {
browseIndexInventoryBrowseGet,
getItemMetadataInventoryMetadataGet,
listCartRestoresCartGet,
addToCartRestoresCartFileIdPost,
removeFromCartRestoresCartItemIdDelete,
addDirectoryToCartRestoresCartDirectoryPost,
type ItemMetadataSchema,
type CartItemSchema
} from '$lib/api';
import { toast } from 'svelte-sonner';
let currentPath = $state('ROOT');
let indexedFiles = $state<FileItem[]>([]);
let loading = $state(false);
let selectedItemMetadata = $state<ItemMetadataSchema | null>(null);
let metadataLoading = $state(false);
// This handles the restore cart selection
let restoreCartItems = $state<CartItemSchema[]>([]);
const restoreCartPaths = $derived(new Set(restoreCartItems.map(i => i.file_path)));
async function loadCart() {
try {
const response = await listCartRestoresCartGet();
if (response.data) {
restoreCartItems = response.data;
}
} catch (error) {
console.error("Failed to load cart:", error);
}
}
async function loadIndexedFiles(path: string) {
loading = true;
try {
const response = await browseIndexInventoryBrowseGet({
query: { path }
});
if (response.data) {
indexedFiles = response.data.map(f => ({
name: f.name,
path: f.path,
type: f.type as 'file' | 'directory' | 'link',
size: f.size ?? null,
mtime: f.mtime ?? null,
media: f.media ?? [],
selected: restoreCartPaths.has(f.path)
}));
}
} catch (error) {
console.error("Failed to query index:", error);
toast.error("Failed to query index");
} finally {
loading = false;
}
}
async function fetchMetadata(item: FileItem) {
metadataLoading = true;
try {
const response = await getItemMetadataInventoryMetadataGet({
query: { path: item.path }
});
if (response.data) {
selectedItemMetadata = response.data;
}
} catch (error) {
console.error("Failed to fetch metadata:", error);
selectedItemMetadata = null;
} finally {
metadataLoading = false;
}
}
async function handleToggleCart(item: FileItem) {
if (item.type !== 'file') return;
const isCurrentlyInCart = restoreCartPaths.has(item.path);
try {
if (isCurrentlyInCart) {
const cartItem = restoreCartItems.find(i => i.file_path === item.path);
if (cartItem) {
await removeFromCartRestoresCartItemIdDelete({
path: { item_id: cartItem.id }
});
toast.info(`Removed ${item.name} from restore cart`);
}
} else {
// Fetch metadata to get the DB ID
const metaResponse = await getItemMetadataInventoryMetadataGet({
query: { path: item.path }
});
if (metaResponse.data?.id) {
await addToCartRestoresCartFileIdPost({
path: { file_id: metaResponse.data.id }
});
toast.success(`Added ${item.name} to restore cart`);
}
}
await loadCart();
// Refresh file list for checkbox state
loadIndexedFiles(currentPath);
} catch (error: any) {
toast.error(error.body?.detail || "Action failed");
}
}
async function handleToggleDirectoryCart(itemPath: string) {
try {
const response = await addDirectoryToCartRestoresCartDirectoryPost({
body: { path: itemPath }
});
toast.success((response.data as any)?.message || "Folder contents added to cart");
await loadCart();
loadIndexedFiles(currentPath);
} catch (error: any) {
toast.error(error.body?.detail || "Action failed");
}
}
onMount(() => {
loadCart();
loadIndexedFiles(currentPath);
});
$effect(() => {
if (currentPath) {
loadIndexedFiles(currentPath);
}
});
function formatSize(bytes: number) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let unitIndex = 0;
let size = bytes;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
</script>
<svelte:head>
<title>Index Browser - TapeHoard</title>
</svelte:head>
<div class="flex flex-col gap-6 h-full overflow-hidden">
<!-- INTEGRATED HEADER -->
<header class="flex justify-between items-center bg-bg-secondary px-8 py-5 rounded-xl border border-border-color shadow-2xl relative overflow-hidden shrink-0">
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-transparent pointer-events-none"></div>
<div class="relative z-10">
<h1 class="text-2xl font-black uppercase tracking-tighter text-text-primary flex items-center gap-3">
<Library class="text-blue-500" size={28} />
Index Browser
</h1>
<p class="text-[12px] font-bold uppercase tracking-widest text-text-secondary mt-1 opacity-80">
Unified Filesystem View across all media
</p>
</div>
{#if restoreCartItems.length > 0}
<div class="flex items-center gap-4 z-10 animate-in fade-in zoom-in duration-300">
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary bg-bg-primary px-3 py-1.5 rounded-full border border-border-color">
{restoreCartItems.length} items in cart
</span>
<Button variant="default" class="bg-success-color hover:bg-success-color/90 text-white font-black uppercase tracking-widest text-[11px] px-6 h-10 shadow-lg shadow-success-color/20" href="/restores">
Review Restore Manifest
</Button>
</div>
{/if}
</header>
<div class="flex gap-6 flex-1 min-h-0 animate-in fade-in slide-in-from-bottom-2 duration-500 overflow-hidden">
<!-- Virtual FS Browser -->
<div class="flex-1 flex flex-col min-h-0 relative min-w-0">
{#if loading}
<div class="absolute inset-0 bg-bg-primary/50 z-50 flex items-center justify-center rounded-lg">
<RotateCw size={32} class="animate-spin text-blue-500" />
</div>
{/if}
<FileBrowser
bind:currentPath
files={indexedFiles}
mode="index"
onNavigate={(path) => currentPath = path}
onToggleTrack={handleToggleCart}
onSelect={fetchMetadata}
/>
</div>
<!-- Metadata Sidebar -->
<aside class="w-96 flex flex-col gap-4 shrink-0">
{#if selectedItemMetadata}
<Card class="flex-1 overflow-hidden flex flex-col bg-bg-secondary border-border-color shadow-2xl relative">
<div class="p-6 border-b border-border-color bg-bg-tertiary/30">
<div class="flex justify-between items-start mb-4">
<div class="p-3 bg-blue-500/10 rounded-xl text-blue-500 border border-blue-500/20">
{#if selectedItemMetadata.type === 'directory'}
<Folder size={24} />
{:else}
<FileText size={24} />
{/if}
</div>
<button class="text-text-secondary hover:text-text-primary transition-colors" onclick={() => selectedItemMetadata = null}>
<X size={20} />
</button>
</div>
<h3 class="text-lg font-black text-text-primary leading-tight truncate" title={selectedItemMetadata.file_path}>
{selectedItemMetadata.file_path.split('/').pop()}
</h3>
<p class="text-[10px] mono text-text-secondary mt-1 opacity-60 truncate italic">{selectedItemMetadata.file_path}</p>
</div>
<ScrollArea class="flex-1">
<div class="p-6 space-y-8">
<!-- Core Stats -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<span class="text-[9px] font-black uppercase tracking-widest text-text-secondary opacity-50 block">
{selectedItemMetadata.type === 'directory' ? 'Total Aggregate Size' : 'File Size'}
</span>
<span class="text-sm font-bold text-text-primary mono">{formatSize(selectedItemMetadata.size)}</span>
</div>
<div class="space-y-1">
<span class="text-[9px] font-black uppercase tracking-widest text-text-secondary opacity-50 block">Last Indexed</span>
<span class="text-sm font-bold text-text-primary mono">{new Date(selectedItemMetadata.last_seen_timestamp).toLocaleDateString()}</span>
</div>
{#if selectedItemMetadata.type === 'directory'}
<div class="space-y-1 col-span-2 mt-2">
<span class="text-[9px] font-black uppercase tracking-widest text-text-secondary opacity-50 block">Recursive Child Count</span>
<span class="text-sm font-bold text-text-primary mono">{selectedItemMetadata.child_count?.toLocaleString()} Indexed Files</span>
</div>
{/if}
</div>
{#if selectedItemMetadata.type === 'file'}
<!-- Hash -->
<div class="space-y-2">
<span class="text-[9px] font-black uppercase tracking-widest text-text-secondary opacity-50 block">SHA-256 Fingerprint</span>
<div class="bg-bg-primary p-3 rounded-lg border border-border-color/50 break-all mono text-[10px] text-blue-400/80 leading-relaxed">
{selectedItemMetadata.sha256_hash || 'Pending computation...'}
</div>
</div>
<!-- Backup Locations -->
<div class="space-y-4">
<div class="flex items-center gap-2">
<ShieldCheck size={14} class="text-success-color" />
<span class="text-[10px] font-black uppercase tracking-widest text-text-primary">Storage Locations</span>
</div>
<div class="space-y-2">
{#each selectedItemMetadata.versions || [] as version}
<div class="bg-bg-primary/50 border border-border-color rounded-lg p-3 group hover:border-blue-500/30 transition-all">
<div class="flex justify-between items-center mb-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded bg-blue-500/10 text-blue-400 text-[10px] font-black border border-blue-500/20">
{version.media_identifier}
</span>
<span class="text-[9px] font-bold text-text-secondary opacity-40 uppercase tracking-tighter">
{version.media_type}
</span>
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 text-[10px] text-text-secondary">
<FolderTree size={12} class="opacity-50" />
<span class="mono">POS: {version.file_number}</span>
</div>
<div class="flex items-center gap-2 text-[10px] text-text-secondary">
<Clock size={12} class="opacity-50" />
<span>Archived: {new Date(version.timestamp).toLocaleString()}</span>
</div>
</div>
</div>
{:else}
<div class="py-8 text-center border-2 border-dashed border-border-color rounded-xl opacity-30">
<p class="text-[10px] font-black uppercase tracking-widest">No versions stored on media.</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
</ScrollArea>
{#if selectedItemMetadata.type === 'file' && (selectedItemMetadata.versions?.length ?? 0) > 0}
<div class="p-6 bg-bg-tertiary/30 border-t border-border-color mt-auto">
<Button class="w-full h-11 font-black uppercase tracking-widest text-[11px] shadow-lg shadow-blue-500/10" onclick={() => handleToggleCart({path: selectedItemMetadata?.file_path || '', type: 'file', name: ''} as FileItem)}>
<ShieldCheck size={16} class="mr-2" />
{restoreCartPaths.has(selectedItemMetadata?.file_path || '') ? 'Remove from Cart' : 'Add to Restore Cart'}
</Button>
</div>
{:else if selectedItemMetadata.type === 'directory' && (selectedItemMetadata.child_count || 0) > 0}
<div class="p-6 bg-bg-tertiary/30 border-t border-border-color mt-auto">
<Button variant="outline" class="w-full h-11 font-black uppercase tracking-widest text-[11px] border-blue-500/30 text-blue-400 hover:bg-blue-500/10" onclick={() => handleToggleDirectoryCart(selectedItemMetadata?.file_path || '')}>
<ListPlus size={16} class="mr-2" />
Add Folder Contents to Cart
</Button>
</div>
{/if}
</Card>
{:else}
<div class="flex-1 border-2 border-dashed border-border-color rounded-xl flex flex-col items-center justify-center p-12 text-center opacity-20">
<Library size={48} class="mb-4 text-blue-500" />
<p class="textxs font-black uppercase tracking-widest leading-relaxed">
Select an item from the index<br>to view detailed metadata and<br>storage locations.
</p>
</div>
{/if}
</aside>
</div>
</div>
+361 -171
View File
@@ -1,191 +1,381 @@
<script lang="ts">
import { Plus, CassetteTape, HardDrive, Cloud, MapPin, Edit3, Scissors } from 'lucide-svelte';
import { onMount } from 'svelte';
import {
Plus,
CassetteTape,
HardDrive,
Cloud,
MapPin,
Edit3,
Database,
ShieldCheck,
RotateCw,
Trash2,
X,
Save,
Globe,
Monitor,
PlayCircle
} from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { cn } from '$lib/utils';
import {
listMediaInventoryMediaGet,
registerMediaInventoryMediaPost,
deleteMediaInventoryMediaMediaIdDelete,
triggerBackupBackupsTriggerMediaIdPost,
type MediaSchema
} from '$lib/api';
import { toast } from 'svelte-sonner';
// Mock Data
const mediaList = [
{ id: 1, type: 'LTO-6', identifier: 'BUP-00001', capacity: 2500, used: 2450, status: 'Full', location: 'Bank Vault' },
{ id: 2, type: 'LTO-6', identifier: 'BUP-00002', capacity: 2500, used: 1200, status: 'Active', location: 'In Drive' },
{ id: 3, type: 'HDD', identifier: 'HDD-001', capacity: 8000, used: 4000, status: 'Active', location: 'Shelf 2' },
{ id: 4, type: 'Cloud', identifier: 's3://my-backups', capacity: Infinity, used: 1500, status: 'Active', location: 'AWS-East' }
];
let mediaList = $state<MediaSchema[]>([]);
let loading = $state(true);
let showRegisterDialog = $state(false);
// New Media Form State
let newMedia = $state({
media_type: 'tape',
identifier: '',
generation_tier: 'LTO-6',
capacity_gb: 2500,
location: 'Storage Shelf',
// Type-specific config
device_path: '/dev/nst0', // For Tape
mount_path: '', // For HDD
bucket_name: '', // For Cloud
cloud_provider: 'AWS S3',
cloud_region: 'us-east-1',
endpoint_url: '' // For Custom S3
});
async function loadMedia() {
loading = true;
try {
const response = await listMediaInventoryMediaGet();
if (response.data) {
mediaList = response.data;
}
} catch (error) {
toast.error("Failed to load media fleet");
} finally {
loading = false;
}
}
async function handleStartBackup(mediaId: number, identifier: string) {
try {
await triggerBackupBackupsTriggerMediaIdPost({
path: { media_id: mediaId }
});
toast.success(`Backup job initiated for ${identifier}`);
} catch (error: any) {
toast.error(error.body?.detail || "Failed to start backup");
}
}
async function handleRegister() {
if (!newMedia.identifier) {
toast.error("Identifier is required");
return;
}
const config: Record<string, any> = {};
if (newMedia.media_type === 'tape') {
config.device_path = newMedia.device_path;
} else if (newMedia.media_type === 'hdd') {
if (!newMedia.mount_path) { toast.error("Mount path required"); return; }
config.mount_path = newMedia.mount_path;
} else if (newMedia.media_type === 'cloud') {
if (!newMedia.bucket_name) { toast.error("Bucket name required"); return; }
config.bucket_name = newMedia.bucket_name;
config.provider = newMedia.cloud_provider;
config.region = newMedia.cloud_region;
if (newMedia.endpoint_url) config.endpoint_url = newMedia.endpoint_url;
}
try {
await registerMediaInventoryMediaPost({
body: {
media_type: newMedia.media_type,
identifier: newMedia.identifier,
generation_tier: newMedia.generation_tier,
capacity: newMedia.capacity_gb * 1024 * 1024 * 1024,
location: newMedia.location,
config: config
}
});
toast.success(`${newMedia.identifier} registered`);
showRegisterDialog = false;
loadMedia();
newMedia.identifier = '';
newMedia.mount_path = '';
} catch (error: any) {
toast.error(error.body?.detail || "Registration failed");
}
}
async function handleDelete(mediaId: number) {
if (!confirm("Remove this media?")) return;
try {
await deleteMediaInventoryMediaMediaIdDelete({
path: { media_id: mediaId }
});
toast.success("Removed");
loadMedia();
} catch (error: any) {
toast.error(error.body?.detail || "Failed");
}
}
onMount(loadMedia);
function getPercentage(used: number, capacity: number) {
if (capacity === Infinity) return 0;
if (capacity === 0) return 0;
return Math.min(100, Math.round((used / capacity) * 100));
}
function formatSize(bytes: number) {
if (bytes === 0) return "0 GB";
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1000) return `${(gb / 1024).toFixed(1)} TB`;
return `${gb.toFixed(0)} GB`;
}
const totalCapacity = $derived(mediaList.reduce((acc, m) => acc + m.capacity, 0));
const totalUsed = $derived(mediaList.reduce((acc, m) => acc + m.bytes_used, 0));
const globalUtilization = $derived(totalCapacity > 0 ? Math.round((totalUsed / totalCapacity) * 100) : 0);
</script>
<svelte:head>
<title>Inventory - TapeHoard</title>
<title>Physical Media - TapeHoard</title>
</svelte:head>
<div class="flex justify-between items-center mb-8 bg-bg-secondary p-6 rounded-lg border border-border-color shadow-lg">
<div>
<h1 class="text-3xl font-bold tracking-tight text-text-primary">Global Inventory</h1>
<p class="text-text-secondary mt-1">Manage physical and cloud storage media across all locations.</p>
</div>
<button class="btn btn-primary h-11 px-8"><Plus size={18} class="mr-2" /> Register New Media</button>
<div class="flex flex-col gap-6 relative">
<header class="flex justify-between items-center bg-bg-secondary px-8 py-5 rounded-xl border border-border-color shadow-2xl relative overflow-hidden shrink-0">
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-transparent pointer-events-none"></div>
<div class="relative z-10">
<h1 class="text-2xl font-black uppercase tracking-tighter text-text-primary flex items-center gap-3">
<CassetteTape class="text-blue-500" size={28} />
Physical Media
</h1>
<p class="text-[12px] font-bold uppercase tracking-widest text-text-secondary mt-1 opacity-80">Inventory & Media Configuration</p>
</div>
<Button variant="default" size="lg" class="px-8 h-12 font-black uppercase tracking-widest text-[11px] z-10" onclick={() => showRegisterDialog = true}>
<Plus size={18} class="mr-2" /> Register New Media
</Button>
</header>
{#if loading && mediaList.length === 0}
<div class="flex flex-col items-center justify-center py-24 gap-4 opacity-50">
<RotateCw size={48} class="animate-spin text-blue-500" />
<span class="text-xs font-black uppercase tracking-widest">Auditing Fleet Status...</span>
</div>
{:else}
<div class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-500 flex-1">
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card class="bg-gradient-to-br from-bg-secondary to-bg-tertiary border-border-color p-5 flex items-center gap-4 shadow-xl">
<div class="p-3 bg-blue-500/10 rounded-xl text-blue-500 border border-blue-500/20"><CassetteTape size={24} /></div>
<div><span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Total Media</span><span class="text-2xl font-black text-text-primary mono tracking-tighter">{mediaList.length}</span></div>
</Card>
<Card class="bg-gradient-to-br from-bg-secondary to-bg-tertiary border-border-color p-5 flex items-center gap-4 shadow-xl">
<div class="p-3 bg-action-color/10 rounded-xl text-action-color border border-action-color/20"><Database size={24} /></div>
<div><span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Fleet Capacity</span><span class="text-2xl font-black text-text-primary mono tracking-tighter">{formatSize(totalCapacity)}</span></div>
</Card>
<Card class="bg-gradient-to-br from-bg-secondary to-bg-tertiary border-border-color p-5 flex items-center gap-4 shadow-xl">
<div class="p-3 bg-success-color/10 rounded-xl text-success-color border border-success-color/20"><ShieldCheck size={24} /></div>
<div><span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Active Usage</span><span class="text-2xl font-black text-text-primary mono tracking-tighter">{formatSize(totalUsed)}</span></div>
</Card>
<Card class="bg-gradient-to-br from-bg-secondary to-bg-tertiary border-border-color p-5 flex items-center gap-4 shadow-xl">
<div class="p-3 bg-orange-500/10 rounded-xl text-orange-500 border border-orange-500/20"><RotateCw size={24} /></div>
<div><span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Utilization</span><span class="text-2xl font-black text-text-primary mono tracking-tighter">{globalUtilization}%</span></div>
</Card>
</div>
<!-- Table -->
<Card class="overflow-hidden border-border-color bg-bg-secondary shadow-2xl">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-bg-tertiary/50 border-b border-border-color">
<th class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-text-secondary">Identifier</th>
<th class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-text-secondary">Spec / Tier</th>
<th class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-text-secondary">System Config</th>
<th class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-text-secondary">Usage</th>
<th class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-text-secondary">Lifecycle</th>
<th class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-text-secondary text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border-color/40">
{#each mediaList as media (media.id)}
<tr class="hover:bg-white/[0.02] transition-colors group">
<td class="px-8 py-5">
<div class="flex flex-col">
<span class="mono font-black text-text-primary text-sm">{media.identifier}</span>
<span class="text-[9px] font-bold text-text-secondary/50 uppercase tracking-tighter">LOC: {media.location || 'Unknown'}</span>
</div>
</td>
<td class="px-8 py-5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-bg-primary text-text-primary text-[10px] font-black border border-border-color uppercase">
{#if media.media_type === 'tape'}<CassetteTape size={12} class="text-blue-400" />{:else if media.media_type === 'hdd'}<HardDrive size={12} class="text-yellow-400" />{:else}<Cloud size={12} class="text-green-400" />{/if}
{media.generation_tier || media.media_type}
</span>
</td>
<td class="px-8 py-5">
<div class="flex flex-col gap-1">
{#if media.media_type === 'tape' && media.config.device_path}
<div class="flex items-center gap-1.5 text-[10px] font-bold mono text-text-secondary"><Monitor size={10} class="opacity-50" /> {media.config.device_path}</div>
{:else if media.media_type === 'hdd' && media.config.mount_path}
<div class="flex items-center gap-1.5 text-[10px] font-bold mono text-text-secondary"><HardDrive size={10} class="opacity-50" /> {media.config.mount_path}</div>
{:else if media.media_type === 'cloud' && media.config.bucket_name}
<div class="flex items-center gap-1.5 text-[10px] font-bold mono text-text-secondary"><Globe size={10} class="opacity-50" /> {media.config.bucket_name}</div>
{:else}
<span class="text-[9px] font-bold uppercase tracking-tighter text-text-secondary opacity-30">No config</span>
{/if}
</div>
</td>
<td class="px-8 py-5">
<div class="flex flex-col gap-2 w-40">
<div class="w-full bg-bg-primary rounded-full h-1.5 overflow-hidden shadow-inner border border-white/5">
<div class={cn("h-full transition-all", media.status === 'full' ? 'bg-error-color' : 'bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.3)]')} style="width: {getPercentage(media.bytes_used, media.capacity)}%"></div>
</div>
<span class="mono text-[9px] font-bold text-text-secondary opacity-70">{formatSize(media.bytes_used)} / {formatSize(media.capacity)}</span>
</div>
</td>
<td class="px-8 py-5">
<div class="flex items-center gap-2 text-[11px] font-black uppercase tracking-wider text-text-primary">
<div class={cn("w-2.5 h-2.5 rounded-full", media.status === 'active' ? 'bg-success-color shadow-[0_0_10px_rgba(46,204,113,0.5)]' : 'bg-error-color')}></div>
{media.status}
</div>
</td>
<td class="px-8 py-5 text-right">
<div class="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-all">
{#if media.status === 'active'}
<Button
variant="secondary"
size="sm"
class="h-9 px-4 font-black uppercase tracking-widest text-[9px] border-blue-500/30 text-blue-400 hover:bg-blue-500/10"
onclick={() => handleStartBackup(media.id, media.identifier)}
>
<PlayCircle size={14} class="mr-1.5" /> Start Backup
</Button>
{/if}
<Button variant="ghost" size="icon" class="h-9 w-9 hover:bg-error-color/10 hover:text-error-color" onclick={() => handleDelete(media.id)}><Trash2 size={16} /></Button>
</div>
</td>
</tr>
{:else}
<tr><td colspan="6" class="px-8 py-24 text-center opacity-20"><Database size={48} class="mx-auto mb-3" /><p class="text-sm font-black uppercase tracking-[0.2em]">No Media Assets Registered</p></td></tr>
{/each}
</tbody>
</table>
</div>
</Card>
</div>
{/if}
</div>
<div class="card stats-summary">
<h3>Storage Pool Summary</h3>
<div class="stats-grid">
<div class="stat-box">
<span class="label">Total Media</span>
<span class="value">{mediaList.length}</span>
</div>
<div class="stat-box">
<span class="label">Total Capacity</span>
<span class="value">13.0 TB</span>
</div>
<div class="stat-box">
<span class="label">Total Used</span>
<span class="value">9.1 TB</span>
</div>
<div class="stat-box">
<span class="label">Utilization</span>
<span class="value">70%</span>
</div>
</div>
</div>
<!-- REGISTRATION DIALOG -->
{#if showRegisterDialog}
<div class="fixed inset-0 z-[999] flex items-center justify-center p-4 overflow-y-auto bg-black/80 backdrop-blur-md">
<div class="absolute inset-0 cursor-pointer" onclick={() => showRegisterDialog = false} role="none"></div>
<Card class="relative z-[1000] w-[600px] bg-bg-secondary border-border-color shadow-[0_30px_150px_rgba(0,0,0,1)] overflow-hidden animate-in zoom-in-95 duration-300 my-auto">
<div class="p-8 border-b border-border-color bg-bg-tertiary/30">
<div class="flex justify-between items-center mb-2">
<h2 class="text-2xl font-black uppercase tracking-tighter text-text-primary">Register Media</h2>
<button class="text-text-secondary hover:text-text-primary" onclick={() => showRegisterDialog = false}><X size={24} /></button>
</div>
<p class="text-[11px] font-bold uppercase tracking-widest text-text-secondary opacity-60">Provision new physical storage unit</p>
</div>
<div class="card no-padding">
<table class="data-table">
<thead>
<tr>
<th>Identifier</th>
<th>Type</th>
<th>Capacity Used</th>
<th>Status</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each mediaList as media}
<tr>
<td><span class="mono"><strong>{media.identifier}</strong></span></td>
<td>
{#if media.type.startsWith('LTO')}
<span class="badge badge-tape"><CassetteTape size={12} style="margin-right: 4px;" /> {media.type}</span>
{:else if media.type === 'HDD'}
<span class="badge badge-hdd"><HardDrive size={12} style="margin-right: 4px;" /> {media.type}</span>
{:else}
<span class="badge badge-cloud"><Cloud size={12} style="margin-right: 4px;" /> {media.type}</span>
{/if}
</td>
<td>
<div class="progress-info">
<div class="progress-container">
<div class="progress-bar" style="width: {getPercentage(media.used, media.capacity)}%; background-color: {media.status === 'Full' ? 'var(--color-error-color)' : 'var(--color-action-color)'}"></div>
<div class="p-8 space-y-8">
<!-- Type Selection -->
<div class="grid grid-cols-3 gap-4">
{#each ['tape', 'hdd', 'cloud'] as type}
<button class={cn("flex flex-col items-center gap-3 p-4 rounded-xl border-2 transition-all", newMedia.media_type === type ? "bg-blue-500/10 border-blue-500 text-blue-400 shadow-[0_0_20px_rgba(59,130,246,0.15)]" : "bg-bg-primary/50 border-border-color text-text-secondary hover:border-text-secondary/30")} onclick={() => newMedia.media_type = type}>
{#if type === 'tape'}<CassetteTape size={24} />{/if}
{#if type === 'hdd'}<HardDrive size={24} />{/if}
{#if type === 'cloud'}<Cloud size={24} />{/if}
<span class="text-[10px] font-black uppercase tracking-widest">{type}</span>
</button>
{/each}
</div>
<div class="space-y-6">
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-secondary ml-1" for="identifier">Identifier (Barcode/SN)</label>
<Input id="identifier" bind:value={newMedia.identifier} placeholder="BUP-00001" class="h-12 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-secondary ml-1" for="capacity">Capacity (GB)</label>
<Input id="capacity" type="number" bind:value={newMedia.capacity_gb} class="h-12 bg-bg-primary/50 border-border-color font-mono" />
</div>
</div>
<!-- Type Specific Fields -->
{#if newMedia.media_type === 'tape'}
<div class="grid grid-cols-2 gap-6 animate-in slide-in-from-top-2">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-secondary ml-1" for="device">Tape Device Path</label>
<Input id="device" bind:value={newMedia.device_path} class="h-12 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-secondary ml-1" for="tier">Generation (LTO-6...)</label>
<Input id="tier" bind:value={newMedia.generation_tier} class="h-12 bg-bg-primary/50 border-border-color" />
</div>
<span class="mono">{media.used} GB / {media.capacity === Infinity ? '∞' : media.capacity + ' GB'}</span>
</div>
</td>
<td>
<div class="status-cell">
<span class="status-dot" class:active={media.status === 'Active'} class:full={media.status === 'Full'}></span>
{media.status}
{:else if newMedia.media_type === 'hdd'}
<div class="grid grid-cols-2 gap-6 animate-in slide-in-from-top-2">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-secondary ml-1" for="mount">System Mount Point</label>
<Input id="mount" bind:value={newMedia.mount_path} placeholder="/mnt/backup" class="h-12 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-secondary ml-1" for="tier">Drive Tier (SATA...)</label>
<Input id="tier" bind:value={newMedia.generation_tier} class="h-12 bg-bg-primary/50 border-border-color" />
</div>
</div>
</td>
<td>
<div class="location-cell">
<MapPin size={14} color="var(--color-text-secondary)" />
{media.location}
{:else if newMedia.media_type === 'cloud'}
<div class="space-y-4 animate-in slide-in-from-top-2">
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-secondary ml-1" for="bucket">Bucket Name</label>
<Input id="bucket" bind:value={newMedia.bucket_name} placeholder="my-backups" class="h-12 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-secondary ml-1" for="provider">Provider (S3/B2...)</label>
<Input id="provider" bind:value={newMedia.cloud_provider} class="h-12 bg-bg-primary/50 border-border-color" />
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-secondary ml-1" for="region">Region</label>
<Input id="region" bind:value={newMedia.cloud_region} class="h-12 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-secondary ml-1" for="endpoint">Endpoint URL (Optional)</label>
<Input id="endpoint" bind:value={newMedia.endpoint_url} placeholder="https://s3.us-west-004..." class="h-12 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
</div>
</div>
</td>
<td>
<div class="actions">
<button class="btn btn-secondary btn-icon-only" title="Edit"><Edit3 size={14} /></button>
{#if media.status === 'Full' && media.type.startsWith('LTO')}
<button class="btn btn-warning btn-icon-only" title="Groom Tape"><Scissors size={14} /></button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<style>
.no-padding {
padding: 0;
}
<div class="space-y-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-secondary ml-1" for="location">Physical Location</label>
<Input id="location" bind:value={newMedia.location} placeholder="e.g. Bank Vault" class="h-12 bg-bg-primary/50 border-border-color" />
</div>
</div>
</div>
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-lg);
margin-top: var(--spacing-md);
}
.stat-box .label {
color: var(--color-text-secondary);
font-size: 0.8rem;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
}
.stat-box .value {
display: block;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-text-primary);
margin-top: 0.25rem;
}
.progress-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.progress-info span {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.badge-tape { background-color: rgba(52, 152, 219, 0.15); color: #3498db; }
.badge-hdd { background-color: rgba(241, 196, 15, 0.15); color: #f1c40f; }
.badge-cloud { background-color: rgba(46, 204, 113, 0.15); color: #2ecc71; }
.status-cell {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--color-text-secondary);
}
.status-dot.active { background-color: var(--color-success-color); box-shadow: 0 0 8px var(--color-success-color); }
.status-dot.full { background-color: var(--color-error-color); }
.location-cell {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--color-text-secondary);
}
.actions {
display: flex;
gap: var(--spacing-xs);
}
.btn-icon-only {
padding: 0.4rem;
}
.btn-warning {
background-color: rgba(243, 156, 18, 0.2);
color: #f39c12;
border: 1px solid rgba(243, 156, 18, 0.3);
}
</style>
<div class="p-8 bg-bg-tertiary/30 border-t border-border-color flex gap-4">
<Button variant="outline" class="flex-1 h-12 font-black uppercase tracking-widest text-[11px]" onclick={() => showRegisterDialog = false}>Cancel</Button>
<Button variant="default" class="flex-1 h-12 font-black uppercase tracking-widest text-[11px] shadow-lg shadow-blue-500/20" onclick={handleRegister}>
<Save size={18} class="mr-2" /> Commit to Fleet
</Button>
</div>
</Card>
</div>
{/if}
+217
View File
@@ -0,0 +1,217 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
Activity,
Clock,
RotateCw,
Search,
ExternalLink,
Play,
StopCircle
} from 'lucide-svelte';
import { Card } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { listJobsSystemJobsGet, cancelJobSystemJobsJobIdCancelPost, type JobSchema } from '$lib/api';
import { cn } from '$lib/utils';
import { toast } from 'svelte-sonner';
let jobs = $state<JobSchema[]>([]);
let loading = $state(true);
let pollInterval: any;
async function loadJobs() {
try {
const response = await listJobsSystemJobsGet();
if (response.data) {
jobs = response.data;
}
} catch (error) {
console.error("Failed to load jobs:", error);
} finally {
loading = false;
}
}
async function cancelJob(jobId: number) {
try {
await cancelJobSystemJobsJobIdCancelPost({
path: { job_id: jobId }
});
toast.info(`Cancellation requested for Job #${jobId}`);
await loadJobs();
} catch (error) {
toast.error("Failed to cancel job");
}
}
onMount(() => {
loadJobs();
pollInterval = setInterval(loadJobs, 2000);
});
onDestroy(() => {
if (pollInterval) clearInterval(pollInterval);
});
function getStatusColor(status: string) {
switch (status) {
case 'COMPLETED': return 'text-success-color bg-success-color/10 border-success-color/20';
case 'RUNNING': return 'text-blue-500 bg-blue-500/10 border-blue-500/20';
case 'FAILED': return 'text-error-color bg-error-color/10 border-error-color/20';
case 'PENDING': return 'text-text-secondary bg-bg-primary border-border-color';
default: return 'text-text-secondary bg-bg-primary';
}
}
function formatDuration(start?: string | null, end?: string | null) {
if (!start) return '--';
const startTime = new Date(start).getTime();
const endTime = end ? new Date(end).getTime() : Date.now();
const seconds = Math.floor((endTime - startTime) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remSeconds = seconds % 60;
return `${minutes}m ${remSeconds}s`;
}
</script>
<svelte:head>
<title>System Activity - TapeHoard</title>
</svelte:head>
<div class="space-y-8 animate-in fade-in duration-700">
<!-- HEADER -->
<header class="flex justify-between items-center bg-bg-secondary px-8 py-5 rounded-xl border border-border-color shadow-2xl relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-transparent pointer-events-none"></div>
<div class="relative z-10">
<h1 class="text-2xl font-black uppercase tracking-tighter text-text-primary flex items-center gap-3">
<Activity class="text-blue-500" size={28} />
System Activity
</h1>
<p class="text-[12px] font-bold uppercase tracking-widest text-text-secondary mt-1 opacity-80">
Real-time task monitoring & operational history
</p>
</div>
<div class="flex gap-4 z-10">
<div class="flex items-center gap-2 px-4 py-2 bg-bg-primary rounded-lg border border-border-color shadow-inner">
<div class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary">
{jobs.filter(j => j.status === 'RUNNING').length} Active Tasks
</span>
</div>
</div>
</header>
{#if loading && jobs.length === 0}
<div class="flex flex-col items-center justify-center py-24 gap-4 opacity-50">
<RotateCw size={48} class="animate-spin text-blue-500" />
<span class="text-xs font-black uppercase tracking-widest">Hydrating Task Pipeline...</span>
</div>
{:else}
<div class="grid grid-cols-1 gap-4">
{#each jobs as job (job.id)}
<Card class="p-6 bg-bg-secondary border-border-color hover:border-blue-500/30 transition-all group overflow-hidden relative">
<div class="flex flex-col md:flex-row md:items-center gap-6 relative z-10">
<!-- Type & Status -->
<div class="flex items-center gap-4 min-w-[240px]">
<div class={cn(
"p-3 rounded-xl border shadow-inner shrink-0",
job.status === 'RUNNING' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' : 'bg-bg-primary text-text-secondary border-border-color/50'
)}>
{#if job.job_type === 'SCAN'}
<Search size={24} />
{:else if job.job_type === 'BACKUP'}
<Play size={24} />
{:else}
<RotateCw size={24} />
{/if}
</div>
<div>
<h3 class="font-black text-text-primary uppercase tracking-tighter text-lg leading-none mb-2">
{job.job_type} JOB #{job.id}
</h3>
<div class={cn("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", getStatusColor(job.status))}>
<span class="font-black uppercase tracking-widest text-[9px]">{job.status}</span>
</div>
</div>
</div>
<!-- Progress Section -->
<div class="flex-1 space-y-3">
<div class="flex justify-between items-end">
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary truncate max-w-[300px]">
{job.current_task || 'Waiting in queue...'}
</span>
<span class="text-xs font-bold mono text-text-primary">
{job.progress.toFixed(1)}%
</span>
</div>
<div class="w-full bg-bg-primary h-2 rounded-full border border-border-color shadow-inner overflow-hidden">
<div
class={cn(
"h-full transition-all duration-500",
job.status === 'RUNNING' ? 'bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.3)]' :
job.status === 'FAILED' ? 'bg-error-color' : 'bg-success-color'
)}
style="width: {job.progress}%"
></div>
</div>
</div>
<!-- Timing Stats -->
<div class="grid grid-cols-2 gap-8 min-w-[200px]">
<div>
<span class="text-[9px] font-black uppercase tracking-widest text-text-secondary opacity-50 block mb-1">Duration</span>
<div class="flex items-center gap-1.5 text-xs font-bold text-text-primary mono">
<Clock size={12} class="text-text-secondary" />
{formatDuration(job.started_at, job.completed_at)}
</div>
</div>
<div>
<span class="text-[9px] font-black uppercase tracking-widest text-text-secondary opacity-50 block mb-1">Created</span>
<div class="text-xs font-bold text-text-primary mono">
{new Date(job.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
{#if job.status === 'RUNNING' || job.status === 'PENDING'}
<button
class="text-error-color hover:bg-error-color/10 font-black uppercase tracking-widest text-[9px] px-3 h-8 rounded-lg border border-transparent hover:border-error-color/20 transition-all flex items-center"
onclick={() => cancelJob(job.id)}
>
<StopCircle size={14} class="mr-1.5" /> Cancel Task
</button>
{/if}
<Button variant="ghost" size="icon" class="h-8 w-8 hover:bg-white/5">
<ExternalLink size={16} class="text-text-secondary group-hover:text-text-primary transition-colors" />
</Button>
</div>
</div>
{#if job.error_message}
<div class="mt-4 p-4 bg-error-color/5 border border-dashed border-error-color/20 rounded-lg flex gap-3 items-start animate-in slide-in-from-top-2">
<RotateCw size={16} class="text-error-color shrink-0 mt-0.5" />
<p class="text-[11px] font-medium text-error-color/80 leading-relaxed italic">
{job.error_message}
</p>
</div>
{/if}
{#if job.status === 'RUNNING'}
<div class="absolute top-0 right-0 w-24 h-24 bg-blue-500/5 blur-3xl rounded-full -mr-12 -mt-12 animate-pulse"></div>
{/if}
</Card>
{:else}
<div class="flex flex-col items-center justify-center py-24 border-2 border-dashed border-border-color rounded-2xl opacity-30">
<Activity size={48} class="mb-4" />
<p class="text-sm font-black uppercase tracking-widest">No historical tasks found.</p>
</div>
{/each}
</div>
{/if}
</div>
+194 -185
View File
@@ -1,88 +1,88 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Play,
History,
Trash2,
CassetteTape,
ChevronRight,
CheckCircle2,
AlertCircle,
Download,
RotateCw,
Database,
CassetteTape,
HardDrive,
ArrowRight,
X,
Search
FileText,
Info,
ShieldCheck,
MapPin
} from 'lucide-svelte';
import { fade, fly } from 'svelte/transition';
import FileBrowser from '$lib/components/file-browser/FileBrowser.svelte';
import type { FileItem } from '$lib/types';
import { browseIndexInventoryBrowseGet } from '$lib/api/sdk.gen';
import { Button } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import {
listCartRestoresCartGet,
getManifestRestoresManifestGet,
removeFromCartRestoresCartItemIdDelete,
clearCartRestoresCartClearPost,
getSettingsSystemSettingsGet,
type CartItemSchema,
type RestoreManifestSchema
} from '$lib/api';
import { cn } from '$lib/utils';
import { toast } from 'svelte-sonner';
// Wizard State
let currentStep = $state(1);
let cartItems = $state<CartItemSchema[]>([]);
let manifest = $state<RestoreManifestSchema | null>(null);
let restoreDests = $state<string[]>([]);
let selectedDest = $state("");
let loading = $state(true);
// File Browser State
let currentPath = $state('/');
let indexedFiles = $state<FileItem[]>([]);
let loading = $state(false);
// Restore Cart (Selected Files)
let restoreCart = $state<FileItem[]>([]);
async function loadIndexedFiles(path: string) {
async function loadData() {
loading = true;
try {
const response = await browseIndexInventoryBrowseGet({
query: { path }
});
if (response.data) {
// Map API response and preserve selection state if already in cart
indexedFiles = response.data.map(f => ({
...f,
type: f.type as 'file' | 'directory' | 'link',
selected: restoreCart.some(cartItem => cartItem.path === f.path)
}));
const [cartRes, manifestRes, settingsRes] = await Promise.all([
listCartRestoresCartGet(),
getManifestRestoresManifestGet(),
getSettingsSystemSettingsGet()
]);
if (cartRes.data) cartItems = cartRes.data;
if (manifestRes.data) manifest = manifestRes.data;
if (settingsRes.data?.restore_destinations) {
restoreDests = JSON.parse(settingsRes.data.restore_destinations);
if (restoreDests.length > 0) selectedDest = restoreDests[0];
}
} catch (error) {
console.error("Failed to load indexed files:", error);
toast.error("Failed to load restore details");
} finally {
loading = false;
}
}
onMount(() => {
loadIndexedFiles(currentPath);
});
$effect(() => {
if (currentPath) {
loadIndexedFiles(currentPath);
}
});
function handleToggleSelect(item: FileItem) {
const index = restoreCart.findIndex(i => i.path === item.path);
if (index > -1) {
restoreCart = restoreCart.filter((_, i) => i !== index);
item.selected = false;
} else {
restoreCart = [...restoreCart, { ...item, selected: true }];
item.selected = true;
async function removeItem(itemId: number) {
try {
await removeFromCartRestoresCartItemIdDelete({ path: { item_id: itemId } });
await loadData();
} catch (error) {
toast.error("Failed to remove item");
}
}
function removeFromCart(path: string) {
restoreCart = restoreCart.filter(i => i.path !== path);
// Update selection in current view if visible
const visibleItem = indexedFiles.find(f => f.path === path);
if (visibleItem) visibleItem.selected = false;
async function clearCart() {
if (!confirm("Clear entire restore cart?")) return;
try {
await clearCartRestoresCartClearPost();
await loadData();
toast.info("Cart cleared");
} catch (error) {
toast.error("Failed to clear cart");
}
}
const totalSize = $derived(restoreCart.reduce((acc, item) => acc + (item.size || 0), 0));
const requiredMedia = $derived([...new Set(restoreCart.flatMap(item => item.media || []))]);
onMount(loadData);
function formatSize(bytes: number) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let unitIndex = 0;
let size = bytes;
while (size >= 1024 && unitIndex < units.length - 1) {
@@ -91,161 +91,170 @@
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
function nextStep() {
if (currentStep < 4) currentStep++;
}
function cancel() {
currentStep = 1;
}
</script>
<svelte:head>
<title>Restore Wizard - TapeHoard</title>
<title>Restore Management - TapeHoard</title>
</svelte:head>
<div class="flex flex-col gap-6 h-full">
<!-- HEADER -->
<header class="flex justify-between items-center bg-bg-secondary px-8 py-5 rounded-xl border border-border-color shadow-2xl relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-transparent pointer-events-none"></div>
<div class="absolute inset-0 bg-gradient-to-r from-success-color/5 to-transparent pointer-events-none"></div>
<div class="relative z-10">
<h1 class="text-2xl font-black uppercase tracking-tighter text-text-primary flex items-center gap-3">
<RotateCw class="text-blue-500" size={28} />
Restore Wizard
<History class="text-success-color" size={28} />
Restore Management
</h1>
<p class="text-[12px] font-bold uppercase tracking-widest text-text-secondary mt-1 opacity-80">
Step {currentStep} of 4: {['Browse & Select', 'Insert Media', 'Swap Media', 'Finalize'][currentStep-1]}
Cart Review & Physical Media Manifest
</p>
</div>
{#if currentStep === 1}
<div class="flex gap-4 relative z-10">
<Button variant="default" size="lg" class="px-8 h-12" disabled={restoreCart.length === 0} onclick={nextStep}>
<Play size={20} class="mr-2" />
Execute Restore
</Button>
</div>
{/if}
<div class="flex gap-3 z-10">
<Button variant="outline" class="h-10 px-6 font-black uppercase tracking-widest text-[10px] border-border-color hover:bg-error-color/5 hover:text-error-color hover:border-error-color/30" onclick={clearCart} disabled={cartItems.length === 0}>
<Trash2 size={14} class="mr-2" /> Clear Cart
</Button>
<Button variant="default" class="h-10 px-6 font-black uppercase tracking-widest text-[10px] bg-success-color hover:bg-success-color/90" disabled={cartItems.length === 0 || !selectedDest}>
<ShieldCheck size={14} class="mr-2" /> Initiate Restore
</Button>
</div>
</header>
{#if currentStep === 1}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
<!-- LEFT: VIRTUAL FILESYSTEM BROWSER -->
<div class="lg:col-span-2 flex flex-col min-h-0 relative">
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-bold uppercase tracking-widest text-text-secondary">Virtual Filesystem</h3>
<span class="text-[10px] bg-white/5 px-2 py-1 rounded text-text-secondary font-mono">Indexing: {currentPath}</span>
</div>
{#if loading}
<div class="absolute inset-0 bg-bg-primary/50 z-50 flex items-center justify-center top-8">
<span class="text-text-secondary animate-pulse">Querying Index...</span>
{#if loading && cartItems.length === 0}
<div class="flex flex-col items-center justify-center py-24 gap-4 opacity-50">
<RotateCw size={48} class="animate-spin text-success-color" />
<span class="text-xs font-black uppercase tracking-widest">Generating Manifest...</span>
</div>
{:else if cartItems.length === 0}
<Card class="flex-1 border-2 border-dashed border-border-color flex flex-col items-center justify-center p-12 text-center opacity-30">
<History size={64} class="mb-4" strokeWidth={1} />
<p class="text-lg font-black uppercase tracking-widest">Restore Cart is Empty</p>
<p class="text-[11px] font-bold uppercase tracking-[0.2em] mt-2">Go to the Index Browser to select files for recovery.</p>
<Button variant="outline" class="mt-8 border-border-color" href="/index-browser">
Browse Index <ArrowRight size={14} class="ml-2" />
</Button>
</Card>
{:else}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 flex-1 min-h-0">
<!-- CART LIST -->
<div class="lg:col-span-2 flex flex-col min-h-0">
<Card class="flex-1 overflow-hidden flex flex-col bg-bg-secondary border-border-color shadow-xl">
<div class="p-6 border-b border-border-color flex justify-between items-center bg-bg-tertiary/30">
<h3 class="text-[11px] font-black uppercase tracking-widest text-text-primary">Queued for Restore ({cartItems.length})</h3>
<span class="text-xs font-bold mono text-text-secondary">{formatSize(manifest?.total_size || 0)}</span>
</div>
{/if}
<FileBrowser
bind:currentPath
files={indexedFiles}
mode="index"
onNavigate={(path) => currentPath = path}
onToggleTrack={handleToggleSelect}
/>
<ScrollArea class="flex-1">
<div class="divide-y divide-border-color/30">
{#each cartItems as item (item.id)}
<div class="p-4 flex items-center justify-between hover:bg-white/[0.02] transition-colors group">
<div class="flex items-center gap-4 min-w-0">
<div class="p-2 bg-bg-primary rounded-lg border border-border-color/50 text-text-secondary">
<FileText size={18} />
</div>
<div class="flex flex-col min-w-0">
<span class="text-[13px] font-bold text-text-primary truncate">{item.file_path.split('/').pop()}</span>
<span class="text-[10px] mono text-text-secondary/50 truncate italic">{item.file_path}</span>
</div>
</div>
<div class="flex items-center gap-6 shrink-0">
<div class="flex gap-1">
{#each item.media_identifiers as media}
<span class="text-[9px] font-black uppercase tracking-tighter bg-blue-500/10 text-blue-400 px-2 py-0.5 rounded border border-blue-500/20">{media}</span>
{/each}
</div>
<span class="text-xs font-bold mono text-text-secondary w-20 text-right">{formatSize(item.size)}</span>
<button class="text-text-secondary hover:text-error-color opacity-0 group-hover:opacity-100 transition-all p-1" onclick={() => removeItem(item.id)}>
<X size={16} />
</button>
</div>
</div>
{/each}
</div>
</ScrollArea>
</Card>
</div>
<!-- RIGHT: RESTORE CART -->
<div class="flex flex-col gap-4 min-h-0">
<div class="bg-bg-secondary border border-border-color rounded-xl p-6 flex flex-col h-full shadow-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-black uppercase tracking-tight text-text-primary flex items-center gap-2">
<Download size={20} class="text-blue-400" />
Restore Cart
</h3>
<span class="bg-blue-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full">
{restoreCart.length}
</span>
<!-- MANIFEST SIDEBAR -->
<div class="flex flex-col gap-6">
<!-- Recovery Destination Card -->
<Card class="p-8 bg-bg-secondary border-border-color shadow-xl">
<h3 class="text-xs font-black uppercase tracking-widest text-text-primary mb-6 flex items-center gap-2">
<MapPin size={14} class="text-action-color" />
Recovery Destination
</h3>
<div class="space-y-4">
<div class="grid grid-cols-1 gap-2">
{#each restoreDests as dest}
<button
class={cn(
"flex items-center gap-3 p-3 rounded-lg border transition-all text-left group",
selectedDest === dest
? "bg-action-color/10 border-action-color text-text-primary shadow-[0_0_15px_rgba(52,152,219,0.1)]"
: "bg-bg-primary/50 border-border-color text-text-secondary hover:border-text-secondary/30"
)}
onclick={() => selectedDest = dest}
>
<div class={cn(
"w-2 h-2 rounded-full",
selectedDest === dest ? "bg-action-color animate-pulse" : "bg-border-color"
)}></div>
<span class="text-[11px] font-bold mono truncate">{dest}</span>
</button>
{:else}
<div class="p-4 border-2 border-dashed border-border-color rounded-lg text-center">
<p class="text-[10px] font-black uppercase tracking-widest text-text-secondary/50">No targets defined in settings</p>
</div>
{/each}
</div>
<p class="text-[9px] font-bold text-text-secondary/50 uppercase tracking-tight italic">Files will be extracted into this directory using their original folder structure.</p>
</div>
</Card>
<Card class="p-8 bg-gradient-to-br from-bg-secondary to-bg-tertiary border-border-color shadow-2xl relative overflow-hidden">
<div class="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
<Database size={120} />
</div>
<div class="flex-1 overflow-y-auto mb-4 pr-2">
{#if restoreCart.length === 0}
<div class="h-full flex flex-col items-center justify-center text-center opacity-20 py-12">
<Search size={48} class="mb-4" />
<p class="text-xs font-bold uppercase tracking-widest leading-relaxed">
Your cart is empty.<br>Select files from the index.
</p>
</div>
{:else}
<div class="flex flex-col gap-2">
{#each restoreCart as item}
<div class="bg-bg-primary/50 border border-border-color/50 rounded-lg p-3 group transition-all hover:border-blue-500/30">
<div class="flex justify-between items-start gap-2">
<div class="min-w-0">
<p class="text-[12px] font-bold text-text-primary truncate">{item.name}</p>
<p class="text-[10px] text-text-secondary truncate mono opacity-50">{item.path}</p>
</div>
<button
class="text-text-secondary hover:text-error-color transition-colors p-1"
onclick={() => removeFromCart(item.path)}
>
<X size={14} />
</button>
<div class="relative z-10">
<h3 class="text-lg font-black uppercase tracking-tighter text-text-primary mb-6 flex items-center gap-2">
<Info size={18} class="text-blue-500" />
Physical Manifest
</h3>
<div class="space-y-4">
{#if manifest}
{#each manifest.media_required as req}
<div class="p-4 bg-bg-primary/50 border border-border-color rounded-xl flex items-center gap-4 group hover:border-blue-500/30 transition-colors">
<div class={cn(
"p-3 rounded-lg border shadow-inner",
req.media_type === 'tape' ? 'bg-blue-500/10 text-blue-400 border-blue-500/20' : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20'
)}>
{#if req.media_type === 'tape'}<CassetteTape size={20} />{:else}<HardDrive size={20} />{/if}
</div>
<div class="flex items-center gap-3 mt-2">
<span class="text-[10px] mono text-text-secondary font-bold">{formatSize(item.size || 0)}</span>
<div class="flex gap-1">
{#each (item.media || []) as m}
<span class="flex items-center gap-1 text-[9px] bg-blue-500/10 text-blue-400 px-1 rounded font-bold">
<CassetteTape size={10} /> {m}
</span>
{/each}
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center mb-1">
<span class="text-sm font-black text-text-primary mono">{req.identifier}</span>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary">{req.media_type}</span>
</div>
<div class="flex justify-between text-[10px] font-bold text-text-secondary opacity-60">
<span>{req.file_count} FILES</span>
<span>{formatSize(req.total_size)}</span>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<div class="pt-4 border-t border-border-color mt-auto">
<div class="flex justify-between mb-2">
<span class="text-[10px] font-bold uppercase tracking-widest text-text-secondary">Total Payload</span>
<span class="text-sm font-black text-text-primary mono">{formatSize(totalSize)}</span>
{/if}
</div>
<div class="flex justify-between">
<span class="text-[10px] font-bold uppercase tracking-widest text-text-secondary">Media Required</span>
<span class="text-sm font-black text-blue-400 mono">{requiredMedia.length} Tapes</span>
<div class="mt-8 p-4 bg-blue-500/5 border border-dashed border-blue-500/20 rounded-lg">
<p class="text-[10px] font-bold text-blue-300/70 leading-relaxed italic">
Note: Recovery will proceed sequentially by media to minimize hardware cycles.
</p>
</div>
</div>
</div>
</div>
</div>
{:else if currentStep === 2}
<!-- (Step 2-4 keep the same design as before but with Tailwind classes) -->
<div in:fly={{ y: 20, duration: 300 }} class="flex flex-col items-center justify-center flex-1">
<div class="bg-bg-secondary border-2 border-border-color rounded-2xl p-12 text-center shadow-2xl max-w-lg w-full">
<div class="w-20 h-20 bg-blue-500/10 text-blue-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
<CassetteTape size={48} />
</div>
<h2 class="text-2xl font-black text-text-primary uppercase tracking-tight mb-2">Insert Tape</h2>
<p class="text-text-secondary mb-8">
Please insert the following tape into the drive to begin extraction.
</p>
<div class="bg-bg-primary border-2 border-dashed border-border-color rounded-xl p-8 mb-8">
<span class="text-4xl font-black text-text-primary mono">{requiredMedia[0] || 'BUP-00001'}</span>
</div>
<div class="flex items-center justify-center gap-3 text-text-secondary text-sm font-bold uppercase tracking-widest mb-10">
<RotateCw size={18} class="animate-spin text-blue-500" />
<span>Waiting for drive status...</span>
</div>
<div class="flex gap-4 justify-center">
<Button variant="secondary" onclick={cancel}>
<X size={18} class="mr-2" /> Cancel
</Button>
<Button variant="default" onclick={nextStep}>
Simulate Load <ChevronRight size={18} class="ml-2" />
</Button>
</div>
</Card>
</div>
</div>
{/if}
+135 -36
View File
@@ -1,54 +1,153 @@
<script lang="ts">
import { Search, Save, ShieldAlert } from 'lucide-svelte';
import { onMount } from 'svelte';
import { Search, Save, ShieldAlert, FolderSearch, RotateCw, Plus, Trash2, Download } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { getSettingsSystemSettingsGet, updateSettingSystemSettingsPost } from '$lib/api/sdk.gen';
import { toast } from "svelte-sonner";
let sourceRoots = $state<string[]>(["/source_data"]);
let restoreDestinations = $state<string[]>(["/restores"]);
let globalExclusions = $state("*.tmp\nnode_modules/\n.DS_Store\nThumbs.db\nCache/\n");
let loading = $state(true);
let saving = $state(false);
async function loadSettings() {
loading = true;
try {
const response = await getSettingsSystemSettingsGet();
if (response.data) {
if (response.data.source_roots) {
try { sourceRoots = JSON.parse(response.data.source_roots); } catch { sourceRoots = [response.data.source_roots]; }
}
if (response.data.restore_destinations) {
try { restoreDestinations = JSON.parse(response.data.restore_destinations); } catch { restoreDestinations = [response.data.restore_destinations]; }
}
if (response.data.global_exclusions) {
globalExclusions = response.data.global_exclusions;
}
}
} catch (error) {
console.error("Failed to load settings:", error);
toast.error("Failed to load system settings");
} finally {
loading = false;
}
}
onMount(loadSettings);
function addSourceRoot() { sourceRoots = [...sourceRoots, ""]; }
function removeSourceRoot(index: number) { sourceRoots = sourceRoots.filter((_, i) => i !== index); }
function addRestoreDest() { restoreDestinations = [...restoreDestinations, ""]; }
function removeRestoreDest(index: number) { restoreDestinations = restoreDestinations.filter((_, i) => i !== index); }
async function saveSettings() {
saving = true;
try {
const roots = sourceRoots.filter(r => r.trim() !== "");
const dests = restoreDestinations.filter(d => d.trim() !== "");
await Promise.all([
updateSettingSystemSettingsPost({ body: { key: "source_roots", value: JSON.stringify(roots) } }),
updateSettingSystemSettingsPost({ body: { key: "restore_destinations", value: JSON.stringify(dests) } }),
updateSettingSystemSettingsPost({ body: { key: "global_exclusions", value: globalExclusions } })
]);
toast.success("Settings saved successfully");
sourceRoots = roots;
restoreDestinations = dests;
} catch (error) {
toast.error("Failed to save settings");
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>Settings - TapeHoard</title>
</svelte:head>
<div class="flex justify-between items-center mb-8 bg-bg-secondary p-6 rounded-lg border border-border-color shadow-lg">
<div>
<h1 class="text-3xl font-bold tracking-tight text-text-primary">System Settings</h1>
<p class="text-text-secondary mt-1">Configure global backup behavior and exclusion engines.</p>
<div class="flex justify-between items-center mb-8 bg-bg-secondary p-6 rounded-xl border border-border-color shadow-lg relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-r from-action-color/5 to-transparent pointer-events-none"></div>
<div class="relative z-10">
<h1 class="text-3xl font-black uppercase tracking-tighter text-text-primary">System Settings</h1>
<p class="text-text-secondary mt-1 font-bold uppercase tracking-widest text-[10px] opacity-70">Global Backup Configuration & Policy Engine</p>
</div>
<div class="flex gap-4 relative z-10">
<Button variant="default" size="lg" class="px-8 h-12 font-black uppercase tracking-widest text-[11px]" onclick={saveSettings} disabled={saving}>
{#if saving}
<RotateCw size={20} class="mr-2 animate-spin" />
{:else}
<Save size={20} class="mr-2" />
{/if}
Apply Settings
</Button>
</div>
<Button variant="default" size="lg" class="px-8 h-12">
<Save size={20} class="mr-2" />
Apply Settings
</Button>
</div>
<div class="max-w-4xl mx-auto space-y-6">
<Card class="p-8 shadow-xl border-border-color/60">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-action-color/10 rounded-lg">
<Search size={24} class="text-action-color" />
</div>
<div>
<h3 class="text-lg font-bold text-text-primary uppercase tracking-tight">Global Exclusion Engine</h3>
<p class="text-[12px] text-text-secondary font-medium">Define patterns that will be ignored across all backup sources.</p>
{#if loading}
<div class="flex flex-col items-center justify-center py-24 gap-4 opacity-50">
<RotateCw size={48} class="animate-spin text-action-color" />
<span class="text-xs font-black uppercase tracking-widest">Hydrating Configuration...</span>
</div>
{:else}
<div class="max-w-4xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<!-- Source Configuration -->
<Card class="p-8 shadow-xl border-border-color/60 bg-gradient-to-br from-bg-secondary to-bg-tertiary">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-500/10 rounded-lg text-blue-500 border border-blue-500/20"><FolderSearch size={24} /></div>
<div><h3 class="text-lg font-black text-text-primary uppercase tracking-tight">Source Provisioning</h3><p class="text-[11px] text-text-secondary font-medium uppercase tracking-wider opacity-60">Primary data ingestion points.</p></div>
</div>
<Button variant="secondary" size="sm" class="h-8 uppercase tracking-widest text-[10px] font-bold" onclick={addSourceRoot}><Plus size={14} class="mr-1" /> Add Source</Button>
</div>
<span class="text-[10px] font-mono text-text-secondary bg-bg-primary px-3 py-1 rounded-full border border-border-color">.gitignore syntax</span>
</div>
<div class="space-y-4">
{#each sourceRoots as root, i}
<div class="flex gap-2">
<Input bind:value={sourceRoots[i]} class="h-11 bg-bg-primary/50 border-border-color font-mono text-[13px]" placeholder="/path/to/data" />
<Button variant="ghost" size="icon" class="h-11 w-11 text-text-secondary hover:text-error-color hover:bg-error-color/10" onclick={() => removeSourceRoot(i)}><Trash2 size={18} /></Button>
</div>
{/each}
</div>
</Card>
<div class="relative group">
<textarea
bind:value={globalExclusions}
class="w-full h-[400px] bg-bg-primary/50 border border-border-color rounded-lg p-6 text-[14px] mono text-text-primary focus:ring-1 focus:ring-action-color focus:border-action-color focus:outline-none resize-none leading-relaxed transition-all"
placeholder="e.g. *.tmp"
></textarea>
</div>
<!-- Restore Destinations -->
<Card class="p-8 shadow-xl border-border-color/60 bg-gradient-to-br from-bg-secondary to-bg-tertiary">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-success-color/10 rounded-lg text-success-color border border-success-color/20"><Download size={24} /></div>
<div><h3 class="text-lg font-black text-text-primary uppercase tracking-tight">Recovery Targets</h3><p class="text-[11px] text-text-secondary font-medium uppercase tracking-wider opacity-60">Authorized destinations for restored data.</p></div>
</div>
<Button variant="secondary" size="sm" class="h-8 uppercase tracking-widest text-[10px] font-bold" onclick={addRestoreDest}><Plus size={14} class="mr-1" /> Add Target</Button>
</div>
<div class="space-y-4">
{#each restoreDestinations as dest, i}
<div class="flex gap-2">
<Input bind:value={restoreDestinations[i]} class="h-11 bg-bg-primary/50 border-border-color font-mono text-[13px]" placeholder="/path/to/restores" />
<Button variant="ghost" size="icon" class="h-11 w-11 text-text-secondary hover:text-error-color hover:bg-error-color/10" onclick={() => removeRestoreDest(i)}><Trash2 size={18} /></Button>
</div>
{/each}
</div>
</Card>
<div class="mt-6 p-4 bg-orange-500/5 border border-dashed border-orange-500/30 rounded-lg flex gap-4 items-start">
<ShieldAlert size={20} class="text-orange-500 shrink-0 mt-0.5" />
<p class="text-[12px] text-text-secondary leading-normal font-medium">
<strong>WARNING:</strong> Broad exclusion patterns (like <code>*</code> or <code>/</code>) can lead to empty backups. Patterns are evaluated recursively. Use <code>!</code> to explicitly include a sub-pattern within an excluded directory.
</p>
</div>
</Card>
</div>
<!-- Exclusion Engine -->
<Card class="p-8 shadow-xl border-border-color/60 bg-gradient-to-br from-bg-secondary to-bg-tertiary">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-action-color/10 rounded-lg text-action-color border border-action-color/20"><Search size={24} /></div>
<div><h3 class="text-lg font-black text-text-primary uppercase tracking-tight">Exclusion Engine</h3><p class="text-[11px] text-text-secondary font-medium uppercase tracking-wider opacity-60">Patterns to bypass during scans.</p></div>
</div>
<span class="text-[10px] font-black tracking-widest text-text-secondary bg-bg-primary px-3 py-1 rounded-full border border-border-color uppercase">.gitignore syntax</span>
</div>
<textarea bind:value={globalExclusions} class="w-full h-48 bg-bg-primary/50 border border-border-color rounded-lg p-6 text-[14px] mono text-text-primary focus:ring-1 focus:ring-action-color focus:outline-none resize-none leading-relaxed transition-all shadow-inner" placeholder="e.g. *.tmp"></textarea>
<div class="mt-6 p-4 bg-orange-500/5 border border-dashed border-orange-500/30 rounded-lg flex gap-4 items-start">
<ShieldAlert size={20} class="text-orange-500 shrink-0 mt-0.5" />
<p class="text-[12px] text-text-secondary leading-normal font-medium"><strong class="text-orange-500 uppercase tracking-tight text-[11px] block mb-1">Warning</strong>Broad exclusion patterns can result in incomplete backups.</p>
</div>
</Card>
</div>
{/if}
+85 -22
View File
@@ -1,20 +1,30 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Save, PlayCircle, FolderTree, FileCheck, Database, HardDrive, LayoutGrid, RotateCw, Search } from 'lucide-svelte';
import { onMount, onDestroy } from 'svelte';
import { Save, FolderTree, Database, HardDrive, LayoutGrid, RotateCw, Activity, FileCheck } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card';
import FileBrowser from '$lib/components/file-browser/FileBrowser.svelte';
import type { FileItem } from '$lib/types';
import { browsePathSystemBrowseGet, trackBatchSystemTrackBatchPost } from '$lib/api/sdk.gen';
import {
browsePathSystemBrowseGet,
trackBatchSystemTrackBatchPost,
triggerScanSystemScanPost,
getScanStatusSystemScanStatusGet,
type ScanStatusSchema
} from '$lib/api';
import { toast } from "svelte-sonner";
import { cn } from "$lib/utils";
// Current directory state
let currentPath = $state('/source_data');
let currentPath = $state('ROOT');
let files = $state<FileItem[]>([]);
let loading = $state(false);
let committing = $state(false);
// Scanner Status (local for button state only)
let scanRunning = $state(false);
let pollInterval: any;
// Staging area for tracking changes: path -> desired tracked state
let pendingChanges = $state<Map<string, boolean>>(new Map());
@@ -26,8 +36,14 @@
});
if (response.data) {
files = response.data.map(f => ({
...f,
type: f.type as 'file' | 'directory' | 'link'
name: f.name,
path: f.path,
type: f.type as 'file' | 'directory' | 'link',
size: f.size ?? null,
mtime: f.mtime ?? null,
tracked: f.tracked ?? false,
ignored: f.ignored ?? false,
sha256_hash: null // Not returned in browse but kept for state consistency
}));
}
} catch (error) {
@@ -38,8 +54,39 @@
}
}
async function updateScanStatus() {
try {
const response = await getScanStatusSystemScanStatusGet();
if (response.data) {
const wasRunning = scanRunning;
scanRunning = response.data.is_running;
if (wasRunning && !scanRunning) {
loadFiles(currentPath);
}
}
} catch (error) {
console.error("Failed to get scan status:", error);
}
}
async function startScan() {
try {
await triggerScanSystemScanPost();
updateScanStatus();
} catch (error: any) {
toast.error(error.body?.detail || "Failed to start scan");
}
}
onMount(() => {
loadFiles(currentPath);
updateScanStatus();
pollInterval = setInterval(updateScanStatus, 2000);
});
onDestroy(() => {
if (pollInterval) clearInterval(pollInterval);
});
$effect(() => {
@@ -52,29 +99,25 @@
currentPath = path;
}
// Toggle track locally (staging)
function handleToggleTrack(item: FileItem) {
const path = item.path;
const currentlyTracked = item.tracked;
const currentlyTracked = item.tracked || false;
const stagedState = pendingChanges.get(path);
if (stagedState !== undefined) {
// If already staged, revert to original state if toggled back
if (stagedState === !currentlyTracked) {
pendingChanges.delete(path);
pendingChanges = new Map(pendingChanges); // Trigger reactivity
pendingChanges = new Map(pendingChanges);
} else {
pendingChanges.set(path, !stagedState);
pendingChanges = new Map(pendingChanges);
}
} else {
// Stage the flip
pendingChanges.set(path, !currentlyTracked);
pendingChanges = new Map(pendingChanges);
}
}
// Computed files that merge original state with pending changes
const displayFiles = $derived(files.map(f => {
const pending = pendingChanges.get(f.path);
return {
@@ -140,9 +183,20 @@
</div>
<div class="flex gap-4 relative z-10">
<Button variant="secondary" size="lg" class="px-6 h-12 border-border-color font-bold uppercase tracking-widest text-[11px]">
<PlayCircle size={20} class="mr-2 text-action-color" />
Simulate Scan
<Button
variant="secondary"
size="lg"
class="px-6 h-12 border-border-color font-bold uppercase tracking-widest text-[11px]"
onclick={startScan}
disabled={scanRunning}
>
{#if scanRunning}
<RotateCw size={20} class="mr-2 animate-spin text-action-color" />
Scanning...
{:else}
<Activity size={20} class="mr-2 text-action-color" />
Run Scanner
{/if}
</Button>
<Button
variant="default"
@@ -172,7 +226,9 @@
</div>
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Tracked Items</span>
<span class="text-xl font-black text-text-primary mono">14,203</span>
<span class="text-xl font-black text-text-primary mono">
{files.filter(f => f.tracked).length}
</span>
</div>
</Card>
@@ -181,8 +237,10 @@
<Database size={24} />
</div>
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Est. Payload</span>
<span class="text-xl font-black text-action-color mono">4.2 TB</span>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Sync Items</span>
<span class="text-xl font-black text-action-color mono">
{files.length}
</span>
</div>
</Card>
@@ -191,8 +249,10 @@
<HardDrive size={24} />
</div>
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Media Load</span>
<span class="text-xl font-black text-success-color mono">2 x LTO-6</span>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Eligible Items</span>
<span class="text-xl font-black text-success-color mono">
{files.filter(f => !f.ignored).length}
</span>
</div>
</Card>
@@ -201,8 +261,10 @@
<FileCheck size={24} />
</div>
<div>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Last Simulation</span>
<span class="text-xl font-black text-text-primary mono">2h ago</span>
<span class="text-[10px] font-black uppercase tracking-widest text-text-secondary block">Pending Actions</span>
<span class="text-xl font-black text-text-primary mono">
{pendingChanges.size}
</span>
</div>
</Card>
</div>
@@ -217,6 +279,7 @@
</div>
</div>
{/if}
<FileBrowser
bind:currentPath
files={displayFiles}
+1 -1
View File
@@ -30,7 +30,7 @@ lint:
@echo "Linting Python (Ruff)..."
cd backend && uv run ruff check .
@echo "Type checking Python (ty)..."
cd backend && uv run ty
cd backend && uv run ty check
@echo "Type checking Svelte..."
cd frontend && npm run check