incremental media inventory improvements
This commit is contained in:
@@ -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")
|
||||
+201
-56
@@ -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:
|
||||
media_record.capacity = request_data.capacity
|
||||
|
||||
if request_data.config:
|
||||
current_config = (
|
||||
json.loads(media_record.extra_config) if media_record.extra_config else {}
|
||||
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).",
|
||||
)
|
||||
current_config.update(request_data.config)
|
||||
media_record.extra_config = json.dumps(current_config)
|
||||
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"
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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,6 +442,10 @@ 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
|
||||
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
|
||||
|
||||
+134
-18
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
@@ -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] || '';
|
||||
newMedia.generation = '';
|
||||
newMedia.capacity = 0;
|
||||
});
|
||||
dynamicConfig = newConfig;
|
||||
} 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;
|
||||
}
|
||||
|
||||
try {
|
||||
await createMedia({
|
||||
body: {
|
||||
// Build type-specific payload
|
||||
let payload: any = {
|
||||
media_type: newMedia.media_type,
|
||||
identifier: newMedia.identifier,
|
||||
generation_tier: newMedia.generation_tier,
|
||||
capacity: newMedia.capacity_gb * 1024 * 1024 * 1024,
|
||||
capacity: newMedia.capacity * 1000 * 1000 * 1000, // Convert GB to bytes
|
||||
location: newMedia.location,
|
||||
config: dynamicConfig
|
||||
},
|
||||
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: 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">
|
||||
@@ -556,19 +724,26 @@
|
||||
newMedia.media_type = asset.type === 'tape' ? 'lto_tape' : 'local_hdd';
|
||||
newMedia.identifier = asset.identifier === 'Unrecognized Disk' ? '' : asset.identifier;
|
||||
|
||||
// Pre-fill dynamic config
|
||||
// Pre-fill based on asset type
|
||||
if (asset.type === 'hdd') {
|
||||
dynamicConfig.mount_path = asset.mount_path;
|
||||
dynamicConfig.device_uuid = asset.device_uuid || '';
|
||||
newMedia.mount_path = asset.mount_path || '';
|
||||
newMedia.device_uuid = asset.device_uuid || '';
|
||||
if (asset.capacity_bytes) {
|
||||
newMedia.capacity_gb = Math.floor(asset.capacity_bytes / (1024 * 1024 * 1024));
|
||||
newMedia.capacity = asset.capacity_bytes;
|
||||
}
|
||||
} 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?.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) {
|
||||
newMedia.capacity_gb = Math.floor(asset.hardware_info.tape.max_capacity_mib / 1024);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
showRegisterDialog = true;
|
||||
@@ -869,13 +1044,20 @@
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
{#each providersList.filter(p => !['lto_tape', 'mock_lto'].includes(p.provider_id)) as provider}
|
||||
{#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';
|
||||
else if (provider.provider_id === 'local_hdd') newMedia.location = 'Offsite Safe';
|
||||
else newMedia.location = 'Cloud';
|
||||
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)}
|
||||
@@ -885,58 +1067,237 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 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">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" />
|
||||
<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>
|
||||
|
||||
{#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>
|
||||
{: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 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_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>
|
||||
<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">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" />
|
||||
<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>
|
||||
|
||||
<!-- 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}
|
||||
<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>
|
||||
{/each}
|
||||
</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">
|
||||
<Button variant="outline" class="flex-1 h-10" onclick={() => showRegisterDialog = false}>Cancel</Button>
|
||||
<Button variant="default" class="flex-[2] h-10" onclick={handleRegister}>Register media</Button>
|
||||
@@ -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} • {editingMedia.generation_tier}</span>
|
||||
<span class="text-xs text-text-secondary block opacity-60">{editingMedia.media_type} • {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>
|
||||
|
||||
<!-- 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"
|
||||
/>
|
||||
<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>
|
||||
{/each}
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
<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 text-sm font-medium text-text-primary outline-none focus:ring-2 focus:ring-blue-500/20 transition-all appearance-none cursor-pointer"
|
||||
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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user