incremental media inventory improvements
Continuous Integration / backend-tests (push) Successful in 48s
Continuous Integration / frontend-check (push) Successful in 24s
Continuous Integration / e2e-tests (push) Successful in 8m1s

This commit is contained in:
2026-05-05 03:40:57 -04:00
parent c488873fed
commit daa69fd8ca
18 changed files with 1842 additions and 360 deletions
@@ -0,0 +1,138 @@
"""add structured location and type-specific fields to storage_media
Revision ID: 6a15f2e5b03b
Revises: 806e933ac89b
Create Date: 2026-05-05 01:07:03.581293
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "6a15f2e5b03b"
down_revision: Union[str, Sequence[str], None] = "806e933ac89b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
with op.batch_alter_table("storage_media", schema=None) as batch_op:
batch_op.add_column(sa.Column("location_building", sa.String(), nullable=True))
batch_op.add_column(sa.Column("location_room", sa.String(), nullable=True))
batch_op.add_column(sa.Column("location_rack", sa.String(), nullable=True))
batch_op.add_column(sa.Column("location_slot", sa.String(), nullable=True))
batch_op.add_column(sa.Column("generation", sa.String(), nullable=True))
batch_op.add_column(
sa.Column("worm", sa.Boolean(), nullable=False, server_default=sa.text("0"))
)
batch_op.add_column(
sa.Column(
"write_protected",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
)
)
batch_op.add_column(
sa.Column(
"compression", sa.Boolean(), nullable=False, server_default=sa.text("1")
)
)
batch_op.add_column(sa.Column("encryption_key_id", sa.String(), nullable=True))
batch_op.add_column(
sa.Column(
"cleaning_cartridge",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
)
)
batch_op.add_column(sa.Column("drive_model", sa.String(), nullable=True))
batch_op.add_column(sa.Column("device_uuid", sa.String(), nullable=True))
batch_op.add_column(
sa.Column(
"is_ssd", sa.Boolean(), nullable=False, server_default=sa.text("0")
)
)
batch_op.add_column(sa.Column("mount_path", sa.String(), nullable=True))
batch_op.add_column(sa.Column("filesystem_type", sa.String(), nullable=True))
batch_op.add_column(
sa.Column("connection_interface", sa.String(), nullable=True)
)
batch_op.add_column(
sa.Column(
"encrypted", sa.Boolean(), nullable=False, server_default=sa.text("0")
)
)
batch_op.add_column(sa.Column("provider_template", sa.String(), nullable=True))
batch_op.add_column(sa.Column("endpoint_url", sa.String(), nullable=True))
batch_op.add_column(sa.Column("region", sa.String(), nullable=True))
batch_op.add_column(sa.Column("bucket_name", sa.String(), nullable=True))
batch_op.add_column(sa.Column("access_key_id", sa.String(), nullable=True))
batch_op.add_column(sa.Column("secret_access_key", sa.String(), nullable=True))
batch_op.add_column(
sa.Column(
"path_style_access",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
)
)
batch_op.add_column(sa.Column("storage_class", sa.String(), nullable=True))
batch_op.add_column(
sa.Column(
"max_part_size_mb",
sa.Integer(),
nullable=False,
server_default=sa.text("5000"),
)
)
batch_op.add_column(
sa.Column(
"obfuscate_filenames",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
)
)
batch_op.add_column(
sa.Column("client_side_encryption_passphrase", sa.String(), nullable=True)
)
def downgrade() -> None:
"""Downgrade schema."""
with op.batch_alter_table("storage_media", schema=None) as batch_op:
batch_op.drop_column("client_side_encryption_passphrase")
batch_op.drop_column("obfuscate_filenames")
batch_op.drop_column("max_part_size_mb")
batch_op.drop_column("storage_class")
batch_op.drop_column("path_style_access")
batch_op.drop_column("secret_access_key")
batch_op.drop_column("access_key_id")
batch_op.drop_column("bucket_name")
batch_op.drop_column("region")
batch_op.drop_column("endpoint_url")
batch_op.drop_column("provider_template")
batch_op.drop_column("encrypted")
batch_op.drop_column("connection_interface")
batch_op.drop_column("filesystem_type")
batch_op.drop_column("mount_path")
batch_op.drop_column("is_ssd")
batch_op.drop_column("device_uuid")
batch_op.drop_column("drive_model")
batch_op.drop_column("cleaning_cartridge")
batch_op.drop_column("encryption_key_id")
batch_op.drop_column("compression")
batch_op.drop_column("write_protected")
batch_op.drop_column("worm")
batch_op.drop_column("generation")
batch_op.drop_column("location_slot")
batch_op.drop_column("location_rack")
batch_op.drop_column("location_room")
batch_op.drop_column("location_building")
+208 -63
View File
@@ -1,6 +1,6 @@
import json
from datetime import datetime, timezone
from typing import List
from typing import List, Dict, Any
import psutil
from fastapi import APIRouter, Depends, HTTPException
@@ -10,6 +10,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.archive import get_source_roots
from app.api import schemas
from app.api.schemas import (
MediaCreateSchema,
MediaSchema,
@@ -31,6 +32,49 @@ class ReorderMediaRequest(BaseModel):
# --- Core Logic ---
def _media_to_schema(media: models.StorageMedia, config: Dict[str, Any]) -> MediaSchema:
"""Convert a StorageMedia model to MediaSchema."""
return MediaSchema(
id=media.id,
identifier=media.identifier,
media_type=media.media_type,
generation_tier=media.generation_tier,
capacity=media.capacity,
bytes_used=media.bytes_used,
status=media.status,
location=media.location,
location_building=media.location_building,
location_room=media.location_room,
location_rack=media.location_rack,
location_slot=media.location_slot,
last_seen=media.last_seen,
created_at=media.created_at,
generation=media.generation,
worm=media.worm,
write_protected=media.write_protected,
compression=media.compression,
encryption_key_id=media.encryption_key_id,
cleaning_cartridge=media.cleaning_cartridge,
drive_model=media.drive_model,
device_uuid=media.device_uuid,
is_ssd=media.is_ssd,
mount_path=media.mount_path,
filesystem_type=media.filesystem_type,
connection_interface=media.connection_interface,
encrypted=media.encrypted,
provider_template=media.provider_template,
endpoint_url=media.endpoint_url,
region=media.region,
bucket_name=media.bucket_name,
access_key_id=media.access_key_id,
path_style_access=media.path_style_access,
storage_class=media.storage_class,
max_part_size_mb=media.max_part_size_mb,
obfuscate_filenames=media.obfuscate_filenames,
config=config,
)
@router.get(
"/providers",
response_model=List[StorageProviderSchema],
@@ -123,28 +167,15 @@ def list_media(refresh: bool = False, db_session: Session = Depends(get_db)):
except Exception:
pass
results.append(
MediaSchema(
id=media.id,
identifier=media.identifier,
media_type=media.media_type,
generation_tier=media.generation_tier,
capacity=media.capacity,
bytes_used=media.bytes_used,
status=media.status,
location=media.location,
last_seen=media.last_seen,
created_at=media.created_at,
config=final_config,
is_online=is_online,
is_identified=hardware_identified,
needs_registration=needs_registration,
priority_index=media.priority_index,
host_free_bytes=host_free_bytes,
host_total_bytes=host_total_bytes,
live_info=live_info,
)
)
schema = _media_to_schema(media, final_config)
schema.is_online = is_online
schema.is_identified = hardware_identified
schema.needs_registration = needs_registration
schema.priority_index = media.priority_index
schema.host_free_bytes = host_free_bytes
schema.host_total_bytes = host_total_bytes
schema.live_info = live_info
results.append(schema)
return results
@@ -175,30 +206,59 @@ def create_media(
if existing_record:
raise HTTPException(status_code=400, detail="Media identifier already exists.")
# Build base media record
new_media = models.StorageMedia(
identifier=request_data.identifier,
media_type=request_data.media_type,
generation_tier=request_data.generation_tier,
capacity=request_data.capacity,
location=request_data.location,
extra_config=json.dumps(request_data.config),
location_building=request_data.location_building,
location_room=request_data.location_room,
location_rack=request_data.location_rack,
location_slot=request_data.location_slot,
)
# Type-specific fields
if request_data.media_type == "lto_tape":
assert isinstance(request_data, schemas.LtoTapeCreateSchema)
new_media.generation = request_data.generation
new_media.generation_tier = request_data.generation
new_media.worm = request_data.worm
new_media.write_protected = request_data.write_protected
new_media.compression = request_data.compression
new_media.encryption_key_id = request_data.encryption_key_id
new_media.cleaning_cartridge = request_data.cleaning_cartridge
elif request_data.media_type == "local_hdd":
assert isinstance(request_data, schemas.OfflineHddCreateSchema)
new_media.drive_model = request_data.drive_model
new_media.device_uuid = request_data.device_uuid
new_media.is_ssd = request_data.is_ssd
new_media.mount_path = request_data.mount_path
new_media.filesystem_type = request_data.filesystem_type
new_media.connection_interface = request_data.connection_interface
new_media.encrypted = request_data.encrypted
new_media.encryption_key_id = request_data.encryption_key_id
elif request_data.media_type == "s3_compat":
assert isinstance(request_data, schemas.CloudCreateSchema)
new_media.provider_template = request_data.provider_template
new_media.endpoint_url = request_data.endpoint_url
new_media.region = request_data.region
new_media.bucket_name = request_data.bucket_name
new_media.access_key_id = request_data.access_key_id
new_media.secret_access_key = request_data.secret_access_key
new_media.path_style_access = request_data.path_style_access
new_media.storage_class = request_data.storage_class
new_media.max_part_size_mb = request_data.max_part_size_mb
new_media.obfuscate_filenames = request_data.obfuscate_filenames
new_media.client_side_encryption_passphrase = (
request_data.client_side_encryption_passphrase
)
db_session.add(new_media)
db_session.commit()
db_session.refresh(new_media)
return MediaSchema(
id=new_media.id,
identifier=new_media.identifier,
media_type=new_media.media_type,
generation_tier=new_media.generation_tier,
capacity=new_media.capacity,
bytes_used=new_media.bytes_used,
created_at=new_media.created_at,
location=new_media.location,
status=new_media.status,
config=request_data.config,
)
return _media_to_schema(new_media, {})
@router.patch(
@@ -224,16 +284,107 @@ def update_media(
if request_data.location is not None:
media_record.location = request_data.location
if request_data.location_building is not None:
media_record.location_building = request_data.location_building
if request_data.location_room is not None:
media_record.location_room = request_data.location_room
if request_data.location_rack is not None:
media_record.location_rack = request_data.location_rack
if request_data.location_slot is not None:
media_record.location_slot = request_data.location_slot
if request_data.capacity:
if request_data.capacity is not None:
if request_data.capacity < media_record.bytes_used:
raise HTTPException(
status_code=400,
detail=f"Capacity cannot be less than utilized space ({media_record.bytes_used} bytes).",
)
media_record.capacity = request_data.capacity
# If media was marked as full but now has free space, reactivate it
if (
media_record.status == "full"
and media_record.bytes_used / media_record.capacity < 0.98
):
media_record.status = "active"
if request_data.config:
current_config = (
json.loads(media_record.extra_config) if media_record.extra_config else {}
# LTO fields
if request_data.generation is not None:
media_record.generation = request_data.generation
media_record.generation_tier = request_data.generation
if request_data.worm is not None:
media_record.worm = request_data.worm
if request_data.write_protected is not None:
media_record.write_protected = request_data.write_protected
if request_data.compression is not None:
media_record.compression = request_data.compression
if request_data.encryption_key_id is not None:
media_record.encryption_key_id = request_data.encryption_key_id
if request_data.cleaning_cartridge is not None:
media_record.cleaning_cartridge = request_data.cleaning_cartridge
# HDD fields
if request_data.drive_model is not None:
media_record.drive_model = request_data.drive_model
if request_data.device_uuid is not None:
media_record.device_uuid = request_data.device_uuid
if request_data.is_ssd is not None:
media_record.is_ssd = request_data.is_ssd
if request_data.mount_path is not None:
media_record.mount_path = request_data.mount_path
if request_data.filesystem_type is not None:
media_record.filesystem_type = request_data.filesystem_type
if request_data.connection_interface is not None:
media_record.connection_interface = request_data.connection_interface
if request_data.encrypted is not None:
media_record.encrypted = request_data.encrypted
# Cloud fields
if request_data.provider_template is not None:
media_record.provider_template = request_data.provider_template
if request_data.endpoint_url is not None:
media_record.endpoint_url = request_data.endpoint_url
if request_data.region is not None:
media_record.region = request_data.region
if request_data.bucket_name is not None:
media_record.bucket_name = request_data.bucket_name
if request_data.access_key_id is not None:
media_record.access_key_id = request_data.access_key_id
if request_data.secret_access_key is not None:
media_record.secret_access_key = request_data.secret_access_key
if request_data.path_style_access is not None:
media_record.path_style_access = request_data.path_style_access
if request_data.storage_class is not None:
media_record.storage_class = request_data.storage_class
if request_data.max_part_size_mb is not None:
media_record.max_part_size_mb = request_data.max_part_size_mb
if request_data.obfuscate_filenames is not None:
media_record.obfuscate_filenames = request_data.obfuscate_filenames
if request_data.client_side_encryption_passphrase is not None:
media_record.client_side_encryption_passphrase = (
request_data.client_side_encryption_passphrase
)
current_config.update(request_data.config)
media_record.extra_config = json.dumps(current_config)
# Handle legacy extra_config for backward compatibility
if media_record.extra_config:
try:
current_config = json.loads(media_record.extra_config)
# Migrate any legacy keys to first-class columns if not already set
if "device_path" in current_config and not media_record.mount_path:
media_record.mount_path = current_config["device_path"]
if (
"encryption_key" in current_config
and not media_record.encryption_key_id
):
media_record.encryption_key_id = current_config["encryption_key"]
if (
"encryption_passphrase" in current_config
and not media_record.client_side_encryption_passphrase
):
media_record.client_side_encryption_passphrase = current_config[
"encryption_passphrase"
]
except Exception:
pass
db_session.commit()
db_session.refresh(media_record)
@@ -245,17 +396,7 @@ def update_media(
except Exception:
pass
return MediaSchema(
id=media_record.id,
identifier=media_record.identifier,
media_type=media_record.media_type,
capacity=media_record.capacity,
bytes_used=media_record.bytes_used,
created_at=media_record.created_at,
location=media_record.location,
status=media_record.status,
config=final_config,
)
return _media_to_schema(media_record, final_config)
@router.delete("/media/{media_id}", operation_id="delete_media")
@@ -301,15 +442,19 @@ def initialize_media(
try:
if storage_provider.initialize_media(media_record.identifier):
# Persist auto-generated device_path to DB so archiver finds the same dir
current_config = (
json.loads(media_record.extra_config)
if media_record.extra_config
else {}
)
if "device_path" not in current_config:
current_config["device_path"] = storage_provider.device_path
media_record.extra_config = json.dumps(current_config)
db_session.commit()
if media_record.media_type == "s3_compat":
# Cloud providers don't have device_path
pass
else:
current_config = (
json.loads(media_record.extra_config)
if media_record.extra_config
else {}
)
if "device_path" not in current_config:
current_config["device_path"] = storage_provider.device_path
media_record.extra_config = json.dumps(current_config)
db_session.commit()
return {"message": "Hardware initialization complete."}
except PermissionError as pe:
raise HTTPException(status_code=403, detail=str(pe))
+134 -18
View File
@@ -1,5 +1,6 @@
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict, Any, Literal
from datetime import datetime
@@ -42,6 +43,106 @@ class BatchDiscrepancyAction(BaseModel):
path_prefix: Optional[str] = None
class MediaBaseSchema(BaseModel):
"""Base schema with common fields for all media types."""
identifier: str
media_type: str
capacity: int
location: Optional[str] = None
location_building: Optional[str] = None
location_room: Optional[str] = None
location_rack: Optional[str] = None
location_slot: Optional[str] = None
class LtoTapeCreateSchema(MediaBaseSchema):
"""Schema for creating LTO Tape media."""
media_type: Literal["lto_tape"] = "lto_tape"
generation: str # LTO-5, LTO-6, LTO-7, LTO-8, LTO-9
worm: bool = False
write_protected: bool = False
compression: bool = True
encryption_key_id: Optional[str] = None
cleaning_cartridge: bool = False
class OfflineHddCreateSchema(MediaBaseSchema):
"""Schema for creating Offline HDD media."""
media_type: Literal["local_hdd"] = "local_hdd"
drive_model: Optional[str] = None
device_uuid: Optional[str] = None
is_ssd: bool = False
mount_path: Optional[str] = None
filesystem_type: Optional[str] = None
connection_interface: Optional[str] = None
encrypted: bool = False
encryption_key_id: Optional[str] = None
class CloudCreateSchema(MediaBaseSchema):
"""Schema for creating S3-Compatible Cloud media."""
media_type: Literal["s3_compat"] = "s3_compat"
provider_template: str # aws, minio, wasabi, backblaze, digitalocean, custom
endpoint_url: str
region: str
bucket_name: str
access_key_id: str
secret_access_key: str
path_style_access: bool = False
storage_class: Optional[str] = None
max_part_size_mb: int = 5000
obfuscate_filenames: bool = False
client_side_encryption_passphrase: Optional[str] = None
# Discriminated union type for creating media
# Uses media_type field to route to the correct type-specific schema
MediaCreateSchema = LtoTapeCreateSchema | OfflineHddCreateSchema | CloudCreateSchema
class MediaUpdateSchema(BaseModel):
"""Schema for updating media - all fields optional."""
status: Optional[str] = None
location: Optional[str] = None
location_building: Optional[str] = None
location_room: Optional[str] = None
location_rack: Optional[str] = None
location_slot: Optional[str] = None
capacity: Optional[int] = None
# LTO fields
generation: Optional[str] = None
worm: Optional[bool] = None
write_protected: Optional[bool] = None
compression: Optional[bool] = None
encryption_key_id: Optional[str] = None
cleaning_cartridge: Optional[bool] = None
# HDD fields
drive_model: Optional[str] = None
device_uuid: Optional[str] = None
is_ssd: Optional[bool] = None
mount_path: Optional[str] = None
filesystem_type: Optional[str] = None
connection_interface: Optional[str] = None
encrypted: Optional[bool] = None
# Cloud fields
provider_template: Optional[str] = None
endpoint_url: Optional[str] = None
region: Optional[str] = None
bucket_name: Optional[str] = None
access_key_id: Optional[str] = None
secret_access_key: Optional[str] = None
path_style_access: Optional[bool] = None
storage_class: Optional[str] = None
max_part_size_mb: Optional[int] = None
obfuscate_filenames: Optional[bool] = None
client_side_encryption_passphrase: Optional[str] = None
class MediaSchema(BaseModel):
id: int
identifier: str
@@ -51,9 +152,40 @@ class MediaSchema(BaseModel):
bytes_used: int
status: str
location: Optional[str] = None
location_building: Optional[str] = None
location_room: Optional[str] = None
location_rack: Optional[str] = None
location_slot: Optional[str] = None
last_seen: Optional[datetime] = None
created_at: datetime
config: Dict[str, Any]
# LTO fields
generation: Optional[str] = None
worm: bool = False
write_protected: bool = False
compression: bool = True
encryption_key_id: Optional[str] = None
cleaning_cartridge: bool = False
# HDD fields
drive_model: Optional[str] = None
device_uuid: Optional[str] = None
is_ssd: bool = False
mount_path: Optional[str] = None
filesystem_type: Optional[str] = None
connection_interface: Optional[str] = None
encrypted: bool = False
# Cloud fields
provider_template: Optional[str] = None
endpoint_url: Optional[str] = None
region: Optional[str] = None
bucket_name: Optional[str] = None
access_key_id: Optional[str] = None
path_style_access: bool = False
storage_class: Optional[str] = None
max_part_size_mb: int = 5000
obfuscate_filenames: bool = False
# Legacy config fallback
config: Dict[str, Any] = {}
# Runtime status
is_online: bool = False
is_identified: bool = False
needs_registration: bool = False
@@ -65,22 +197,6 @@ class MediaSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
class MediaCreateSchema(BaseModel):
identifier: str
media_type: str
generation_tier: Optional[str] = None
capacity: int
location: Optional[str] = None
config: Dict[str, Any] = {}
class MediaUpdateSchema(BaseModel):
status: Optional[str] = None
location: Optional[str] = None
capacity: Optional[int] = None
config: Optional[Dict[str, Any]] = None
class StorageProviderSchema(BaseModel):
provider_id: str
name: str
+40 -3
View File
@@ -42,16 +42,21 @@ class StorageMedia(Base):
__tablename__ = "storage_media"
id: Mapped[int] = mapped_column(primary_key=True)
media_type: Mapped[str] = mapped_column(String) # tape, hdd, cloud
media_type: Mapped[str] = mapped_column(String) # lto_tape, local_hdd, s3_compat
identifier: Mapped[str] = mapped_column(
String, unique=True, index=True
) # barcode, UUID, bucket
generation_tier: Mapped[Optional[str]] = mapped_column(
String
) # e.g., LTO-6, S3 Standard
) # e.g., LTO-6, S3 Standard (kept for backward compat)
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)
# Structured location fields
location: Mapped[Optional[str]] = mapped_column(String) # Kept as display fallback
location_building: Mapped[Optional[str]] = mapped_column(String)
location_room: Mapped[Optional[str]] = mapped_column(String)
location_rack: Mapped[Optional[str]] = mapped_column(String)
location_slot: Mapped[Optional[str]] = mapped_column(String)
status: Mapped[str] = mapped_column(
String, default="active"
) # active, full, retired, offline
@@ -64,6 +69,38 @@ class StorageMedia(Base):
DateTime, default=lambda: datetime.now(timezone.utc)
)
# Type-specific fields for LTO Tape
generation: Mapped[Optional[str]] = mapped_column(String) # LTO-6, LTO-7, etc.
worm: Mapped[bool] = mapped_column(Boolean, default=False)
write_protected: Mapped[bool] = mapped_column(Boolean, default=False)
compression: Mapped[bool] = mapped_column(Boolean, default=True)
encryption_key_id: Mapped[Optional[str]] = mapped_column(String)
cleaning_cartridge: Mapped[bool] = mapped_column(Boolean, default=False)
# Type-specific fields for Offline HDD
drive_model: Mapped[Optional[str]] = mapped_column(String)
device_uuid: Mapped[Optional[str]] = mapped_column(String)
is_ssd: Mapped[bool] = mapped_column(Boolean, default=False)
mount_path: Mapped[Optional[str]] = mapped_column(String)
filesystem_type: Mapped[Optional[str]] = mapped_column(String)
connection_interface: Mapped[Optional[str]] = mapped_column(String)
encrypted: Mapped[bool] = mapped_column(Boolean, default=False)
# Type-specific fields for S3-Compatible Cloud
provider_template: Mapped[Optional[str]] = mapped_column(
String
) # aws, minio, wasabi, etc.
endpoint_url: Mapped[Optional[str]] = mapped_column(String)
region: Mapped[Optional[str]] = mapped_column(String)
bucket_name: Mapped[Optional[str]] = mapped_column(String)
access_key_id: Mapped[Optional[str]] = mapped_column(String)
secret_access_key: Mapped[Optional[str]] = mapped_column(String)
path_style_access: Mapped[bool] = mapped_column(Boolean, default=False)
storage_class: Mapped[Optional[str]] = mapped_column(String)
max_part_size_mb: Mapped[int] = mapped_column(Integer, default=5000)
obfuscate_filenames: Mapped[bool] = mapped_column(Boolean, default=False)
client_side_encryption_passphrase: Mapped[Optional[str]] = mapped_column(String)
versions: Mapped[List["FileVersion"]] = relationship(back_populates="media")
+43 -14
View File
@@ -24,28 +24,51 @@ class CloudStorageProvider(AbstractStorageProvider):
"supports_hardware_encryption": True,
}
config_schema = {
"provider_template": {
"type": "string",
"title": "Provider Template",
"description": "AWS S3, MinIO, Wasabi, Backblaze B2, DigitalOcean Spaces, Custom.",
"enum": ["aws", "minio", "wasabi", "backblaze", "digitalocean", "custom"],
},
"endpoint_url": {
"type": "string",
"title": "Endpoint URL",
"description": "e.g., https://s3.us-west-004.backblazeb2.com",
},
"bucket_name": {
"type": "string",
"title": "Bucket Name",
},
"region": {
"type": "string",
"title": "Region",
"description": "Optional region",
},
"access_key": {
"bucket_name": {
"type": "string",
"title": "Bucket Name",
},
"access_key_id": {
"type": "string",
"title": "Access Key ID",
},
"secret_key": {
"secret_access_key": {
"type": "string",
"title": "Secret Access Key",
},
"path_style_access": {
"type": "boolean",
"title": "Path-Style Access",
"description": "Required for MinIO and some self-hosted S3.",
"default": False,
},
"storage_class": {
"type": "string",
"title": "Storage Class",
"description": "Standard, Glacier, Glacier Deep Archive, etc.",
},
"max_part_size_mb": {
"type": "integer",
"title": "Max Part Size (MB)",
"description": "Multipart upload chunk size.",
"default": 5000,
},
"encryption_passphrase": {
"type": "string",
"title": "Client-Side Encryption Passphrase",
@@ -63,7 +86,11 @@ class CloudStorageProvider(AbstractStorageProvider):
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")
endpoint = config.get("endpoint_url", "")
# Normalize endpoint: add https:// if no protocol is present
if endpoint and not endpoint.startswith(("http://", "https://")):
endpoint = f"https://{endpoint}"
self.endpoint_url = endpoint or None
self.obfuscate = config.get("obfuscate_filenames", False)
# Local Encryption Settings: Use provided or global default
@@ -75,13 +102,15 @@ class CloudStorageProvider(AbstractStorageProvider):
access_key = config.get("access_key")
secret_key = config.get("secret_key")
self.s3 = boto3.client(
"s3",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=self.region,
endpoint_url=self.endpoint_url,
)
client_kwargs = {
"aws_access_key_id": access_key,
"aws_secret_access_key": secret_key,
"region_name": self.region,
}
if self.endpoint_url:
client_kwargs["endpoint_url"] = self.endpoint_url
self.s3 = boto3.client("s3", **client_kwargs)
def _derive_key(self, salt: bytes) -> bytes:
"""Derives a 256-bit AES key using PBKDF2-HMAC-SHA256"""
+34
View File
@@ -27,6 +27,40 @@ class OfflineHDDProvider(AbstractStorageProvider):
"title": "Device UUID",
"description": "Optional UUID to verify the correct drive is mounted.",
},
"drive_model": {
"type": "string",
"title": "Drive Model",
"description": "e.g., Samsung T7 Shield, WD My Passport.",
},
"is_ssd": {
"type": "boolean",
"title": "Is SSD",
"description": "Check if this is a solid-state drive.",
"default": False,
},
"filesystem_type": {
"type": "string",
"title": "Filesystem Type",
"description": "ext4, NTFS, APFS, exFAT, etc.",
"enum": ["ext4", "NTFS", "APFS", "exFAT"],
},
"connection_interface": {
"type": "string",
"title": "Connection Interface",
"description": "USB-A, USB-C, Thunderbolt, SATA, NVMe.",
"enum": ["USB-A", "USB-C", "Thunderbolt", "SATA", "NVMe"],
},
"encrypted": {
"type": "boolean",
"title": "Drive Encrypted",
"description": "Drive-level encryption (BitLocker, LUKS, FileVault).",
"default": False,
},
"encryption_key_id": {
"type": "string",
"title": "Encryption Key ID",
"description": "Reference to key in system keystore.",
},
}
def __init__(self, config: Dict[str, Any]):
+27 -3
View File
@@ -24,10 +24,34 @@ class LTOProvider(AbstractStorageProvider):
"description": "Enable LTO hardware-level compression (default: True).",
"default": True,
},
"encryption_key": {
"encryption_key_id": {
"type": "string",
"title": "Hardware Encryption Key",
"description": "Optional 256-bit hex key for LTO hardware encryption.",
"title": "Encryption Key ID",
"description": "Reference to a key stored in the system keystore.",
},
"generation": {
"type": "string",
"title": "LTO Generation",
"description": "Tape generation (LTO-5, LTO-6, LTO-7, LTO-8, LTO-9).",
"enum": ["LTO-5", "LTO-6", "LTO-7", "LTO-8", "LTO-9"],
},
"worm": {
"type": "boolean",
"title": "WORM (Write Once Read Many)",
"description": "Mark tape as Write Once Read Many.",
"default": False,
},
"write_protected": {
"type": "boolean",
"title": "Write Protected",
"description": "Physical write-protect switch status.",
"default": False,
},
"cleaning_cartridge": {
"type": "boolean",
"title": "Cleaning Cartridge",
"description": "Mark if this is a cleaning tape.",
"default": False,
},
}
+40 -4
View File
@@ -106,6 +106,11 @@ class ArchiverService:
if os.environ.get("TAPEHOARD_TEST_MODE") == "true":
from app.providers.mock import MockLTOProvider
# In test mode, replace LTOProvider with MockLTOProvider
provider_map[LTOProvider.provider_id] = (
MockLTOProvider # ty: ignore[invalid-assignment]
)
# Also keep mock_lto mapping for backward compatibility
provider_map[MockLTOProvider.provider_id] = (
MockLTOProvider # ty: ignore[invalid-assignment]
)
@@ -114,6 +119,7 @@ class ArchiverService:
if not provider_cls:
return None
# Build provider config from extra_config (legacy) and first-class columns
provider_config: Dict[str, Any] = {}
if media_record.extra_config:
try:
@@ -123,10 +129,40 @@ class ArchiverService:
f"Failed to decode config for media {media_record.identifier}"
)
# Standards fallback for legacy config keys
if provider_cls == OfflineHDDProvider and "mount_path" not in provider_config:
# Older DBs might have used mount_base in some contexts, though hdd used mount_path in code
pass
# Add first-class columns to config based on media type
if media_record.media_type == "lto_tape":
if media_record.compression is not None:
provider_config.setdefault("compression", media_record.compression)
if media_record.encryption_key_id:
provider_config.setdefault(
"encryption_key", media_record.encryption_key_id
)
if media_record.generation:
provider_config.setdefault("generation", media_record.generation)
elif media_record.media_type == "local_hdd":
if media_record.mount_path:
provider_config.setdefault("mount_path", media_record.mount_path)
if media_record.device_uuid:
provider_config.setdefault("device_uuid", media_record.device_uuid)
elif media_record.media_type == "s3_compat":
if media_record.endpoint_url:
provider_config.setdefault("endpoint_url", media_record.endpoint_url)
if media_record.region:
provider_config.setdefault("region", media_record.region)
if media_record.bucket_name:
provider_config.setdefault("bucket_name", media_record.bucket_name)
if media_record.access_key_id:
provider_config.setdefault("access_key", media_record.access_key_id)
if media_record.secret_access_key:
provider_config.setdefault("secret_key", media_record.secret_access_key)
if media_record.client_side_encryption_passphrase:
provider_config.setdefault(
"encryption_passphrase",
media_record.client_side_encryption_passphrase,
)
provider_config.setdefault(
"obfuscate_filenames", media_record.obfuscate_filenames
)
return provider_cls(config=provider_config)
+2 -3
View File
@@ -13,12 +13,11 @@ def test_list_media_empty(client):
def test_register_media(client):
"""Tests registering a new storage medium."""
media_data = {
"media_type": "hdd",
"media_type": "local_hdd",
"identifier": "DISK_001",
"generation_tier": "SATA",
"capacity": 1000000000,
"location": "Safe A",
"config": {"mount_path": "/mnt/test"},
"mount_path": "/mnt/test",
}
response = client.post("/inventory/media", json=media_data)
assert response.status_code == 200
File diff suppressed because one or more lines are too long
+441 -38
View File
@@ -160,6 +160,90 @@ export type CartTreeNodeSchema = {
has_children?: boolean;
};
/**
* CloudCreateSchema
*
* Schema for creating S3-Compatible Cloud media.
*/
export type CloudCreateSchema = {
/**
* Identifier
*/
identifier: string;
/**
* Media Type
*/
media_type?: 's3_compat';
/**
* Capacity
*/
capacity: number;
/**
* Location
*/
location?: string | null;
/**
* Location Building
*/
location_building?: string | null;
/**
* Location Room
*/
location_room?: string | null;
/**
* Location Rack
*/
location_rack?: string | null;
/**
* Location Slot
*/
location_slot?: string | null;
/**
* Provider Template
*/
provider_template: string;
/**
* Endpoint Url
*/
endpoint_url: string;
/**
* Region
*/
region: string;
/**
* Bucket Name
*/
bucket_name: string;
/**
* Access Key Id
*/
access_key_id: string;
/**
* Secret Access Key
*/
secret_access_key: string;
/**
* Path Style Access
*/
path_style_access?: boolean;
/**
* Storage Class
*/
storage_class?: string | null;
/**
* Max Part Size Mb
*/
max_part_size_mb?: number;
/**
* Obfuscate Filenames
*/
obfuscate_filenames?: boolean;
/**
* Client Side Encryption Passphrase
*/
client_side_encryption_passphrase?: string | null;
};
/**
* DashboardStatsSchema
*/
@@ -396,6 +480,70 @@ export type JobLogSchema = {
timestamp: string;
};
/**
* LtoTapeCreateSchema
*
* Schema for creating LTO Tape media.
*/
export type LtoTapeCreateSchema = {
/**
* Identifier
*/
identifier: string;
/**
* Media Type
*/
media_type?: 'lto_tape';
/**
* Capacity
*/
capacity: number;
/**
* Location
*/
location?: string | null;
/**
* Location Building
*/
location_building?: string | null;
/**
* Location Room
*/
location_room?: string | null;
/**
* Location Rack
*/
location_rack?: string | null;
/**
* Location Slot
*/
location_slot?: string | null;
/**
* Generation
*/
generation: string;
/**
* Worm
*/
worm?: boolean;
/**
* Write Protected
*/
write_protected?: boolean;
/**
* Compression
*/
compression?: boolean;
/**
* Encryption Key Id
*/
encryption_key_id?: string | null;
/**
* Cleaning Cartridge
*/
cleaning_cartridge?: boolean;
};
/**
* ManifestMediaSchema
*/
@@ -418,38 +566,6 @@ export type ManifestMediaSchema = {
total_size: number;
};
/**
* MediaCreateSchema
*/
export type MediaCreateSchema = {
/**
* Identifier
*/
identifier: string;
/**
* Media Type
*/
media_type: string;
/**
* Generation Tier
*/
generation_tier?: string | null;
/**
* Capacity
*/
capacity: number;
/**
* Location
*/
location?: string | null;
/**
* Config
*/
config?: {
[key: string]: unknown;
};
};
/**
* MediaSchema
*/
@@ -486,6 +602,22 @@ export type MediaSchema = {
* Location
*/
location?: string | null;
/**
* Location Building
*/
location_building?: string | null;
/**
* Location Room
*/
location_room?: string | null;
/**
* Location Rack
*/
location_rack?: string | null;
/**
* Location Slot
*/
location_slot?: string | null;
/**
* Last Seen
*/
@@ -494,10 +626,98 @@ export type MediaSchema = {
* Created At
*/
created_at: string;
/**
* Generation
*/
generation?: string | null;
/**
* Worm
*/
worm?: boolean;
/**
* Write Protected
*/
write_protected?: boolean;
/**
* Compression
*/
compression?: boolean;
/**
* Encryption Key Id
*/
encryption_key_id?: string | null;
/**
* Cleaning Cartridge
*/
cleaning_cartridge?: boolean;
/**
* Drive Model
*/
drive_model?: string | null;
/**
* Device Uuid
*/
device_uuid?: string | null;
/**
* Is Ssd
*/
is_ssd?: boolean;
/**
* Mount Path
*/
mount_path?: string | null;
/**
* Filesystem Type
*/
filesystem_type?: string | null;
/**
* Connection Interface
*/
connection_interface?: string | null;
/**
* Encrypted
*/
encrypted?: boolean;
/**
* Provider Template
*/
provider_template?: string | null;
/**
* Endpoint Url
*/
endpoint_url?: string | null;
/**
* Region
*/
region?: string | null;
/**
* Bucket Name
*/
bucket_name?: string | null;
/**
* Access Key Id
*/
access_key_id?: string | null;
/**
* Path Style Access
*/
path_style_access?: boolean;
/**
* Storage Class
*/
storage_class?: string | null;
/**
* Max Part Size Mb
*/
max_part_size_mb?: number;
/**
* Obfuscate Filenames
*/
obfuscate_filenames?: boolean;
/**
* Config
*/
config: {
config?: {
[key: string]: unknown;
};
/**
@@ -534,6 +754,8 @@ export type MediaSchema = {
/**
* MediaUpdateSchema
*
* Schema for updating media - all fields optional.
*/
export type MediaUpdateSchema = {
/**
@@ -544,16 +766,194 @@ export type MediaUpdateSchema = {
* Location
*/
location?: string | null;
/**
* Location Building
*/
location_building?: string | null;
/**
* Location Room
*/
location_room?: string | null;
/**
* Location Rack
*/
location_rack?: string | null;
/**
* Location Slot
*/
location_slot?: string | null;
/**
* Capacity
*/
capacity?: number | null;
/**
* Config
* Generation
*/
config?: {
[key: string]: unknown;
} | null;
generation?: string | null;
/**
* Worm
*/
worm?: boolean | null;
/**
* Write Protected
*/
write_protected?: boolean | null;
/**
* Compression
*/
compression?: boolean | null;
/**
* Encryption Key Id
*/
encryption_key_id?: string | null;
/**
* Cleaning Cartridge
*/
cleaning_cartridge?: boolean | null;
/**
* Drive Model
*/
drive_model?: string | null;
/**
* Device Uuid
*/
device_uuid?: string | null;
/**
* Is Ssd
*/
is_ssd?: boolean | null;
/**
* Mount Path
*/
mount_path?: string | null;
/**
* Filesystem Type
*/
filesystem_type?: string | null;
/**
* Connection Interface
*/
connection_interface?: string | null;
/**
* Encrypted
*/
encrypted?: boolean | null;
/**
* Provider Template
*/
provider_template?: string | null;
/**
* Endpoint Url
*/
endpoint_url?: string | null;
/**
* Region
*/
region?: string | null;
/**
* Bucket Name
*/
bucket_name?: string | null;
/**
* Access Key Id
*/
access_key_id?: string | null;
/**
* Secret Access Key
*/
secret_access_key?: string | null;
/**
* Path Style Access
*/
path_style_access?: boolean | null;
/**
* Storage Class
*/
storage_class?: string | null;
/**
* Max Part Size Mb
*/
max_part_size_mb?: number | null;
/**
* Obfuscate Filenames
*/
obfuscate_filenames?: boolean | null;
/**
* Client Side Encryption Passphrase
*/
client_side_encryption_passphrase?: string | null;
};
/**
* OfflineHddCreateSchema
*
* Schema for creating Offline HDD media.
*/
export type OfflineHddCreateSchema = {
/**
* Identifier
*/
identifier: string;
/**
* Media Type
*/
media_type?: 'local_hdd';
/**
* Capacity
*/
capacity: number;
/**
* Location
*/
location?: string | null;
/**
* Location Building
*/
location_building?: string | null;
/**
* Location Room
*/
location_room?: string | null;
/**
* Location Rack
*/
location_rack?: string | null;
/**
* Location Slot
*/
location_slot?: string | null;
/**
* Drive Model
*/
drive_model?: string | null;
/**
* Device Uuid
*/
device_uuid?: string | null;
/**
* Is Ssd
*/
is_ssd?: boolean;
/**
* Mount Path
*/
mount_path?: string | null;
/**
* Filesystem Type
*/
filesystem_type?: string | null;
/**
* Connection Interface
*/
connection_interface?: string | null;
/**
* Encrypted
*/
encrypted?: boolean;
/**
* Encryption Key Id
*/
encryption_key_id?: string | null;
};
/**
@@ -1821,7 +2221,10 @@ export type ListMediaResponses = {
export type ListMediaResponse = ListMediaResponses[keyof ListMediaResponses];
export type CreateMediaData = {
body: MediaCreateSchema;
/**
* Request Data
*/
body: LtoTapeCreateSchema | OfflineHddCreateSchema | CloudCreateSchema;
path?: never;
query?: never;
url: '/inventory/media';
@@ -172,7 +172,7 @@
</div>
<!-- NAME & ICON -->
<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="flex flex-auto min-w-[300px] items-center gap-3 px-4 h-full border-r border-border-color/10 overflow-hidden">
<div class="shrink-0 relative">
<FileIcon
size={18}
@@ -260,7 +260,7 @@
</div>
<!-- QUICK ACTIONS -->
<div class="w-24 shrink-0 flex items-center justify-end gap-1 px-2">
<div class="w-10 shrink-0 flex items-center justify-end gap-1 px-2">
{#if mode === "discrepancies"}
{#if item.discrepancy_id && item.has_versions && !item.is_deleted}
<Button
+80
View File
@@ -38,3 +38,83 @@ export interface TreemapItem {
fullPath?: string;
children?: TreemapItem[];
}
// Discriminated union for media creation
export interface LtoTapeCreateData {
media_type: 'lto_tape';
identifier: string;
capacity: number;
location?: string;
location_building?: string;
location_room?: string;
location_rack?: string;
location_slot?: string;
generation: string;
worm?: boolean;
write_protected?: boolean;
compression?: boolean;
encryption_key_id?: string;
cleaning_cartridge?: boolean;
}
export interface OfflineHddCreateData {
media_type: 'local_hdd';
identifier: string;
capacity: number;
location?: string;
location_building?: string;
location_room?: string;
location_rack?: string;
location_slot?: string;
drive_model?: string;
device_uuid?: string;
is_ssd?: boolean;
mount_path?: string;
filesystem_type?: string;
connection_interface?: string;
encrypted?: boolean;
encryption_key_id?: string;
}
export interface CloudCreateData {
media_type: 's3_compat';
identifier: string;
capacity: number;
location?: string;
location_building?: string;
location_room?: string;
location_rack?: string;
location_slot?: string;
provider_template: string;
endpoint_url: string;
region: string;
bucket_name: string;
access_key_id: string;
secret_access_key: string;
path_style_access?: boolean;
storage_class?: string;
max_part_size_mb?: number;
obfuscate_filenames?: boolean;
client_side_encryption_passphrase?: string;
}
export type MediaCreateData = LtoTapeCreateData | OfflineHddCreateData | CloudCreateData;
// LTO Generation capacity mapping (in GB, base-10)
export const LTO_CAPACITY: Record<string, number> = {
'LTO-5': 1500, // 1.5 TB
'LTO-6': 2500, // 2.5 TB
'LTO-7': 6000, // 6.0 TB
'LTO-8': 12000, // 12.0 TB
'LTO-9': 18000, // 18.0 TB
};
// Provider template defaults
export const PROVIDER_TEMPLATES: Record<string, { endpoint: string; region: string }> = {
'aws': { endpoint: 's3.amazonaws.com', region: 'us-east-1' },
'minio': { endpoint: '', region: 'us-east-1' },
'wasabi': { endpoint: 's3.wasabisys.com', region: 'us-east-1' },
'backblaze': { endpoint: 's3.us-west-002.backblazeb2.com', region: 'us-west-002' },
'digitalocean': { endpoint: '', region: 'nyc3' },
'custom': { endpoint: '', region: 'us-east-1' },
};
+612 -166
View File
@@ -26,7 +26,8 @@
ShieldCheck,
Edit3,
Database,
EyeOff
EyeOff,
ChevronDown
} from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
@@ -53,6 +54,7 @@
type MediaSchema,
type StorageProviderSchema
} from '$lib/api';
import { LTO_CAPACITY, PROVIDER_TEMPLATES, type LtoTapeCreateData, type OfflineHddCreateData, type CloudCreateData } from '$lib/types';
import { dndzone } from 'svelte-dnd-action';
import { toast } from 'svelte-sonner';
import { beforeNavigate } from '$app/navigation';
@@ -72,30 +74,78 @@
let newMedia = $state({
media_type: 'lto_tape',
identifier: '',
generation_tier: 'LTO-6',
capacity_gb: 2500,
location: 'Storage Shelf'
generation: 'LTO-6',
capacity: 2500, // 2.5 TB in GB
location: 'Storage Shelf',
location_building: '',
location_room: '',
location_rack: '',
location_slot: '',
// LTO fields
worm: false,
write_protected: false,
compression: true,
encryption_key_id: '',
cleaning_cartridge: false,
// HDD fields
drive_model: '',
device_uuid: '',
is_ssd: false,
mount_path: '',
filesystem_type: '',
connection_interface: '',
encrypted: false,
hdd_encryption_key_id: '',
// Cloud fields
provider_template: 'aws',
endpoint_url: 's3.amazonaws.com',
region: 'us-east-1',
bucket_name: '',
access_key_id: '',
secret_access_key: '',
path_style_access: false,
storage_class: '',
max_part_size_mb: 5000,
obfuscate_filenames: false,
client_side_encryption_passphrase: ''
});
let dynamicConfig = $state<Record<string, any>>({});
// Provider template change handler
function handleProviderTemplateChange(template: string) {
newMedia.provider_template = template;
const defaults = PROVIDER_TEMPLATES[template];
if (defaults) {
newMedia.endpoint_url = defaults.endpoint;
newMedia.region = defaults.region;
}
}
// LTO Generation change handler (auto-populate capacity)
function handleGenerationChange(gen: string) {
newMedia.generation = gen;
if (LTO_CAPACITY[gen]) {
newMedia.capacity = LTO_CAPACITY[gen];
}
}
const activeProvider = $derived(
providersList.find(p => p.provider_id === newMedia.media_type)
);
// Initialize dynamicConfig when media_type changes
// Media type change handler
$effect(() => {
// Track provider identity changes
// Track provider identity changes to reset form
const _id = activeProvider?.provider_id;
if (activeProvider) {
// Reset form when media type changes
if (activeProvider && newMedia.media_type !== 'lto_tape') {
untrack(() => {
const newConfig: Record<string, any> = {};
Object.keys(activeProvider.config_schema).forEach(key => {
// Preserve existing value if key is the same, otherwise default empty
newConfig[key] = dynamicConfig[key] || '';
});
dynamicConfig = newConfig;
newMedia.generation = '';
newMedia.capacity = 0;
});
} else if (activeProvider && newMedia.media_type === 'lto_tape' && newMedia.capacity === 0) {
untrack(() => {
newMedia.generation = 'LTO-6';
newMedia.capacity = LTO_CAPACITY['LTO-6'];
});
}
});
@@ -130,8 +180,8 @@
const onlineDevicePaths = $derived(
new Set(
mediaList
.filter(m => m.is_online && m.config?.device_path)
.map(m => m.config.device_path)
.filter(m => m.is_online && (m.media_type === 'lto_tape' || m.media_type === 'tape') && m.identifier)
.map(m => m.identifier)
)
);
@@ -311,16 +361,51 @@
return;
}
// Build type-specific payload
let payload: any = {
media_type: newMedia.media_type,
identifier: newMedia.identifier,
capacity: newMedia.capacity * 1000 * 1000 * 1000, // Convert GB to bytes
location: newMedia.location,
location_building: newMedia.location_building || undefined,
location_room: newMedia.location_room || undefined,
location_rack: newMedia.location_rack || undefined,
location_slot: newMedia.location_slot || undefined,
};
if (newMedia.media_type === 'lto_tape') {
payload.generation = newMedia.generation;
payload.worm = newMedia.worm;
payload.write_protected = newMedia.write_protected;
payload.compression = newMedia.compression;
payload.encryption_key_id = newMedia.encryption_key_id || undefined;
payload.cleaning_cartridge = newMedia.cleaning_cartridge;
} else if (newMedia.media_type === 'local_hdd') {
payload.drive_model = newMedia.drive_model || undefined;
payload.device_uuid = newMedia.device_uuid || undefined;
payload.is_ssd = newMedia.is_ssd;
payload.mount_path = newMedia.mount_path || undefined;
payload.filesystem_type = newMedia.filesystem_type || undefined;
payload.connection_interface = newMedia.connection_interface || undefined;
payload.encrypted = newMedia.encrypted;
payload.encryption_key_id = newMedia.hdd_encryption_key_id || undefined;
} else if (newMedia.media_type === 's3_compat') {
payload.provider_template = newMedia.provider_template;
payload.endpoint_url = newMedia.endpoint_url;
payload.region = newMedia.region;
payload.bucket_name = newMedia.bucket_name;
payload.access_key_id = newMedia.access_key_id;
payload.secret_access_key = newMedia.secret_access_key;
payload.path_style_access = newMedia.path_style_access;
payload.storage_class = newMedia.storage_class || undefined;
payload.max_part_size_mb = newMedia.max_part_size_mb;
payload.obfuscate_filenames = newMedia.obfuscate_filenames;
payload.client_side_encryption_passphrase = newMedia.client_side_encryption_passphrase || undefined;
}
try {
await createMedia({
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: dynamicConfig
},
body: payload,
throwOnError: true
});
toast.success(`${newMedia.identifier} registered in inventory`);
@@ -332,26 +417,96 @@
}
function openEdit(media: MediaSchema) {
editingMedia = JSON.parse(JSON.stringify(media));
editingMedia = JSON.parse(JSON.stringify(media)) as MediaSchema;
// Convert capacity from bytes to GB for editing
editingMedia.capacity = Math.round(editingMedia.capacity / (1000 * 1000 * 1000));
// Ensure all fields exist for type-specific editing
if (editingMedia.media_type === 'lto_tape') {
editingMedia.encryption_key_id = editingMedia.encryption_key_id || '';
} else if (editingMedia.media_type === 'local_hdd') {
editingMedia.drive_model = editingMedia.drive_model || '';
editingMedia.device_uuid = editingMedia.device_uuid || '';
editingMedia.mount_path = editingMedia.mount_path || '';
editingMedia.filesystem_type = editingMedia.filesystem_type || '';
editingMedia.connection_interface = editingMedia.connection_interface || '';
editingMedia.encryption_key_id = editingMedia.encryption_key_id || '';
} else if (editingMedia.media_type === 's3_compat') {
editingMedia.endpoint_url = editingMedia.endpoint_url || '';
editingMedia.region = editingMedia.region || '';
editingMedia.bucket_name = editingMedia.bucket_name || '';
editingMedia.access_key_id = editingMedia.access_key_id || '';
editingMedia.storage_class = editingMedia.storage_class || '';
editingMedia.path_style_access = editingMedia.path_style_access ?? false;
editingMedia.obfuscate_filenames = editingMedia.obfuscate_filenames ?? false;
}
}
async function handleUpdate() {
if (!editingMedia) return;
const capacityBytes = editingMedia.capacity * 1000 * 1000 * 1000;
if (capacityBytes < editingMedia.bytes_used) {
toast.error(`Capacity cannot be less than utilized space (${formatSize(editingMedia.bytes_used)})`);
return;
}
// Build update payload with type-specific fields
let payload: any = {
location: editingMedia.location || undefined,
location_building: editingMedia.location_building || undefined,
location_room: editingMedia.location_room || undefined,
location_rack: editingMedia.location_rack || undefined,
location_slot: editingMedia.location_slot || undefined,
status: editingMedia.status,
capacity: capacityBytes,
};
// LTO fields
if (editingMedia.media_type === 'lto_tape') {
payload.compression = editingMedia.compression;
payload.worm = editingMedia.worm;
payload.write_protected = editingMedia.write_protected;
payload.cleaning_cartridge = editingMedia.cleaning_cartridge;
payload.encryption_key_id = editingMedia.encryption_key_id || undefined;
}
// HDD fields
else if (editingMedia.media_type === 'local_hdd') {
payload.drive_model = editingMedia.drive_model || undefined;
payload.device_uuid = editingMedia.device_uuid || undefined;
payload.is_ssd = editingMedia.is_ssd;
payload.encrypted = editingMedia.encrypted;
payload.encryption_key_id = editingMedia.encryption_key_id || undefined;
}
// Cloud fields
else if (editingMedia.media_type === 's3_compat') {
payload.endpoint_url = editingMedia.endpoint_url || undefined;
payload.region = editingMedia.region || undefined;
payload.bucket_name = editingMedia.bucket_name || undefined;
payload.access_key_id = editingMedia.access_key_id || undefined;
payload.path_style_access = editingMedia.path_style_access;
payload.obfuscate_filenames = editingMedia.obfuscate_filenames;
payload.storage_class = editingMedia.storage_class || undefined;
}
// Remove undefined values
Object.keys(payload).forEach(key => {
if (payload[key] === undefined) {
delete payload[key];
}
});
try {
await updateMedia({
path: { media_id: editingMedia.id },
body: {
location: editingMedia.location,
status: editingMedia.status,
config: editingMedia.config
},
body: payload,
throwOnError: true
});
toast.success("Media configuration updated");
editingMedia = null;
loadMedia();
} catch (error) {
toast.error("Failed to update media");
} catch (error: any) {
const detail = error?.body?.detail || "Failed to update media";
toast.error(detail);
}
}
@@ -412,30 +567,40 @@
</div>
</td>
<td class="px-6 py-3">
<div class="flex flex-col min-w-0">
<div class="flex flex-col">
<span class="text-sm font-semibold text-text-primary truncate">{media.identifier}</span>
<div class="mt-0.5 flex flex-col gap-0.5">
{#if (media.media_type === 'local_hdd' || media.media_type === 'hdd') && media.config?.mount_path}
{#if media.media_type === 'local_hdd' && media.mount_path}
<div class="flex items-center gap-1.5 text-text-secondary/50 text-[10px] mono truncate">
<Monitor size={10} /> {media.config.mount_path}
<Monitor size={10} /> {media.mount_path}
</div>
{:else if media.media_type === 's3_compat' && media.config?.bucket_name}
{:else if media.media_type === 's3_compat' && media.bucket_name}
<div class="flex items-center gap-1.5 text-text-secondary/50 text-[10px] mono truncate">
<Globe size={10} /> {media.config.bucket_name}
<Globe size={10} /> {media.bucket_name}
</div>
{:else if media.media_type === 'lto_tape' && media.generation}
<div class="flex items-center gap-1.5 text-text-secondary/50 text-[10px] mono truncate">
<CassetteTape size={10} /> {media.generation}
</div>
{/if}
<div class="flex gap-2 mt-0.5">
<div class="flex flex-wrap gap-1 mt-0.5">
{#if media.status === 'failed'}
<StatusBadge variant="error">Hardware failure</StatusBadge>
{:else if media.status === 'retired'}
<StatusBadge variant="neutral">Retired</StatusBadge>
{/if}
{#if media.config?.encryption_key || media.config?.encryption_passphrase}
{#if media.encryption_key_id || media.encrypted}
<StatusBadge variant="info">
<ShieldCheck size={8} /> Encrypted
</StatusBadge>
{/if}
{#if media.worm}
<StatusBadge variant="warning">WORM</StatusBadge>
{/if}
{#if media.cleaning_cartridge}
<StatusBadge variant="neutral">Cleaning</StatusBadge>
{/if}
</div>
</div>
</div>
@@ -444,9 +609,12 @@
<div class="flex flex-col">
<span class="text-xs font-medium text-text-secondary">{media.media_type}</span>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[10px] font-medium text-text-secondary/40">{media.generation_tier || 'Generic'}</span>
{#if (media.media_type === 'local_hdd' || media.media_type === 'hdd') && media.config?.device_uuid}
<span class="text-[10px] mono text-text-secondary/30 truncate max-w-[80px]">{media.config.device_uuid}</span>
<span class="text-[10px] font-medium text-text-secondary/40">{media.generation || media.generation_tier || 'Generic'}</span>
{#if media.media_type === 'local_hdd' && media.device_uuid}
<span class="text-[10px] mono text-text-secondary/30 truncate max-w-[80px]">{media.device_uuid}</span>
{/if}
{#if media.media_type === 'local_hdd' && media.drive_model}
<span class="text-[10px] text-text-secondary/30 truncate max-w-[100px]">{media.drive_model}</span>
{/if}
</div>
</div>
@@ -454,7 +622,7 @@
<td class="px-6 py-3">
<div class="flex items-center gap-1.5 text-text-secondary">
<MapPin size={12} class="opacity-40" />
<span class="text-xs font-medium">{media.location || 'Unknown'}</span>
<span class="text-xs font-medium">{media.location || (media.location_building ? `${media.location_building}${media.location_room ? ' / ' + media.location_room : ''}` : 'Unknown')}</span>
</div>
</td>
<td class="px-6 py-3">
@@ -551,28 +719,35 @@
</div>
{/if}
<div class="mt-4 flex gap-2">
<Button variant="default" size="sm" class="h-8 text-xs flex-1" onclick={() => {
newMedia.media_type = asset.type === 'tape' ? 'lto_tape' : 'local_hdd';
newMedia.identifier = asset.identifier === 'Unrecognized Disk' ? '' : asset.identifier;
<div class="mt-4 flex gap-2">
<Button variant="default" size="sm" class="h-8 text-xs flex-1" onclick={() => {
newMedia.media_type = asset.type === 'tape' ? 'lto_tape' : 'local_hdd';
newMedia.identifier = asset.identifier === 'Unrecognized Disk' ? '' : asset.identifier;
// Pre-fill dynamic config
if (asset.type === 'hdd') {
dynamicConfig.mount_path = asset.mount_path;
dynamicConfig.device_uuid = asset.device_uuid || '';
if (asset.capacity_bytes) {
newMedia.capacity_gb = Math.floor(asset.capacity_bytes / (1024 * 1024 * 1024));
// Pre-fill based on asset type
if (asset.type === 'hdd') {
newMedia.mount_path = asset.mount_path || '';
newMedia.device_uuid = asset.device_uuid || '';
if (asset.capacity_bytes) {
newMedia.capacity = asset.capacity_bytes;
}
} else if (asset.type === 'tape') {
if (asset.hardware_info?.tape?.serial) {
newMedia.identifier = asset.hardware_info.tape.serial;
}
if (asset.hardware_info?.tape?.barcode) {
newMedia.identifier = asset.hardware_info.tape.barcode;
}
if (asset.hardware_info?.tape?.max_capacity_mib) {
// Convert MiB to GB (base-10)
newMedia.capacity = Math.round(asset.hardware_info.tape.max_capacity_mib * 1024 * 1024 / (1000 * 1000 * 1000));
}
if (asset.hardware_info?.tape?.generation_label) {
newMedia.generation = asset.hardware_info.tape.generation_label;
}
}
} else if (asset.type === 'tape') {
dynamicConfig.device_path = asset.device_path;
dynamicConfig.serial = asset.hardware_info?.tape?.serial || '';
dynamicConfig.barcode = asset.hardware_info?.tape?.barcode || '';
if (asset.hardware_info?.tape?.max_capacity_mib) {
newMedia.capacity_gb = Math.floor(asset.hardware_info.tape.max_capacity_mib / 1024);
}
}
showRegisterDialog = true;
}}>Add media</Button>
showRegisterDialog = true;
}}>Add media</Button>
<Button variant="outline" size="sm" class="h-8 text-xs border-border-color/60 text-text-secondary hover:bg-white/5" onclick={() => handleIgnoreAsset(asset.identifier)}>Ignore</Button>
</div>
</div>
@@ -868,73 +1043,259 @@
<Button variant="ghost" size="icon" class="hover:bg-white/5" onclick={() => showRegisterDialog = false}><X size={20} /></Button>
</header>
<div class="grid grid-cols-3 gap-4">
{#each providersList.filter(p => !['lto_tape', 'mock_lto'].includes(p.provider_id)) as provider}
<button class={cn("flex flex-col items-center gap-3 p-4 rounded-xl border-2 transition-all", newMedia.media_type === provider.provider_id ? "bg-blue-500/10 border-blue-500 text-blue-400 shadow-lg shadow-blue-500/10" : "bg-bg-primary/50 border-border-color text-text-secondary hover:border-text-secondary/30")}
onclick={() => {
newMedia.media_type = provider.provider_id;
if (provider.provider_id === 'lto_tape') newMedia.location = 'Storage Shelf';
else if (provider.provider_id === 'local_hdd') newMedia.location = 'Offsite Safe';
else newMedia.location = 'Cloud';
}}
>
{@render ConfigIcon(provider.provider_id)}
<span class="text-xs font-semibold">{provider.name}</span>
</button>
{/each}
</div>
<div class="grid grid-cols-3 gap-4">
{#each providersList.filter(p => !['mock_lto'].includes(p.provider_id)) as provider}
<button class={cn("flex flex-col items-center gap-3 p-4 rounded-xl border-2 transition-all", newMedia.media_type === provider.provider_id ? "bg-blue-500/10 border-blue-500 text-blue-400 shadow-lg shadow-blue-500/10" : "bg-bg-primary/50 border-border-color text-text-secondary hover:border-text-secondary/30")}
onclick={() => {
newMedia.media_type = provider.provider_id;
if (provider.provider_id === 'lto_tape') {
newMedia.location = 'Storage Shelf';
newMedia.generation = 'LTO-6';
newMedia.capacity = LTO_CAPACITY['LTO-6'];
} else if (provider.provider_id === 'local_hdd') {
newMedia.location = 'Offsite Safe';
} else {
newMedia.location = 'Cloud';
handleProviderTemplateChange('aws');
}
}}
>
{@render ConfigIcon(provider.provider_id)}
<span class="text-xs font-semibold">{provider.name}</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-xs font-medium text-text-secondary ml-1" for="identifier">Identifier (Barcode/SN)</label>
<Input id="identifier" bind:value={newMedia.identifier} placeholder="BUP-00001" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="capacity">Capacity (GB)</label>
<Input id="capacity" type="number" bind:value={newMedia.capacity_gb} class="h-10 bg-bg-primary/50 border-border-color font-mono" />
<p class="text-[10px] text-text-secondary leading-tight opacity-60">Auto-detected when possible. You can manually reduce this to reserve space.</p>
</div>
</div>
<!-- Identity Section -->
<div class="space-y-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">Identity</h3>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="identifier">
{newMedia.media_type === 'lto_tape' ? 'Barcode' : newMedia.media_type === 'local_hdd' ? 'Identifier / Serial' : 'Friendly Name'}
</label>
<Input id="identifier" bind:value={newMedia.identifier} placeholder={newMedia.media_type === 'lto_tape' ? 'BUP-00001' : newMedia.media_type === 'local_hdd' ? 'Samsung-T7-001' : 'AWS-Production'} class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="location">Physical location</label>
<div class="relative">
<MapPin size={16} class="absolute left-4 top-3 text-text-secondary opacity-50" />
<Input id="location" bind:value={newMedia.location} placeholder="Cabinet A, Shelf 2" class="h-10 bg-bg-primary/50 pl-12 border-border-color font-mono text-sm" />
</div>
</div>
<!-- Dynamic Provider Config Fields -->
{#if activeProvider}
<div class="grid grid-cols-2 gap-4 animate-in slide-in-from-top-2 duration-300">
{#each Object.entries(activeProvider.config_schema) as [key, schema]}
{@const field = schema as any}
<div class="space-y-2 flex flex-col justify-center">
{#if field.type === 'boolean'}
<div class="flex items-center gap-3 h-10 px-1">
<input
id="config-{key}"
type="checkbox"
bind:checked={dynamicConfig[key]}
class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20"
/>
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="config-{key}">{field.title || key}</label>
</div>
{:else}
<label class="text-xs font-medium text-text-secondary ml-1" for="config-{key}">{field.title || key}</label>
<Input
id="config-{key}"
bind:value={dynamicConfig[key]}
placeholder={field.description || ""}
type={key.includes("key") || key.includes("passphrase") ? "password" : "text"}
class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm"
/>
{/if}
{#if newMedia.media_type === 'lto_tape'}
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="generation">LTO Generation</label>
<div class="relative">
<select id="generation" bind:value={newMedia.generation} onchange={() => handleGenerationChange(newMedia.generation)} class="w-full h-10 bg-bg-primary border border-border-color rounded-xl px-4 pr-10 text-sm font-medium text-text-primary outline-none focus:ring-2 focus:ring-blue-500/20 transition-all appearance-none cursor-pointer">
<option value="LTO-5">LTO-5 (1.5 TB)</option>
<option value="LTO-6">LTO-6 (2.5 TB)</option>
<option value="LTO-7">LTO-7 (6.0 TB)</option>
<option value="LTO-8">LTO-8 (12.0 TB)</option>
<option value="LTO-9">LTO-9 (18.0 TB)</option>
</select>
<ChevronDown size={16} class="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none" />
</div>
</div>
{/each}
{:else if newMedia.media_type === 'local_hdd'}
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="drive_model">Drive Model</label>
<Input id="drive_model" bind:value={newMedia.drive_model} placeholder="Samsung T7 Shield" class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
{:else if newMedia.media_type === 's3_compat'}
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="bucket_name">Bucket Name</label>
<Input id="bucket_name" bind:value={newMedia.bucket_name} placeholder="my-backup-bucket" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
{/if}
</div>
{/if}
{#if newMedia.media_type === 'local_hdd'}
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="device_uuid">Device UUID</label>
<Input id="device_uuid" bind:value={newMedia.device_uuid} placeholder="12345678-ABCD" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="mount_path">Expected Mount Path</label>
<Input id="mount_path" bind:value={newMedia.mount_path} placeholder="/Volumes/Backup-01" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
</div>
{/if}
{#if newMedia.media_type === 's3_compat'}
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="provider_template">Provider Template</label>
<div class="relative">
<select id="provider_template" bind:value={newMedia.provider_template} onchange={() => handleProviderTemplateChange(newMedia.provider_template)} class="w-full h-10 bg-bg-primary border border-border-color rounded-xl px-4 pr-10 text-sm font-medium text-text-primary outline-none focus:ring-2 focus:ring-blue-500/20 transition-all appearance-none cursor-pointer">
<option value="aws">AWS S3</option>
<option value="minio">MinIO</option>
<option value="wasabi">Wasabi</option>
<option value="backblaze">Backblaze B2</option>
<option value="digitalocean">DigitalOcean Spaces</option>
<option value="custom">Custom</option>
</select>
<ChevronDown size={16} class="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none" />
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="region">Region</label>
<Input id="region" bind:value={newMedia.region} placeholder="us-east-1" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="endpoint_url">Endpoint URL</label>
<Input id="endpoint_url" bind:value={newMedia.endpoint_url} placeholder="https://s3.amazonaws.com" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="access_key_id">Access Key ID</label>
<Input id="access_key_id" bind:value={newMedia.access_key_id} placeholder="AKIA..." class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" type="password" />
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="secret_access_key">Secret Access Key</label>
<Input id="secret_access_key" bind:value={newMedia.secret_access_key} placeholder="Secret key" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" type="password" />
</div>
{/if}
</div>
<!-- Capacity -->
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="capacity">Capacity (GB)</label>
<Input id="capacity" type="number" bind:value={newMedia.capacity} class="h-10 bg-bg-primary/50 border-border-color font-mono" />
{#if newMedia.media_type === 'lto_tape'}
<p class="text-[10px] text-text-secondary leading-tight opacity-60">Auto-populated from LTO generation. You can manually adjust.</p>
{/if}
</div>
<!-- Location Section -->
<div class="space-y-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">Location</h3>
{#if newMedia.media_type !== 's3_compat'}
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="location_building">Building</label>
<Input id="location_building" bind:value={newMedia.location_building} placeholder="Office" class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="location_room">Room/Vault</label>
<Input id="location_room" bind:value={newMedia.location_room} placeholder="Tape Vault A" class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="location_rack">Rack/Shelf</label>
<Input id="location_rack" bind:value={newMedia.location_rack} placeholder="Rack-12" class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="location_slot">Slot/Position</label>
<Input id="location_slot" bind:value={newMedia.location_slot} placeholder="Slot-45" class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
</div>
{:else}
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="location">Friendly Location Label</label>
<div class="relative">
<MapPin size={16} class="absolute left-4 top-3 text-text-secondary opacity-50" />
<Input id="location" bind:value={newMedia.location} placeholder="US-East Data Center" class="h-10 bg-bg-primary/50 pl-12 border-border-color text-sm" />
</div>
</div>
{/if}
</div>
<!-- Configuration Section -->
<div class="space-y-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">Configuration</h3>
{#if newMedia.media_type === 'lto_tape'}
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-3 h-10 px-1">
<input id="compression" type="checkbox" bind:checked={newMedia.compression} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="compression">Hardware Compression</label>
</div>
<div class="flex items-center gap-3 h-10 px-1">
<input id="worm" type="checkbox" bind:checked={newMedia.worm} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="worm">WORM (Write Once Read Many)</label>
</div>
<div class="flex items-center gap-3 h-10 px-1">
<input id="write_protected" type="checkbox" bind:checked={newMedia.write_protected} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="write_protected">Write Protected (Physical)</label>
</div>
<div class="flex items-center gap-3 h-10 px-1">
<input id="cleaning_cartridge" type="checkbox" bind:checked={newMedia.cleaning_cartridge} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="cleaning_cartridge">Cleaning Cartridge</label>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="encryption_key_id">Encryption Key ID</label>
<Input id="encryption_key_id" bind:value={newMedia.encryption_key_id} placeholder="Key reference in system keystore" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
{:else if newMedia.media_type === 'local_hdd'}
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-3 h-10 px-1">
<input id="is_ssd" type="checkbox" bind:checked={newMedia.is_ssd} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="is_ssd">SSD (Solid State Drive)</label>
</div>
<div class="flex items-center gap-3 h-10 px-1">
<input id="encrypted" type="checkbox" bind:checked={newMedia.encrypted} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="encrypted">Drive Encrypted (BitLocker/LUKS)</label>
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="filesystem_type">Filesystem Type</label>
<div class="relative">
<select id="filesystem_type" bind:value={newMedia.filesystem_type} class="w-full h-10 bg-bg-primary border border-border-color rounded-xl px-4 pr-10 text-sm font-medium text-text-primary outline-none focus:ring-2 focus:ring-blue-500/20 transition-all appearance-none cursor-pointer">
<option value="">Select...</option>
<option value="ext4">ext4</option>
<option value="NTFS">NTFS</option>
<option value="APFS">APFS</option>
<option value="exFAT">exFAT</option>
</select>
<ChevronDown size={16} class="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none" />
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="connection_interface">Connection Interface</label>
<div class="relative">
<select id="connection_interface" bind:value={newMedia.connection_interface} class="w-full h-10 bg-bg-primary border border-border-color rounded-xl px-4 pr-10 text-sm font-medium text-text-primary outline-none focus:ring-2 focus:ring-blue-500/20 transition-all appearance-none cursor-pointer">
<option value="">Select...</option>
<option value="USB-A">USB-A</option>
<option value="USB-C">USB-C</option>
<option value="Thunderbolt">Thunderbolt</option>
<option value="SATA">SATA</option>
<option value="NVMe">NVMe</option>
</select>
<ChevronDown size={16} class="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none" />
</div>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="hdd_encryption_key_id">Encryption Key ID</label>
<Input id="hdd_encryption_key_id" bind:value={newMedia.hdd_encryption_key_id} placeholder="Key reference in system keystore" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
{:else if newMedia.media_type === 's3_compat'}
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-3 h-10 px-1">
<input id="path_style_access" type="checkbox" bind:checked={newMedia.path_style_access} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="path_style_access">Path-Style Access (MinIO/Self-hosted)</label>
</div>
<div class="flex items-center gap-3 h-10 px-1">
<input id="obfuscate_filenames" type="checkbox" bind:checked={newMedia.obfuscate_filenames} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="obfuscate_filenames">Obfuscate Filenames</label>
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="storage_class">Storage Class</label>
<Input id="storage_class" bind:value={newMedia.storage_class} placeholder="Standard, Glacier, etc." class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="max_part_size_mb">Max Part Size (MB)</label>
<Input id="max_part_size_mb" type="number" bind:value={newMedia.max_part_size_mb} class="h-10 bg-bg-primary/50 border-border-color font-mono" />
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="client_side_encryption_passphrase">Client-Side Encryption Passphrase</label>
<Input id="client_side_encryption_passphrase" bind:value={newMedia.client_side_encryption_passphrase} type="password" placeholder="Encrypts payloads before upload" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
{/if}
</div>
</div>
<footer class="flex gap-3 pt-4 border-t border-border-color">
@@ -966,57 +1327,142 @@
</div>
<div>
<span class="text-sm font-semibold text-text-primary">{editingMedia.identifier}</span>
<span class="text-xs text-text-secondary block opacity-60">{editingMedia.media_type} &bull; {editingMedia.generation_tier}</span>
<span class="text-xs text-text-secondary block opacity-60">{editingMedia.media_type} &bull; {editingMedia.generation || editingMedia.generation_tier || 'Generic'}</span>
</div>
</div>
<div class="space-y-2 animate-in fade-in duration-300">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-location">Physical location</label>
<Input id="edit-location" bind:value={editingMedia.location} class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
<!-- Location Fields -->
<div class="space-y-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">Location</h3>
{#if editingMedia.media_type !== 's3_compat'}
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-location_building">Building</label>
<Input id="edit-location_building" bind:value={editingMedia.location_building} class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-location_room">Room/Vault</label>
<Input id="edit-location_room" bind:value={editingMedia.location_room} class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-location_rack">Rack/Shelf</label>
<Input id="edit-location_rack" bind:value={editingMedia.location_rack} class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-location_slot">Slot/Position</label>
<Input id="edit-location_slot" bind:value={editingMedia.location_slot} class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
</div>
{:else}
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-location">Location Label</label>
<Input id="edit-location" bind:value={editingMedia.location} class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
{/if}
</div>
<!-- Dynamic Config for Edit -->
{#if providersList.find(p => p.provider_id === editingMedia?.media_type)}
{@const schema = providersList.find(p => p.provider_id === editingMedia?.media_type)?.config_schema || {}}
<div class="grid grid-cols-1 gap-4">
{#each Object.entries(schema) as [key, entry]}
{@const field = entry as any}
<div class="space-y-2 flex flex-col justify-center">
{#if field.type === 'boolean'}
<div class="flex items-center gap-3 h-10 px-1">
<input
id="edit-config-{key}"
type="checkbox"
bind:checked={(editingMedia.config[key] as any)}
class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20"
/>
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="edit-config-{key}">{field.title || key}</label>
</div>
{:else}
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-config-{key}">{field.title || key}</label>
<Input
id="edit-config-{key}"
bind:value={editingMedia.config[key]}
type={key.includes("key") || key.includes("passphrase") ? "password" : "text"}
class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm"
/>
{/if}
<!-- Capacity -->
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-capacity">Capacity (GB)</label>
<Input id="edit-capacity" type="number" bind:value={editingMedia.capacity} class="h-10 bg-bg-primary/50 border-border-color font-mono" />
</div>
<!-- Type-Specific Edit Fields -->
{#if editingMedia.media_type === 'lto_tape'}
<div class="space-y-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">LTO Configuration</h3>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-3 h-10 px-1">
<input id="edit-compression" type="checkbox" bind:checked={editingMedia.compression} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="edit-compression">Hardware Compression</label>
</div>
{/each}
<div class="flex items-center gap-3 h-10 px-1">
<input id="edit-worm" type="checkbox" bind:checked={editingMedia.worm} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="edit-worm">WORM</label>
</div>
<div class="flex items-center gap-3 h-10 px-1">
<input id="edit-write_protected" type="checkbox" bind:checked={editingMedia.write_protected} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="edit-write_protected">Write Protected</label>
</div>
<div class="flex items-center gap-3 h-10 px-1">
<input id="edit-cleaning_cartridge" type="checkbox" bind:checked={editingMedia.cleaning_cartridge} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="edit-cleaning_cartridge">Cleaning Cartridge</label>
</div>
</div>
</div>
{:else if editingMedia.media_type === 'local_hdd'}
<div class="space-y-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">HDD Configuration</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-drive_model">Drive Model</label>
<Input id="edit-drive_model" bind:value={editingMedia.drive_model} class="h-10 bg-bg-primary/50 border-border-color text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-device_uuid">Device UUID</label>
<Input id="edit-device_uuid" bind:value={editingMedia.device_uuid} class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="flex items-center gap-3 h-10 px-1">
<input id="edit-is_ssd" type="checkbox" bind:checked={editingMedia.is_ssd} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="edit-is_ssd">SSD</label>
</div>
<div class="flex items-center gap-3 h-10 px-1">
<input id="edit-encrypted" type="checkbox" bind:checked={editingMedia.encrypted} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="edit-encrypted">Encrypted</label>
</div>
</div>
</div>
{:else if editingMedia.media_type === 's3_compat'}
<div class="space-y-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">Cloud Configuration</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-endpoint_url">Endpoint URL</label>
<Input id="edit-endpoint_url" bind:value={editingMedia.endpoint_url} class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-region">Region</label>
<Input id="edit-region" bind:value={editingMedia.region} class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-bucket_name">Bucket Name</label>
<Input id="edit-bucket_name" bind:value={editingMedia.bucket_name} class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-storage_class">Storage Class</label>
<Input id="edit-storage_class" bind:value={editingMedia.storage_class} placeholder="STANDARD, GLACIER, etc." class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-access_key_id">Access Key ID</label>
<Input id="edit-access_key_id" bind:value={editingMedia.access_key_id} class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="flex items-center gap-3 h-10 px-1">
<input id="edit-path_style_access" type="checkbox" bind:checked={editingMedia.path_style_access} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="edit-path_style_access">Path-Style Access</label>
</div>
<div class="flex items-center gap-3 h-10 px-1">
<input id="edit-obfuscate_filenames" type="checkbox" bind:checked={editingMedia.obfuscate_filenames} class="w-4 h-4 rounded border-border-color bg-bg-primary text-blue-600 focus:ring-blue-500/20" />
<label class="text-xs font-medium text-text-secondary cursor-pointer" for="edit-obfuscate_filenames">Obfuscate Filenames</label>
</div>
</div>
</div>
{/if}
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="edit-status">Status</label>
<select
id="edit-status"
bind:value={editingMedia.status}
class="w-full h-10 bg-bg-primary border border-border-color rounded-xl px-4 text-sm font-medium text-text-primary outline-none focus:ring-2 focus:ring-blue-500/20 transition-all appearance-none cursor-pointer"
>
<option value="active">Active</option>
<option value="retired">Retired</option>
<option value="failed">Hardware Failure</option>
</select>
<div class="relative">
<select
id="edit-status"
bind:value={editingMedia.status}
class="w-full h-10 bg-bg-primary border border-border-color rounded-xl px-4 pr-10 text-sm font-medium text-text-primary outline-none focus:ring-2 focus:ring-blue-500/20 transition-all appearance-none cursor-pointer"
>
<option value="active">Active</option>
<option value="full">Fully Utilized</option>
<option value="retired">Retired</option>
<option value="failed">Hardware Failure</option>
</select>
<ChevronDown size={16} class="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none" />
</div>
</div>
</div>
+12 -16
View File
@@ -19,10 +19,9 @@ test.describe('Backup & Restore', () => {
const registerResp = await requestContext.post(`${API_URL}/inventory/media`, {
data: {
identifier: 'SPECIFIC_TAPE_001',
media_type: 'mock_lto',
generation_tier: 'LTO-7',
capacity: 6000,
config: {}
media_type: "lto_tape",
generation: 'LTO-7',
capacity: 6000
}
});
const media = await registerResp.json();
@@ -61,10 +60,9 @@ test.describe('Backup & Restore', () => {
const registerResp = await requestContext.post(`${API_URL}/inventory/media`, {
data: {
identifier: 'RESTORE_TAPE_001',
media_type: 'mock_lto',
generation_tier: 'LTO-8',
capacity: 12000,
config: {}
media_type: "lto_tape",
generation: 'LTO-8',
capacity: 12000
}
});
const media = await registerResp.json();
@@ -116,10 +114,9 @@ test.describe('Backup & Restore', () => {
const registerResp = await requestContext.post(`${API_URL}/inventory/media`, {
data: {
identifier: 'DELETE_TAPE_001',
media_type: 'mock_lto',
generation_tier: 'LTO-8',
capacity: 12000,
config: {}
media_type: "lto_tape",
generation: 'LTO-8',
capacity: 12000
}
});
const media = await registerResp.json();
@@ -165,10 +162,9 @@ test.describe('Backup & Restore', () => {
const registerResp = await requestContext.post(`${API_URL}/inventory/media`, {
data: {
identifier: 'MANIFEST_TAPE_001',
media_type: 'mock_lto',
generation_tier: 'LTO-8',
capacity: 12000,
config: {}
media_type: "lto_tape",
generation: 'LTO-8',
capacity: 12000
}
});
const media = await registerResp.json();
+10 -7
View File
@@ -102,19 +102,19 @@ test.describe('TapeHoard Golden Path', () => {
// Tape media is registered via API (discovery-only flow — no UI form for tape)
const registerResp = await request.post(`${API_URL}/inventory/media`, {
data: {
media_type: 'mock_lto',
media_type: "lto_tape",
identifier: 'TAPE001',
generation_tier: 'LTO-6',
generation: 'LTO-6',
capacity: 100 * 1024 * 1024 * 1024,
location: 'Test Shelf',
config: { device_path: MOCK_LTO_PATH }
location: 'Test Shelf'
}
});
expect(registerResp.ok()).toBe(true);
await page.goto('/inventory');
await page.waitForLoadState('networkidle');
await expect(page.getByText('TAPE001')).toBeVisible({ timeout: 10000 });
// Use first() to handle multiple matches (e.g., in both Active and Discovered sections)
await expect(page.getByText('TAPE001').first()).toBeVisible({ timeout: 10000 });
console.log('Step 4: Initialization');
page.on('dialog', dialog => {
@@ -125,7 +125,8 @@ test.describe('TapeHoard Golden Path', () => {
await expect(page.getByText(/initialized successfully/i)).toBeVisible({ timeout: 10000 });
console.log('Step 5: Archival');
await expect(page.getByText('TAPE001', { exact: true })).toBeVisible();
const tapeLocator = page.getByText('TAPE001', { exact: true });
await expect(tapeLocator.first()).toBeVisible();
await page.getByRole('button', { name: /Auto archive/i }).click();
await expect(page.getByText(/Archival job initiated/i)).toBeVisible();
@@ -143,7 +144,9 @@ test.describe('TapeHoard Golden Path', () => {
await page.getByRole('button', { name: SOURCE_ROOT }).first().dblclick();
await page.getByText('subfolder').first().dblclick();
await expect(page.getByText('test_file_2.txt')).toBeVisible();
await expect(page.getByText('TAPE001')).toBeVisible();
// Use first() to handle multiple matches
const tapeLocator2 = page.getByText('TAPE001');
await expect(tapeLocator2.first()).toBeVisible();
console.log('Step 8: Data Recovery');
const fileRow = page.locator('div[role="button"]', { hasText: 'test_file_2.txt' });
+16 -20
View File
@@ -11,11 +11,10 @@ test.describe('Media Lifecycle', () => {
const registerResp = await requestContext.post(`${API_URL}/inventory/media`, {
data: {
identifier: 'TEST_LTO_001',
media_type: 'mock_lto',
generation_tier: 'LTO-8',
media_type: 'lto_tape',
generation: 'LTO-8',
capacity: 12000,
location: 'Test Lab',
config: {}
location: 'Test Lab'
}
});
expect(registerResp.ok()).toBe(true);
@@ -52,16 +51,15 @@ test.describe('Media Lifecycle', () => {
const registerResp = await requestContext.post(`${API_URL}/inventory/media`, {
data: {
identifier: 'TEST_HDD_001',
media_type: 'hdd',
generation_tier: 'HDD',
media_type: 'local_hdd',
capacity: 1000000,
location: 'Test Lab',
config: { mount_path: hddPath }
mount_path: hddPath
}
});
expect(registerResp.ok()).toBe(true);
const media = await registerResp.json();
expect(media.media_type).toBe('hdd');
expect(media.media_type).toBe('local_hdd');
console.log('Step 2: Retire media');
const retireResp = await requestContext.patch(`${API_URL}/inventory/media/${media.id}`, {
@@ -102,10 +100,9 @@ test.describe('Media Lifecycle', () => {
const registerResp = await requestContext.post(`${API_URL}/inventory/media`, {
data: {
identifier: 'TEST_DUP_001',
media_type: 'mock_lto',
generation_tier: 'LTO-7',
capacity: 6000,
config: {}
media_type: 'lto_tape',
generation: 'LTO-7',
capacity: 6000
}
});
expect(registerResp.ok()).toBe(true);
@@ -115,10 +112,9 @@ test.describe('Media Lifecycle', () => {
const dupResp = await requestContext.post(`${API_URL}/inventory/media`, {
data: {
identifier: 'TEST_DUP_001',
media_type: 'mock_lto',
generation_tier: 'LTO-7',
capacity: 6000,
config: {}
media_type: 'lto_tape',
generation: 'LTO-7',
capacity: 6000
}
});
expect(dupResp.status()).toBe(400);
@@ -133,21 +129,21 @@ test.describe('Media Lifecycle', () => {
const requestContext = await setupRequestContext();
const activeMedia = await requestContext.post(`${API_URL}/inventory/media`, {
data: { identifier: 'CAT_ACTIVE', media_type: 'mock_lto', generation_tier: 'LTO-8', capacity: 12000, config: {} }
data: { identifier: 'CAT_ACTIVE', media_type: 'lto_tape', generation: 'LTO-8', capacity: 12000 }
}).then(r => r.json());
const fullMedia = await requestContext.post(`${API_URL}/inventory/media`, {
data: { identifier: 'CAT_FULL', media_type: 'mock_lto', generation_tier: 'LTO-8', capacity: 12000, config: {} }
data: { identifier: 'CAT_FULL', media_type: 'lto_tape', generation: 'LTO-8', capacity: 12000 }
}).then(r => r.json());
await requestContext.patch(`${API_URL}/inventory/media/${fullMedia.id}`, { data: { status: 'full' } });
const failedMedia = await requestContext.post(`${API_URL}/inventory/media`, {
data: { identifier: 'CAT_FAILED', media_type: 'mock_lto', generation_tier: 'LTO-8', capacity: 12000, config: {} }
data: { identifier: 'CAT_FAILED', media_type: 'lto_tape', generation: 'LTO-8', capacity: 12000 }
}).then(r => r.json());
await requestContext.patch(`${API_URL}/inventory/media/${failedMedia.id}`, { data: { status: 'failed' } });
const retiredMedia = await requestContext.post(`${API_URL}/inventory/media`, {
data: { identifier: 'CAT_RETIRED', media_type: 'mock_lto', generation_tier: 'LTO-8', capacity: 12000, config: {} }
data: { identifier: 'CAT_RETIRED', media_type: 'lto_tape', generation: 'LTO-8', capacity: 12000 }
}).then(r => r.json());
await requestContext.patch(`${API_URL}/inventory/media/${retiredMedia.id}`, { data: { status: 'retired' } });
+2 -2
View File
@@ -37,7 +37,7 @@ test.describe('Settings & System', () => {
test('dashboard stats reflect system state', async ({ page }) => {
const requestContext = await setupRequestContext();
configureBackend(requestContext);
await configureBackend(requestContext);
const statsResp = await requestContext.get(`${API_URL}/system/dashboard/stats`);
expect(statsResp.ok()).toBe(true);
@@ -69,7 +69,7 @@ test.describe('Settings & System', () => {
test('tree endpoint returns source roots', async ({ page }) => {
const requestContext = await setupRequestContext();
configureBackend(requestContext);
await configureBackend(requestContext);
const treeResp = await requestContext.get(`${API_URL}/system/tree`);
expect(treeResp.ok()).toBe(true);