From daa69fd8ca13b1eb4a1e27c4c3163254e0c28ddc Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Tue, 5 May 2026 03:40:57 -0400 Subject: [PATCH] incremental media inventory improvements --- ...5b03b_add_structured_location_and_type_.py | 138 ++++ backend/app/api/inventory.py | 271 ++++-- backend/app/api/schemas.py | 152 +++- backend/app/db/models.py | 43 +- backend/app/providers/cloud.py | 57 +- backend/app/providers/hdd.py | 34 + backend/app/providers/tape.py | 30 +- backend/app/services/archiver.py | 44 +- backend/tests/test_api_inventory.py | 5 +- frontend/src/lib/api/index.ts | 2 +- frontend/src/lib/api/types.gen.ts | 479 ++++++++++- .../file-browser/FileBrowserRowItem.svelte | 4 +- frontend/src/lib/types.ts | 80 ++ frontend/src/routes/inventory/+page.svelte | 778 ++++++++++++++---- frontend/tests/backup-restore.test.ts | 28 +- frontend/tests/full-workflow.test.ts | 17 +- frontend/tests/media-lifecycle.test.ts | 36 +- frontend/tests/settings.test.ts | 4 +- 18 files changed, 1842 insertions(+), 360 deletions(-) create mode 100644 backend/alembic/versions/6a15f2e5b03b_add_structured_location_and_type_.py diff --git a/backend/alembic/versions/6a15f2e5b03b_add_structured_location_and_type_.py b/backend/alembic/versions/6a15f2e5b03b_add_structured_location_and_type_.py new file mode 100644 index 0000000..f8b318b --- /dev/null +++ b/backend/alembic/versions/6a15f2e5b03b_add_structured_location_and_type_.py @@ -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") diff --git a/backend/app/api/inventory.py b/backend/app/api/inventory.py index 19c70c9..0f5f126 100644 --- a/backend/app/api/inventory.py +++ b/backend/app/api/inventory.py @@ -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)) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index dbf5a30..df86c37 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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 diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 928f728..8351263 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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") diff --git a/backend/app/providers/cloud.py b/backend/app/providers/cloud.py index e207c88..e793514 100644 --- a/backend/app/providers/cloud.py +++ b/backend/app/providers/cloud.py @@ -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""" diff --git a/backend/app/providers/hdd.py b/backend/app/providers/hdd.py index 5b2d8ff..9653b33 100644 --- a/backend/app/providers/hdd.py +++ b/backend/app/providers/hdd.py @@ -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]): diff --git a/backend/app/providers/tape.py b/backend/app/providers/tape.py index dd05d79..b528d73 100644 --- a/backend/app/providers/tape.py +++ b/backend/app/providers/tape.py @@ -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, }, } diff --git a/backend/app/services/archiver.py b/backend/app/services/archiver.py index b89a90b..25ea713 100644 --- a/backend/app/services/archiver.py +++ b/backend/app/services/archiver.py @@ -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) diff --git a/backend/tests/test_api_inventory.py b/backend/tests/test_api_inventory.py index 725fd23..d1c3ea8 100644 --- a/backend/tests/test_api_inventory.py +++ b/backend/tests/test_api_inventory.py @@ -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 diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index 74c8b03..42be9f1 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { addDirectoryToRestoreQueue, addFileToRestoreQueue, archiveBrowse, archiveMetadata, archiveSearch, archiveTree, batchAddToRestoreQueue, batchConfirmDiscrepancies, batchDeleteDiscrepancies, batchDismissDiscrepancies, batchResolveDiscrepancies, batchTrack, browseDiscrepancies, browseRestoreQueue, cancelJob, checkHealth, clearRestoreQueue, confirmDiscrepancy, createMedia, deleteDiscrepancy, deleteMedia, detectMedia, discoverHardware, dismissDiscrepancy, downloadExclusionReport, exportDatabase, filesystemBrowse, filesystemSearch, filesystemTree, getAnalytics, getDashboardStats, getDiscrepancyTree, getJob, getJobCount, getJobLogs, getJobStats, getRestoreManifest, getRestoreQueue, getRestoreQueueTree, getScanStatus, getSettings, getTreemap, ignoreHardware, importDatabase, initializeMedia, listBackups, listDirectories, listDiscrepancies, listJobs, listMedia, listProviders, type Options, removeFromRestoreQueue, reorderMedia, resetTestEnvironment, retryJob, streamJobs, testExclusions, testNotification, triggerAutoBackup, triggerBackup, triggerIndexing, triggerRestore, triggerScan, undoDismissDiscrepancy, updateMedia, updateSettings } from './sdk.gen'; -export type { AddDirectoryToRestoreQueueData, AddDirectoryToRestoreQueueError, AddDirectoryToRestoreQueueErrors, AddDirectoryToRestoreQueueResponses, AddFileToRestoreQueueData, AddFileToRestoreQueueError, AddFileToRestoreQueueErrors, AddFileToRestoreQueueResponses, AppApiBackupsJobSchema, AppApiCommonJobSchema, ArchiveBrowseData, ArchiveBrowseError, ArchiveBrowseErrors, ArchiveBrowseResponses, ArchiveMetadataData, ArchiveMetadataError, ArchiveMetadataErrors, ArchiveMetadataResponse, ArchiveMetadataResponses, ArchiveSearchData, ArchiveSearchError, ArchiveSearchErrors, ArchiveSearchResponses, ArchiveTreeData, ArchiveTreeError, ArchiveTreeErrors, ArchiveTreeResponse, ArchiveTreeResponses, BatchAddToRestoreQueueData, BatchAddToRestoreQueueError, BatchAddToRestoreQueueErrors, BatchAddToRestoreQueueResponses, BatchCartRequest, BatchConfirmDiscrepanciesData, BatchConfirmDiscrepanciesError, BatchConfirmDiscrepanciesErrors, BatchConfirmDiscrepanciesResponses, BatchDeleteDiscrepanciesData, BatchDeleteDiscrepanciesError, BatchDeleteDiscrepanciesErrors, BatchDeleteDiscrepanciesResponses, BatchDiscrepancyAction, BatchDismissDiscrepanciesData, BatchDismissDiscrepanciesError, BatchDismissDiscrepanciesErrors, BatchDismissDiscrepanciesResponses, BatchResolveDiscrepanciesData, BatchResolveDiscrepanciesError, BatchResolveDiscrepanciesErrors, BatchResolveDiscrepanciesResponse, BatchResolveDiscrepanciesResponses, BatchResolveReport, BatchTrackData, BatchTrackError, BatchTrackErrors, BatchTrackRequest, BatchTrackResponses, BrowseDiscrepanciesData, BrowseDiscrepanciesError, BrowseDiscrepanciesErrors, BrowseDiscrepanciesResponse, BrowseDiscrepanciesResponses, BrowseResponseSchema, BrowseRestoreQueueData, BrowseRestoreQueueError, BrowseRestoreQueueErrors, BrowseRestoreQueueResponse, BrowseRestoreQueueResponses, CancelJobData, CancelJobError, CancelJobErrors, CancelJobResponses, CartFileItemSchema, CartItemSchema, CartTreeNodeSchema, CheckHealthData, CheckHealthResponses, ClearRestoreQueueData, ClearRestoreQueueResponses, ClientOptions, ConfirmDiscrepancyData, ConfirmDiscrepancyError, ConfirmDiscrepancyErrors, ConfirmDiscrepancyResponses, CreateMediaData, CreateMediaError, CreateMediaErrors, CreateMediaResponse, CreateMediaResponses, DashboardStatsSchema, DeleteDiscrepancyData, DeleteDiscrepancyError, DeleteDiscrepancyErrors, DeleteDiscrepancyResponses, DeleteMediaData, DeleteMediaError, DeleteMediaErrors, DeleteMediaResponses, DetectMediaData, DetectMediaResponses, DirectoryCartRequest, DiscoverHardwareData, DiscoverHardwareResponses, DiscrepancySchema, DismissDiscrepancyData, DismissDiscrepancyError, DismissDiscrepancyErrors, DismissDiscrepancyResponses, DownloadExclusionReportData, DownloadExclusionReportError, DownloadExclusionReportErrors, DownloadExclusionReportResponses, ExportDatabaseData, ExportDatabaseResponses, FileItemSchema, FilesystemBrowseData, FilesystemBrowseError, FilesystemBrowseErrors, FilesystemBrowseResponse, FilesystemBrowseResponses, FilesystemSearchData, FilesystemSearchError, FilesystemSearchErrors, FilesystemSearchResponse, FilesystemSearchResponses, FilesystemTreeData, FilesystemTreeError, FilesystemTreeErrors, FilesystemTreeResponse, FilesystemTreeResponses, GetAnalyticsData, GetAnalyticsResponses, GetDashboardStatsData, GetDashboardStatsResponse, GetDashboardStatsResponses, GetDiscrepancyTreeData, GetDiscrepancyTreeError, GetDiscrepancyTreeErrors, GetDiscrepancyTreeResponse, GetDiscrepancyTreeResponses, GetJobCountData, GetJobCountResponses, GetJobData, GetJobError, GetJobErrors, GetJobLogsData, GetJobLogsError, GetJobLogsErrors, GetJobLogsResponse, GetJobLogsResponses, GetJobResponse, GetJobResponses, GetJobStatsData, GetJobStatsResponses, GetRestoreManifestData, GetRestoreManifestResponse, GetRestoreManifestResponses, GetRestoreQueueData, GetRestoreQueueResponse, GetRestoreQueueResponses, GetRestoreQueueTreeData, GetRestoreQueueTreeError, GetRestoreQueueTreeErrors, GetRestoreQueueTreeResponse, GetRestoreQueueTreeResponses, GetScanStatusData, GetScanStatusResponse, GetScanStatusResponses, GetSettingsData, GetSettingsResponse, GetSettingsResponses, GetTreemapData, GetTreemapResponses, HttpValidationError, IgnoreHardwareData, IgnoreHardwareError, IgnoreHardwareErrors, IgnoreHardwareRequest, IgnoreHardwareResponses, ImportDatabaseData, ImportDatabaseError, ImportDatabaseErrors, ImportDatabaseResponses, InitializeMediaData, InitializeMediaError, InitializeMediaErrors, InitializeMediaResponses, ItemMetadataSchema, JobLogSchema, ListBackupsData, ListBackupsResponse, ListBackupsResponses, ListDirectoriesData, ListDirectoriesError, ListDirectoriesErrors, ListDirectoriesResponses, ListDiscrepanciesData, ListDiscrepanciesResponse, ListDiscrepanciesResponses, ListJobsData, ListJobsError, ListJobsErrors, ListJobsResponse, ListJobsResponses, ListMediaData, ListMediaError, ListMediaErrors, ListMediaResponse, ListMediaResponses, ListProvidersData, ListProvidersResponse, ListProvidersResponses, ManifestMediaSchema, MediaCreateSchema, MediaSchema, MediaUpdateSchema, RemoveFromRestoreQueueData, RemoveFromRestoreQueueError, RemoveFromRestoreQueueErrors, RemoveFromRestoreQueueResponses, ReorderMediaData, ReorderMediaError, ReorderMediaErrors, ReorderMediaRequest, ReorderMediaResponses, ResetTestEnvironmentData, ResetTestEnvironmentResponses, RestoreManifestSchema, RestoreTriggerRequest, RetryJobData, RetryJobError, RetryJobErrors, RetryJobResponses, ScanStatusSchema, SettingSchema, StorageProviderSchema, StreamJobsData, StreamJobsResponses, TestExclusionsData, TestExclusionsError, TestExclusionsErrors, TestExclusionsRequest, TestExclusionsResponse, TestExclusionsResponse2, TestExclusionsResponses, TestNotificationData, TestNotificationError, TestNotificationErrors, TestNotificationRequest, TestNotificationResponses, TreeNodeSchema, TriggerAutoBackupData, TriggerAutoBackupResponses, TriggerBackupData, TriggerBackupError, TriggerBackupErrors, TriggerBackupResponses, TriggerIndexingData, TriggerIndexingResponses, TriggerRestoreData, TriggerRestoreError, TriggerRestoreErrors, TriggerRestoreResponses, TriggerScanData, TriggerScanResponses, UndoDismissDiscrepancyData, UndoDismissDiscrepancyError, UndoDismissDiscrepancyErrors, UndoDismissDiscrepancyResponses, UpdateMediaData, UpdateMediaError, UpdateMediaErrors, UpdateMediaResponse, UpdateMediaResponses, UpdateSettingsData, UpdateSettingsError, UpdateSettingsErrors, UpdateSettingsResponses, ValidationError } from './types.gen'; +export type { AddDirectoryToRestoreQueueData, AddDirectoryToRestoreQueueError, AddDirectoryToRestoreQueueErrors, AddDirectoryToRestoreQueueResponses, AddFileToRestoreQueueData, AddFileToRestoreQueueError, AddFileToRestoreQueueErrors, AddFileToRestoreQueueResponses, AppApiBackupsJobSchema, AppApiCommonJobSchema, ArchiveBrowseData, ArchiveBrowseError, ArchiveBrowseErrors, ArchiveBrowseResponses, ArchiveMetadataData, ArchiveMetadataError, ArchiveMetadataErrors, ArchiveMetadataResponse, ArchiveMetadataResponses, ArchiveSearchData, ArchiveSearchError, ArchiveSearchErrors, ArchiveSearchResponses, ArchiveTreeData, ArchiveTreeError, ArchiveTreeErrors, ArchiveTreeResponse, ArchiveTreeResponses, BatchAddToRestoreQueueData, BatchAddToRestoreQueueError, BatchAddToRestoreQueueErrors, BatchAddToRestoreQueueResponses, BatchCartRequest, BatchConfirmDiscrepanciesData, BatchConfirmDiscrepanciesError, BatchConfirmDiscrepanciesErrors, BatchConfirmDiscrepanciesResponses, BatchDeleteDiscrepanciesData, BatchDeleteDiscrepanciesError, BatchDeleteDiscrepanciesErrors, BatchDeleteDiscrepanciesResponses, BatchDiscrepancyAction, BatchDismissDiscrepanciesData, BatchDismissDiscrepanciesError, BatchDismissDiscrepanciesErrors, BatchDismissDiscrepanciesResponses, BatchResolveDiscrepanciesData, BatchResolveDiscrepanciesError, BatchResolveDiscrepanciesErrors, BatchResolveDiscrepanciesResponse, BatchResolveDiscrepanciesResponses, BatchResolveReport, BatchTrackData, BatchTrackError, BatchTrackErrors, BatchTrackRequest, BatchTrackResponses, BrowseDiscrepanciesData, BrowseDiscrepanciesError, BrowseDiscrepanciesErrors, BrowseDiscrepanciesResponse, BrowseDiscrepanciesResponses, BrowseResponseSchema, BrowseRestoreQueueData, BrowseRestoreQueueError, BrowseRestoreQueueErrors, BrowseRestoreQueueResponse, BrowseRestoreQueueResponses, CancelJobData, CancelJobError, CancelJobErrors, CancelJobResponses, CartFileItemSchema, CartItemSchema, CartTreeNodeSchema, CheckHealthData, CheckHealthResponses, ClearRestoreQueueData, ClearRestoreQueueResponses, ClientOptions, CloudCreateSchema, ConfirmDiscrepancyData, ConfirmDiscrepancyError, ConfirmDiscrepancyErrors, ConfirmDiscrepancyResponses, CreateMediaData, CreateMediaError, CreateMediaErrors, CreateMediaResponse, CreateMediaResponses, DashboardStatsSchema, DeleteDiscrepancyData, DeleteDiscrepancyError, DeleteDiscrepancyErrors, DeleteDiscrepancyResponses, DeleteMediaData, DeleteMediaError, DeleteMediaErrors, DeleteMediaResponses, DetectMediaData, DetectMediaResponses, DirectoryCartRequest, DiscoverHardwareData, DiscoverHardwareResponses, DiscrepancySchema, DismissDiscrepancyData, DismissDiscrepancyError, DismissDiscrepancyErrors, DismissDiscrepancyResponses, DownloadExclusionReportData, DownloadExclusionReportError, DownloadExclusionReportErrors, DownloadExclusionReportResponses, ExportDatabaseData, ExportDatabaseResponses, FileItemSchema, FilesystemBrowseData, FilesystemBrowseError, FilesystemBrowseErrors, FilesystemBrowseResponse, FilesystemBrowseResponses, FilesystemSearchData, FilesystemSearchError, FilesystemSearchErrors, FilesystemSearchResponse, FilesystemSearchResponses, FilesystemTreeData, FilesystemTreeError, FilesystemTreeErrors, FilesystemTreeResponse, FilesystemTreeResponses, GetAnalyticsData, GetAnalyticsResponses, GetDashboardStatsData, GetDashboardStatsResponse, GetDashboardStatsResponses, GetDiscrepancyTreeData, GetDiscrepancyTreeError, GetDiscrepancyTreeErrors, GetDiscrepancyTreeResponse, GetDiscrepancyTreeResponses, GetJobCountData, GetJobCountResponses, GetJobData, GetJobError, GetJobErrors, GetJobLogsData, GetJobLogsError, GetJobLogsErrors, GetJobLogsResponse, GetJobLogsResponses, GetJobResponse, GetJobResponses, GetJobStatsData, GetJobStatsResponses, GetRestoreManifestData, GetRestoreManifestResponse, GetRestoreManifestResponses, GetRestoreQueueData, GetRestoreQueueResponse, GetRestoreQueueResponses, GetRestoreQueueTreeData, GetRestoreQueueTreeError, GetRestoreQueueTreeErrors, GetRestoreQueueTreeResponse, GetRestoreQueueTreeResponses, GetScanStatusData, GetScanStatusResponse, GetScanStatusResponses, GetSettingsData, GetSettingsResponse, GetSettingsResponses, GetTreemapData, GetTreemapResponses, HttpValidationError, IgnoreHardwareData, IgnoreHardwareError, IgnoreHardwareErrors, IgnoreHardwareRequest, IgnoreHardwareResponses, ImportDatabaseData, ImportDatabaseError, ImportDatabaseErrors, ImportDatabaseResponses, InitializeMediaData, InitializeMediaError, InitializeMediaErrors, InitializeMediaResponses, ItemMetadataSchema, JobLogSchema, ListBackupsData, ListBackupsResponse, ListBackupsResponses, ListDirectoriesData, ListDirectoriesError, ListDirectoriesErrors, ListDirectoriesResponses, ListDiscrepanciesData, ListDiscrepanciesResponse, ListDiscrepanciesResponses, ListJobsData, ListJobsError, ListJobsErrors, ListJobsResponse, ListJobsResponses, ListMediaData, ListMediaError, ListMediaErrors, ListMediaResponse, ListMediaResponses, ListProvidersData, ListProvidersResponse, ListProvidersResponses, LtoTapeCreateSchema, ManifestMediaSchema, MediaSchema, MediaUpdateSchema, OfflineHddCreateSchema, RemoveFromRestoreQueueData, RemoveFromRestoreQueueError, RemoveFromRestoreQueueErrors, RemoveFromRestoreQueueResponses, ReorderMediaData, ReorderMediaError, ReorderMediaErrors, ReorderMediaRequest, ReorderMediaResponses, ResetTestEnvironmentData, ResetTestEnvironmentResponses, RestoreManifestSchema, RestoreTriggerRequest, RetryJobData, RetryJobError, RetryJobErrors, RetryJobResponses, ScanStatusSchema, SettingSchema, StorageProviderSchema, StreamJobsData, StreamJobsResponses, TestExclusionsData, TestExclusionsError, TestExclusionsErrors, TestExclusionsRequest, TestExclusionsResponse, TestExclusionsResponse2, TestExclusionsResponses, TestNotificationData, TestNotificationError, TestNotificationErrors, TestNotificationRequest, TestNotificationResponses, TreeNodeSchema, TriggerAutoBackupData, TriggerAutoBackupResponses, TriggerBackupData, TriggerBackupError, TriggerBackupErrors, TriggerBackupResponses, TriggerIndexingData, TriggerIndexingResponses, TriggerRestoreData, TriggerRestoreError, TriggerRestoreErrors, TriggerRestoreResponses, TriggerScanData, TriggerScanResponses, UndoDismissDiscrepancyData, UndoDismissDiscrepancyError, UndoDismissDiscrepancyErrors, UndoDismissDiscrepancyResponses, UpdateMediaData, UpdateMediaError, UpdateMediaErrors, UpdateMediaResponse, UpdateMediaResponses, UpdateSettingsData, UpdateSettingsError, UpdateSettingsErrors, UpdateSettingsResponses, ValidationError } from './types.gen'; diff --git a/frontend/src/lib/api/types.gen.ts b/frontend/src/lib/api/types.gen.ts index cebabfa..668764d 100644 --- a/frontend/src/lib/api/types.gen.ts +++ b/frontend/src/lib/api/types.gen.ts @@ -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'; diff --git a/frontend/src/lib/components/file-browser/FileBrowserRowItem.svelte b/frontend/src/lib/components/file-browser/FileBrowserRowItem.svelte index 9e9bc15..4d96bf0 100644 --- a/frontend/src/lib/components/file-browser/FileBrowserRowItem.svelte +++ b/frontend/src/lib/components/file-browser/FileBrowserRowItem.svelte @@ -172,7 +172,7 @@ -
+
-
+
{#if mode === "discrepancies"} {#if item.discrepancy_id && item.has_versions && !item.is_deleted}
-
+
{media.identifier}
- {#if (media.media_type === 'local_hdd' || media.media_type === 'hdd') && media.config?.mount_path} + {#if media.media_type === 'local_hdd' && media.mount_path}
- {media.config.mount_path} + {media.mount_path}
- {:else if media.media_type === 's3_compat' && media.config?.bucket_name} + {:else if media.media_type === 's3_compat' && media.bucket_name}
- {media.config.bucket_name} + {media.bucket_name} +
+ {:else if media.media_type === 'lto_tape' && media.generation} +
+ {media.generation}
{/if} -
+
{#if media.status === 'failed'} Hardware failure {:else if media.status === 'retired'} Retired {/if} - {#if media.config?.encryption_key || media.config?.encryption_passphrase} + {#if media.encryption_key_id || media.encrypted} Encrypted {/if} + {#if media.worm} + WORM + {/if} + {#if media.cleaning_cartridge} + Cleaning + {/if}
@@ -444,9 +609,12 @@
{media.media_type}
- {media.generation_tier || 'Generic'} - {#if (media.media_type === 'local_hdd' || media.media_type === 'hdd') && media.config?.device_uuid} - {media.config.device_uuid} + {media.generation || media.generation_tier || 'Generic'} + {#if media.media_type === 'local_hdd' && media.device_uuid} + {media.device_uuid} + {/if} + {#if media.media_type === 'local_hdd' && media.drive_model} + {media.drive_model} {/if}
@@ -454,7 +622,7 @@
- {media.location || 'Unknown'} + {media.location || (media.location_building ? `${media.location_building}${media.location_room ? ' / ' + media.location_room : ''}` : 'Unknown')}
@@ -551,28 +719,35 @@
{/if} -
- + showRegisterDialog = true; + }}>Add media
@@ -868,73 +1043,259 @@ -
- {#each providersList.filter(p => !['lto_tape', 'mock_lto'].includes(p.provider_id)) as provider} - - {/each} -
+
+ {#each providersList.filter(p => !['mock_lto'].includes(p.provider_id)) as provider} + + {/each} +
-
-
- - -
-
- - -

Auto-detected when possible. You can manually reduce this to reserve space.

-
-
+ +
+

Identity

+
+
+ + +
-
- -
- - -
-
- - - {#if activeProvider} -
- {#each Object.entries(activeProvider.config_schema) as [key, schema]} - {@const field = schema as any} -
- {#if field.type === 'boolean'} -
- - -
- {:else} - - - {/if} + {#if newMedia.media_type === 'lto_tape'} +
+ +
+ + +
- {/each} + {:else if newMedia.media_type === 'local_hdd'} +
+ + +
+ {:else if newMedia.media_type === 's3_compat'} +
+ + +
+ {/if}
- {/if} + + {#if newMedia.media_type === 'local_hdd'} +
+
+ + +
+
+ + +
+
+ {/if} + + {#if newMedia.media_type === 's3_compat'} +
+
+ +
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ {/if} +
+ + +
+ + + {#if newMedia.media_type === 'lto_tape'} +

Auto-populated from LTO generation. You can manually adjust.

+ {/if} +
+ + +
+

Location

+ {#if newMedia.media_type !== 's3_compat'} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {:else} +
+ +
+ + +
+
+ {/if} +
+ + +
+

Configuration

+ + {#if newMedia.media_type === 'lto_tape'} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ {:else if newMedia.media_type === 'local_hdd'} +
+
+ + +
+
+ + +
+
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+ {:else if newMedia.media_type === 's3_compat'} +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ {/if} +
@@ -966,57 +1327,142 @@
{editingMedia.identifier} - {editingMedia.media_type} • {editingMedia.generation_tier} + {editingMedia.media_type} • {editingMedia.generation || editingMedia.generation_tier || 'Generic'}
-
- - + +
+

Location

+ {#if editingMedia.media_type !== 's3_compat'} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {:else} +
+ + +
+ {/if}
- - {#if providersList.find(p => p.provider_id === editingMedia?.media_type)} - {@const schema = providersList.find(p => p.provider_id === editingMedia?.media_type)?.config_schema || {}} -
- {#each Object.entries(schema) as [key, entry]} - {@const field = entry as any} -
- {#if field.type === 'boolean'} -
- - -
- {:else} - - - {/if} + +
+ + +
+ + + {#if editingMedia.media_type === 'lto_tape'} +
+

LTO Configuration

+
+
+ +
- {/each} +
+ + +
+
+ + +
+
+ + +
+
+
+ {:else if editingMedia.media_type === 'local_hdd'} +
+

HDD Configuration

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {:else if editingMedia.media_type === 's3_compat'} +
+

Cloud Configuration

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
{/if}
- +
+ + +
diff --git a/frontend/tests/backup-restore.test.ts b/frontend/tests/backup-restore.test.ts index 7ffe973..dc64880 100644 --- a/frontend/tests/backup-restore.test.ts +++ b/frontend/tests/backup-restore.test.ts @@ -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(); diff --git a/frontend/tests/full-workflow.test.ts b/frontend/tests/full-workflow.test.ts index 75c3fc8..50c30d5 100644 --- a/frontend/tests/full-workflow.test.ts +++ b/frontend/tests/full-workflow.test.ts @@ -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' }); diff --git a/frontend/tests/media-lifecycle.test.ts b/frontend/tests/media-lifecycle.test.ts index af44120..a712a9a 100644 --- a/frontend/tests/media-lifecycle.test.ts +++ b/frontend/tests/media-lifecycle.test.ts @@ -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' } }); diff --git a/frontend/tests/settings.test.ts b/frontend/tests/settings.test.ts index 0a5672c..6571170 100644 --- a/frontend/tests/settings.test.ts +++ b/frontend/tests/settings.test.ts @@ -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);