bunch of changes
This commit is contained in:
+9
-6
@@ -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*
|
||||
|
||||
@@ -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
@@ -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 []
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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("/")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
Generated
+83
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user