Передаем на тест
This commit is contained in:
@@ -1 +1 @@
|
|||||||
from . import plugins, tasks, settings, connections, environments, mappings, migration, git
|
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from typing import List
|
from typing import List
|
||||||
from ...core.config_models import AppConfig, Environment, GlobalSettings
|
from ...core.config_models import AppConfig, Environment, GlobalSettings
|
||||||
|
from ...models.storage import StorageConfig
|
||||||
from ...dependencies import get_config_manager
|
from ...dependencies import get_config_manager
|
||||||
from ...core.config_manager import ConfigManager
|
from ...core.config_manager import ConfigManager
|
||||||
from ...core.logger import logger, belief_scope
|
from ...core.logger import logger, belief_scope
|
||||||
@@ -56,6 +57,33 @@ async def update_global_settings(
|
|||||||
return settings
|
return settings
|
||||||
# [/DEF:update_global_settings:Function]
|
# [/DEF:update_global_settings:Function]
|
||||||
|
|
||||||
|
# [DEF:get_storage_settings:Function]
|
||||||
|
# @PURPOSE: Retrieves storage-specific settings.
|
||||||
|
# @RETURN: StorageConfig - The storage configuration.
|
||||||
|
@router.get("/storage", response_model=StorageConfig)
|
||||||
|
async def get_storage_settings(config_manager: ConfigManager = Depends(get_config_manager)):
|
||||||
|
with belief_scope("get_storage_settings"):
|
||||||
|
return config_manager.get_config().settings.storage
|
||||||
|
# [/DEF:get_storage_settings:Function]
|
||||||
|
|
||||||
|
# [DEF:update_storage_settings:Function]
|
||||||
|
# @PURPOSE: Updates storage-specific settings.
|
||||||
|
# @PARAM: storage (StorageConfig) - The new storage settings.
|
||||||
|
# @POST: Storage settings are updated and saved.
|
||||||
|
# @RETURN: StorageConfig - The updated storage settings.
|
||||||
|
@router.put("/storage", response_model=StorageConfig)
|
||||||
|
async def update_storage_settings(storage: StorageConfig, config_manager: ConfigManager = Depends(get_config_manager)):
|
||||||
|
with belief_scope("update_storage_settings"):
|
||||||
|
is_valid, message = config_manager.validate_path(storage.root_path)
|
||||||
|
if not is_valid:
|
||||||
|
raise HTTPException(status_code=400, detail=message)
|
||||||
|
|
||||||
|
settings = config_manager.get_config().settings
|
||||||
|
settings.storage = storage
|
||||||
|
config_manager.update_global_settings(settings)
|
||||||
|
return config_manager.get_config().settings.storage
|
||||||
|
# [/DEF:update_storage_settings:Function]
|
||||||
|
|
||||||
# [DEF:get_environments:Function]
|
# [DEF:get_environments:Function]
|
||||||
# @PURPOSE: Lists all configured Superset environments.
|
# @PURPOSE: Lists all configured Superset environments.
|
||||||
# @PRE: Config manager is available.
|
# @PRE: Config manager is available.
|
||||||
|
|||||||
126
backend/src/api/routes/storage.py
Normal file
126
backend/src/api/routes/storage.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# [DEF:storage_routes:Module]
|
||||||
|
#
|
||||||
|
# @SEMANTICS: storage, files, upload, download, backup, repository
|
||||||
|
# @PURPOSE: API endpoints for file storage management (backups and repositories).
|
||||||
|
# @LAYER: API
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.models.storage
|
||||||
|
#
|
||||||
|
# @INVARIANT: All paths must be validated against path traversal.
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from typing import List, Optional
|
||||||
|
from backend.src.models.storage import StoredFile, FileCategory
|
||||||
|
from backend.src.dependencies import get_plugin_loader
|
||||||
|
from backend.src.plugins.storage.plugin import StoragePlugin
|
||||||
|
from backend.src.core.logger import belief_scope
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
router = APIRouter(tags=["storage"])
|
||||||
|
|
||||||
|
# [DEF:list_files:Function]
|
||||||
|
# @PURPOSE: List all files in the storage system, optionally filtered by category.
|
||||||
|
#
|
||||||
|
# @PRE: None.
|
||||||
|
# @POST: Returns a list of StoredFile objects.
|
||||||
|
#
|
||||||
|
# @PARAM: category (Optional[FileCategory]) - Filter by category.
|
||||||
|
# @RETURN: List[StoredFile] - List of files.
|
||||||
|
#
|
||||||
|
# @RELATION: CALLS -> StoragePlugin.list_files
|
||||||
|
@router.get("/files", response_model=List[StoredFile])
|
||||||
|
async def list_files(category: Optional[FileCategory] = None, plugin_loader=Depends(get_plugin_loader)):
|
||||||
|
with belief_scope("list_files"):
|
||||||
|
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||||
|
if not storage_plugin:
|
||||||
|
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||||
|
return storage_plugin.list_files(category)
|
||||||
|
# [/DEF:list_files:Function]
|
||||||
|
|
||||||
|
# [DEF:upload_file:Function]
|
||||||
|
# @PURPOSE: Upload a file to the storage system under a specific category.
|
||||||
|
#
|
||||||
|
# @PRE: category must be a valid FileCategory.
|
||||||
|
# @PRE: file must be a valid UploadFile.
|
||||||
|
# @POST: Returns the StoredFile object of the uploaded file.
|
||||||
|
#
|
||||||
|
# @PARAM: category (FileCategory) - Target category.
|
||||||
|
# @PARAM: file (UploadFile) - The file content.
|
||||||
|
# @RETURN: StoredFile - Metadata of the uploaded file.
|
||||||
|
#
|
||||||
|
# @SIDE_EFFECT: Writes file to the filesystem.
|
||||||
|
#
|
||||||
|
# @RELATION: CALLS -> StoragePlugin.save_file
|
||||||
|
@router.post("/upload", response_model=StoredFile, status_code=201)
|
||||||
|
async def upload_file(
|
||||||
|
category: FileCategory = Form(...),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
plugin_loader=Depends(get_plugin_loader)
|
||||||
|
):
|
||||||
|
with belief_scope("upload_file"):
|
||||||
|
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||||
|
if not storage_plugin:
|
||||||
|
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||||
|
try:
|
||||||
|
return await storage_plugin.save_file(file, category)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:upload_file:Function]
|
||||||
|
|
||||||
|
# [DEF:delete_file:Function]
|
||||||
|
# @PURPOSE: Delete a specific file from the storage system.
|
||||||
|
#
|
||||||
|
# @PRE: category must be a valid FileCategory.
|
||||||
|
# @PRE: filename must not contain path separators.
|
||||||
|
# @POST: File is removed from storage.
|
||||||
|
#
|
||||||
|
# @PARAM: category (FileCategory) - File category.
|
||||||
|
# @PARAM: filename (str) - Name of the file.
|
||||||
|
# @RETURN: None
|
||||||
|
#
|
||||||
|
# @SIDE_EFFECT: Deletes file from the filesystem.
|
||||||
|
#
|
||||||
|
# @RELATION: CALLS -> StoragePlugin.delete_file
|
||||||
|
@router.delete("/files/{category}/{filename}", status_code=204)
|
||||||
|
async def delete_file(category: FileCategory, filename: str, plugin_loader=Depends(get_plugin_loader)):
|
||||||
|
with belief_scope("delete_file"):
|
||||||
|
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||||
|
if not storage_plugin:
|
||||||
|
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||||
|
try:
|
||||||
|
storage_plugin.delete_file(category, filename)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:delete_file:Function]
|
||||||
|
|
||||||
|
# [DEF:download_file:Function]
|
||||||
|
# @PURPOSE: Retrieve a file for download.
|
||||||
|
#
|
||||||
|
# @PRE: category must be a valid FileCategory.
|
||||||
|
# @PRE: filename must exist in the specified category.
|
||||||
|
# @POST: Returns a FileResponse.
|
||||||
|
#
|
||||||
|
# @PARAM: category (FileCategory) - File category.
|
||||||
|
# @PARAM: filename (str) - Name of the file.
|
||||||
|
# @RETURN: FileResponse - The file content.
|
||||||
|
#
|
||||||
|
# @RELATION: CALLS -> StoragePlugin.get_file_path
|
||||||
|
@router.get("/download/{category}/{filename}")
|
||||||
|
async def download_file(category: FileCategory, filename: str, plugin_loader=Depends(get_plugin_loader)):
|
||||||
|
with belief_scope("download_file"):
|
||||||
|
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||||
|
if not storage_plugin:
|
||||||
|
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||||
|
try:
|
||||||
|
path = storage_plugin.get_file_path(category, filename)
|
||||||
|
return FileResponse(path=path, filename=filename)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:download_file:Function]
|
||||||
|
|
||||||
|
# [/DEF:storage_routes:Module]
|
||||||
@@ -18,7 +18,7 @@ import os
|
|||||||
|
|
||||||
from .dependencies import get_task_manager, get_scheduler_service
|
from .dependencies import get_task_manager, get_scheduler_service
|
||||||
from .core.logger import logger, belief_scope
|
from .core.logger import logger, belief_scope
|
||||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git
|
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage
|
||||||
from .core.database import init_db
|
from .core.database import init_db
|
||||||
|
|
||||||
# [DEF:App:Global]
|
# [DEF:App:Global]
|
||||||
@@ -89,6 +89,7 @@ app.include_router(environments.router, prefix="/api/environments", tags=["Envir
|
|||||||
app.include_router(mappings.router)
|
app.include_router(mappings.router)
|
||||||
app.include_router(migration.router)
|
app.include_router(migration.router)
|
||||||
app.include_router(git.router)
|
app.include_router(git.router)
|
||||||
|
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
|
||||||
|
|
||||||
# [DEF:websocket_endpoint:Function]
|
# [DEF:websocket_endpoint:Function]
|
||||||
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task.
|
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from ..models.storage import StorageConfig
|
||||||
|
|
||||||
# [DEF:Schedule:DataClass]
|
# [DEF:Schedule:DataClass]
|
||||||
# @PURPOSE: Represents a backup schedule configuration.
|
# @PURPOSE: Represents a backup schedule configuration.
|
||||||
@@ -43,6 +44,7 @@ class LoggingConfig(BaseModel):
|
|||||||
# @PURPOSE: Represents global application settings.
|
# @PURPOSE: Represents global application settings.
|
||||||
class GlobalSettings(BaseModel):
|
class GlobalSettings(BaseModel):
|
||||||
backup_path: str
|
backup_path: str
|
||||||
|
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||||
default_environment_id: Optional[str] = None
|
default_environment_id: Optional[str] = None
|
||||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||||
|
|
||||||
|
|||||||
@@ -50,9 +50,18 @@ class PluginLoader:
|
|||||||
sys.path.insert(0, plugin_parent_dir)
|
sys.path.insert(0, plugin_parent_dir)
|
||||||
|
|
||||||
for filename in os.listdir(self.plugin_dir):
|
for filename in os.listdir(self.plugin_dir):
|
||||||
|
file_path = os.path.join(self.plugin_dir, filename)
|
||||||
|
|
||||||
|
# Handle directory-based plugins (packages)
|
||||||
|
if os.path.isdir(file_path):
|
||||||
|
init_file = os.path.join(file_path, "__init__.py")
|
||||||
|
if os.path.exists(init_file):
|
||||||
|
self._load_module(filename, init_file)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle single-file plugins
|
||||||
if filename.endswith(".py") and filename != "__init__.py":
|
if filename.endswith(".py") and filename != "__init__.py":
|
||||||
module_name = filename[:-3]
|
module_name = filename[:-3]
|
||||||
file_path = os.path.join(self.plugin_dir, filename)
|
|
||||||
self._load_module(module_name, file_path)
|
self._load_module(module_name, file_path)
|
||||||
# [/DEF:_load_plugins:Function]
|
# [/DEF:_load_plugins:Function]
|
||||||
|
|
||||||
|
|||||||
28
backend/src/models/storage.py
Normal file
28
backend/src/models/storage.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# [DEF:FileCategory:Class]
|
||||||
|
class FileCategory(str, Enum):
|
||||||
|
BACKUP = "backup"
|
||||||
|
REPOSITORY = "repository"
|
||||||
|
# [/DEF:FileCategory:Class]
|
||||||
|
|
||||||
|
# [DEF:StorageConfig:Class]
|
||||||
|
class StorageConfig(BaseModel):
|
||||||
|
root_path: str = Field(default="../ss-tools-storage", description="Absolute path to the storage root directory.")
|
||||||
|
backup_structure_pattern: str = Field(default="{category}/", description="Pattern for backup directory structure.")
|
||||||
|
repo_structure_pattern: str = Field(default="{category}/", description="Pattern for repository directory structure.")
|
||||||
|
filename_pattern: str = Field(default="{name}_{timestamp}", description="Pattern for filenames.")
|
||||||
|
# [/DEF:StorageConfig:Class]
|
||||||
|
|
||||||
|
# [DEF:StoredFile:Class]
|
||||||
|
class StoredFile(BaseModel):
|
||||||
|
name: str = Field(..., description="Name of the file (including extension).")
|
||||||
|
path: str = Field(..., description="Relative path from storage root.")
|
||||||
|
size: int = Field(..., ge=0, description="Size of the file in bytes.")
|
||||||
|
created_at: datetime = Field(..., description="Creation timestamp.")
|
||||||
|
category: FileCategory = Field(..., description="Category of the file.")
|
||||||
|
mime_type: Optional[str] = Field(None, description="MIME type of the file.")
|
||||||
|
# [/DEF:StoredFile:Class]
|
||||||
3
backend/src/plugins/storage/__init__.py
Normal file
3
backend/src/plugins/storage/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .plugin import StoragePlugin
|
||||||
|
|
||||||
|
__all__ = ["StoragePlugin"]
|
||||||
230
backend/src/plugins/storage/plugin.py
Normal file
230
backend/src/plugins/storage/plugin.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# [DEF:StoragePlugin:Module]
|
||||||
|
#
|
||||||
|
# @SEMANTICS: storage, files, filesystem, plugin
|
||||||
|
# @PURPOSE: Provides core filesystem operations for managing backups and repositories.
|
||||||
|
# @LAYER: App
|
||||||
|
# @RELATION: IMPLEMENTS -> PluginBase
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.models.storage
|
||||||
|
#
|
||||||
|
# @INVARIANT: All file operations must be restricted to the configured storage root.
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from ...core.plugin_base import PluginBase
|
||||||
|
from ...core.logger import belief_scope, logger
|
||||||
|
from ...models.storage import StoredFile, FileCategory, StorageConfig
|
||||||
|
from ...dependencies import get_config_manager
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:StoragePlugin:Class]
|
||||||
|
# @PURPOSE: Implementation of the storage management plugin.
|
||||||
|
class StoragePlugin(PluginBase):
|
||||||
|
"""
|
||||||
|
Plugin for managing local file storage for backups and repositories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the StoragePlugin and ensures required directories exist.
|
||||||
|
def __init__(self):
|
||||||
|
with belief_scope("StoragePlugin:init"):
|
||||||
|
self.ensure_directories()
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:id:Function]
|
||||||
|
# @PURPOSE: Returns the unique identifier for the storage plugin.
|
||||||
|
# @RETURN: str - "storage-manager"
|
||||||
|
def id(self) -> str:
|
||||||
|
return "storage-manager"
|
||||||
|
# [/DEF:id:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:name:Function]
|
||||||
|
# @PURPOSE: Returns the human-readable name of the storage plugin.
|
||||||
|
# @RETURN: str - "Storage Manager"
|
||||||
|
def name(self) -> str:
|
||||||
|
return "Storage Manager"
|
||||||
|
# [/DEF:name:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:description:Function]
|
||||||
|
# @PURPOSE: Returns a description of the storage plugin.
|
||||||
|
# @RETURN: str - Plugin description.
|
||||||
|
def description(self) -> str:
|
||||||
|
return "Manages local file storage for backups and repositories."
|
||||||
|
# [/DEF:description:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:version:Function]
|
||||||
|
# @PURPOSE: Returns the version of the storage plugin.
|
||||||
|
# @RETURN: str - "1.0.0"
|
||||||
|
def version(self) -> str:
|
||||||
|
return "1.0.0"
|
||||||
|
# [/DEF:version:Function]
|
||||||
|
|
||||||
|
# [DEF:get_schema:Function]
|
||||||
|
# @PURPOSE: Returns the JSON schema for storage plugin parameters.
|
||||||
|
# @RETURN: Dict[str, Any] - JSON schema.
|
||||||
|
def get_schema(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [c.value for c in FileCategory],
|
||||||
|
"title": "Category"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["category"]
|
||||||
|
}
|
||||||
|
# [/DEF:get_schema:Function]
|
||||||
|
|
||||||
|
# [DEF:execute:Function]
|
||||||
|
# @PURPOSE: Executes storage-related tasks (placeholder for PluginBase compliance).
|
||||||
|
async def execute(self, params: Dict[str, Any]):
|
||||||
|
with belief_scope("StoragePlugin:execute"):
|
||||||
|
logger.info(f"[StoragePlugin][Action] Executing with params: {params}")
|
||||||
|
# [/DEF:execute:Function]
|
||||||
|
|
||||||
|
# [DEF:get_storage_root:Function]
|
||||||
|
# @PURPOSE: Resolves the absolute path to the storage root.
|
||||||
|
# @POST: Returns a Path object representing the storage root.
|
||||||
|
def get_storage_root(self) -> Path:
|
||||||
|
with belief_scope("StoragePlugin:get_storage_root"):
|
||||||
|
config_manager = get_config_manager()
|
||||||
|
storage_config = config_manager.get_config().settings.storage
|
||||||
|
root = Path(storage_config.root_path)
|
||||||
|
if not root.is_absolute():
|
||||||
|
# Resolve relative to the workspace root (ss-tools)
|
||||||
|
root = (Path(__file__).parents[4] / root).resolve()
|
||||||
|
return root
|
||||||
|
# [/DEF:get_storage_root:Function]
|
||||||
|
|
||||||
|
# [DEF:ensure_directories:Function]
|
||||||
|
# @PURPOSE: Creates the storage root and category subdirectories if they don't exist.
|
||||||
|
# @SIDE_EFFECT: Creates directories on the filesystem.
|
||||||
|
def ensure_directories(self):
|
||||||
|
with belief_scope("StoragePlugin:ensure_directories"):
|
||||||
|
root = self.get_storage_root()
|
||||||
|
for category in FileCategory:
|
||||||
|
path = root / f"{category.value}s"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.debug(f"[StoragePlugin][Action] Ensured directory: {path}")
|
||||||
|
# [/DEF:ensure_directories:Function]
|
||||||
|
|
||||||
|
# [DEF:validate_path:Function]
|
||||||
|
# @PURPOSE: Prevents path traversal attacks by ensuring the path is within the storage root.
|
||||||
|
# @PRE: path must be a Path object.
|
||||||
|
# @POST: Returns the resolved absolute path if valid, otherwise raises ValueError.
|
||||||
|
def validate_path(self, path: Path) -> Path:
|
||||||
|
with belief_scope("StoragePlugin:validate_path"):
|
||||||
|
root = self.get_storage_root().resolve()
|
||||||
|
resolved = path.resolve()
|
||||||
|
try:
|
||||||
|
resolved.relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
logger.error(f"[StoragePlugin][Coherence:Failed] Path traversal detected: {resolved} is not under {root}")
|
||||||
|
raise ValueError("Access denied: Path is outside of storage root.")
|
||||||
|
return resolved
|
||||||
|
# [/DEF:validate_path:Function]
|
||||||
|
|
||||||
|
# [DEF:list_files:Function]
|
||||||
|
# @PURPOSE: Lists all files in a specific category.
|
||||||
|
# @PARAM: category (Optional[FileCategory]) - The category to list.
|
||||||
|
# @RETURN: List[StoredFile] - List of file metadata objects.
|
||||||
|
def list_files(self, category: Optional[FileCategory] = None) -> List[StoredFile]:
|
||||||
|
with belief_scope("StoragePlugin:list_files"):
|
||||||
|
root = self.get_storage_root()
|
||||||
|
files = []
|
||||||
|
|
||||||
|
categories = [category] if category else list(FileCategory)
|
||||||
|
|
||||||
|
for cat in categories:
|
||||||
|
cat_dir = root / f"{cat.value}s"
|
||||||
|
if not cat_dir.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in cat_dir.iterdir():
|
||||||
|
if item.is_file():
|
||||||
|
stat = item.stat()
|
||||||
|
files.append(StoredFile(
|
||||||
|
name=item.name,
|
||||||
|
path=str(item.relative_to(root)),
|
||||||
|
size=stat.st_size,
|
||||||
|
created_at=datetime.fromtimestamp(stat.st_ctime),
|
||||||
|
category=cat,
|
||||||
|
mime_type=None # Could use python-magic here if needed
|
||||||
|
))
|
||||||
|
|
||||||
|
return sorted(files, key=lambda x: x.created_at, reverse=True)
|
||||||
|
# [/DEF:list_files:Function]
|
||||||
|
|
||||||
|
# [DEF:save_file:Function]
|
||||||
|
# @PURPOSE: Saves an uploaded file to the specified category.
|
||||||
|
# @PARAM: file (UploadFile) - The uploaded file.
|
||||||
|
# @PARAM: category (FileCategory) - The target category.
|
||||||
|
# @RETURN: StoredFile - Metadata of the saved file.
|
||||||
|
# @SIDE_EFFECT: Writes file to disk.
|
||||||
|
async def save_file(self, file: UploadFile, category: FileCategory) -> StoredFile:
|
||||||
|
with belief_scope("StoragePlugin:save_file"):
|
||||||
|
root = self.get_storage_root()
|
||||||
|
dest_dir = root / f"{category.value}s"
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
dest_path = self.validate_path(dest_dir / file.filename)
|
||||||
|
|
||||||
|
with dest_path.open("wb") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
|
||||||
|
stat = dest_path.stat()
|
||||||
|
return StoredFile(
|
||||||
|
name=dest_path.name,
|
||||||
|
path=str(dest_path.relative_to(root)),
|
||||||
|
size=stat.st_size,
|
||||||
|
created_at=datetime.fromtimestamp(stat.st_ctime),
|
||||||
|
category=category,
|
||||||
|
mime_type=file.content_type
|
||||||
|
)
|
||||||
|
# [/DEF:save_file:Function]
|
||||||
|
|
||||||
|
# [DEF:delete_file:Function]
|
||||||
|
# @PURPOSE: Deletes a file from the specified category.
|
||||||
|
# @PARAM: category (FileCategory) - The category.
|
||||||
|
# @PARAM: filename (str) - The name of the file.
|
||||||
|
# @SIDE_EFFECT: Removes file from disk.
|
||||||
|
def delete_file(self, category: FileCategory, filename: str):
|
||||||
|
with belief_scope("StoragePlugin:delete_file"):
|
||||||
|
root = self.get_storage_root()
|
||||||
|
file_path = self.validate_path(root / f"{category.value}s" / filename)
|
||||||
|
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
logger.info(f"[StoragePlugin][Action] Deleted file: {file_path}")
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError(f"File {filename} not found in {category.value}s")
|
||||||
|
# [/DEF:delete_file:Function]
|
||||||
|
|
||||||
|
# [DEF:get_file_path:Function]
|
||||||
|
# @PURPOSE: Returns the absolute path of a file for download.
|
||||||
|
# @PARAM: category (FileCategory) - The category.
|
||||||
|
# @PARAM: filename (str) - The name of the file.
|
||||||
|
# @RETURN: Path - Absolute path to the file.
|
||||||
|
def get_file_path(self, category: FileCategory, filename: str) -> Path:
|
||||||
|
with belief_scope("StoragePlugin:get_file_path"):
|
||||||
|
root = self.get_storage_root()
|
||||||
|
file_path = self.validate_path(root / f"{category.value}s" / filename)
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise FileNotFoundError(f"File {filename} not found in {category.value}s")
|
||||||
|
|
||||||
|
return file_path
|
||||||
|
# [/DEF:get_file_path:Function]
|
||||||
|
|
||||||
|
# [/DEF:StoragePlugin:Class]
|
||||||
|
# [/DEF:StoragePlugin:Module]
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
<a href="/tools/search" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.tools_search}</a>
|
<a href="/tools/search" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.tools_search}</a>
|
||||||
<a href="/tools/mapper" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.tools_mapper}</a>
|
<a href="/tools/mapper" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.tools_mapper}</a>
|
||||||
<a href="/tools/debug" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.tools_debug}</a>
|
<a href="/tools/debug" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.tools_debug}</a>
|
||||||
|
<a href="/tools/storage" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.tools_storage}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative inline-block group">
|
<div class="relative inline-block group">
|
||||||
|
|||||||
85
frontend/src/components/storage/FileList.svelte
Normal file
85
frontend/src/components/storage/FileList.svelte
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<!-- [DEF:FileList:Component] -->
|
||||||
|
<!--
|
||||||
|
@SEMANTICS: storage, files, list, table
|
||||||
|
@PURPOSE: Displays a table of files with metadata and actions.
|
||||||
|
@LAYER: Component
|
||||||
|
@RELATION: DEPENDS_ON -> storageService
|
||||||
|
|
||||||
|
@PROPS: files (Array) - List of StoredFile objects.
|
||||||
|
@EVENTS: delete (filename) - Dispatched when a file is deleted.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// [SECTION: IMPORTS]
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { downloadFileUrl } from '../../services/storageService';
|
||||||
|
// [/SECTION: IMPORTS]
|
||||||
|
|
||||||
|
export let files = [];
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- [SECTION: TEMPLATE] -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full bg-white border border-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created At</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
{#each files as file}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{file.name}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">{file.category}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatSize(file.size)}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatDate(file.created_at)}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<a
|
||||||
|
href={downloadFileUrl(file.category, file.name)}
|
||||||
|
download={file.name}
|
||||||
|
class="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
on:click={() => dispatch('delete', { category: file.category, filename: file.name })}
|
||||||
|
class="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-6 py-10 text-center text-sm text-gray-500">
|
||||||
|
No files found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- [/SECTION: TEMPLATE] -->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ... */
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- [/DEF:FileList:Component] -->
|
||||||
126
frontend/src/components/storage/FileUpload.svelte
Normal file
126
frontend/src/components/storage/FileUpload.svelte
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<!-- [DEF:FileUpload:Component] -->
|
||||||
|
<!--
|
||||||
|
@SEMANTICS: storage, upload, files
|
||||||
|
@PURPOSE: Provides a form for uploading files to a specific category.
|
||||||
|
@LAYER: Component
|
||||||
|
@RELATION: DEPENDS_ON -> storageService
|
||||||
|
|
||||||
|
@PROPS: None
|
||||||
|
@EVENTS: uploaded - Dispatched when a file is successfully uploaded.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// [SECTION: IMPORTS]
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { uploadFile } from '../../services/storageService';
|
||||||
|
import { addToast } from '../../lib/toasts';
|
||||||
|
// [/SECTION: IMPORTS]
|
||||||
|
|
||||||
|
// [DEF:handleUpload:Function]
|
||||||
|
/**
|
||||||
|
* @purpose Handles the file upload process.
|
||||||
|
* @pre A file must be selected in the file input.
|
||||||
|
* @post The file is uploaded to the server and a success toast is shown.
|
||||||
|
*/
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let fileInput;
|
||||||
|
let category = 'backup';
|
||||||
|
let isUploading = false;
|
||||||
|
let dragOver = false;
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
isUploading = true;
|
||||||
|
try {
|
||||||
|
await uploadFile(file, category);
|
||||||
|
addToast(`File ${file.name} uploaded successfully.`, 'success');
|
||||||
|
fileInput.value = '';
|
||||||
|
dispatch('uploaded');
|
||||||
|
} catch (error) {
|
||||||
|
addToast(`Upload failed: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
isUploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [/DEF:handleUpload:Function]
|
||||||
|
|
||||||
|
// [DEF:handleDrop:Function]
|
||||||
|
/**
|
||||||
|
* @purpose Handles the file drop event for drag-and-drop.
|
||||||
|
* @param {DragEvent} event - The drop event.
|
||||||
|
*/
|
||||||
|
function handleDrop(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
dragOver = false;
|
||||||
|
const files = event.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
fileInput.files = files;
|
||||||
|
handleUpload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [/DEF:handleDrop:Function]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- [SECTION: TEMPLATE] -->
|
||||||
|
<div class="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Upload File</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Target Category</label>
|
||||||
|
<select
|
||||||
|
bind:value={category}
|
||||||
|
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="backup">Backup</option>
|
||||||
|
<option value="repository">Repository</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-dashed rounded-md transition-colors
|
||||||
|
{dragOver ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300'}"
|
||||||
|
on:dragover|preventDefault={() => dragOver = true}
|
||||||
|
on:dragleave|preventDefault={() => dragOver = false}
|
||||||
|
on:drop|preventDefault={handleDrop}
|
||||||
|
>
|
||||||
|
<div class="space-y-1 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
|
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex text-sm text-gray-600">
|
||||||
|
<label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
|
||||||
|
<span>Upload a file</span>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
name="file-upload"
|
||||||
|
type="file"
|
||||||
|
class="sr-only"
|
||||||
|
bind:this={fileInput}
|
||||||
|
on:change={handleUpload}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<p class="pl-1">or drag and drop</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">ZIP, YAML, JSON up to 50MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isUploading}
|
||||||
|
<div class="flex items-center justify-center space-x-2 text-indigo-600">
|
||||||
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600"></div>
|
||||||
|
<span class="text-sm font-medium">Uploading...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- [/SECTION: TEMPLATE] -->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ... */
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- [/DEF:FileUpload:Component] -->
|
||||||
@@ -123,6 +123,8 @@ export const api = {
|
|||||||
deleteEnvironment: (id) => requestApi(`/settings/environments/${id}`, 'DELETE'),
|
deleteEnvironment: (id) => requestApi(`/settings/environments/${id}`, 'DELETE'),
|
||||||
testEnvironmentConnection: (id) => postApi(`/settings/environments/${id}/test`, {}),
|
testEnvironmentConnection: (id) => postApi(`/settings/environments/${id}/test`, {}),
|
||||||
updateEnvironmentSchedule: (id, schedule) => requestApi(`/environments/${id}/schedule`, 'PUT', schedule),
|
updateEnvironmentSchedule: (id, schedule) => requestApi(`/environments/${id}/schedule`, 'PUT', schedule),
|
||||||
|
getStorageSettings: () => fetchApi('/settings/storage'),
|
||||||
|
updateStorageSettings: (storage) => requestApi('/settings/storage', 'PUT', storage),
|
||||||
getEnvironmentsList: () => fetchApi('/environments'),
|
getEnvironmentsList: () => fetchApi('/environments'),
|
||||||
};
|
};
|
||||||
// [/DEF:api:Data]
|
// [/DEF:api:Data]
|
||||||
@@ -143,3 +145,5 @@ export const deleteEnvironment = api.deleteEnvironment;
|
|||||||
export const testEnvironmentConnection = api.testEnvironmentConnection;
|
export const testEnvironmentConnection = api.testEnvironmentConnection;
|
||||||
export const updateEnvironmentSchedule = api.updateEnvironmentSchedule;
|
export const updateEnvironmentSchedule = api.updateEnvironmentSchedule;
|
||||||
export const getEnvironmentsList = api.getEnvironmentsList;
|
export const getEnvironmentsList = api.getEnvironmentsList;
|
||||||
|
export const getStorageSettings = api.getStorageSettings;
|
||||||
|
export const updateStorageSettings = api.updateStorageSettings;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { updateGlobalSettings, addEnvironment, updateEnvironment, deleteEnvironment, testEnvironmentConnection } from '../../lib/api';
|
import { updateGlobalSettings, addEnvironment, updateEnvironment, deleteEnvironment, testEnvironmentConnection, updateStorageSettings } from '../../lib/api';
|
||||||
import { addToast } from '../../lib/toasts';
|
import { addToast } from '../../lib/toasts';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { Button, Input, Card, PageHeader } from '$lib/ui';
|
import { Button, Input, Card, PageHeader } from '$lib/ui';
|
||||||
@@ -41,6 +41,24 @@
|
|||||||
}
|
}
|
||||||
// [/DEF:handleSaveGlobal:Function]
|
// [/DEF:handleSaveGlobal:Function]
|
||||||
|
|
||||||
|
// [DEF:handleSaveStorage:Function]
|
||||||
|
/* @PURPOSE: Saves storage-specific settings.
|
||||||
|
@PRE: settings.settings.storage must contain valid configuration.
|
||||||
|
@POST: Storage settings are updated via API.
|
||||||
|
*/
|
||||||
|
async function handleSaveStorage() {
|
||||||
|
try {
|
||||||
|
console.log("[Settings.handleSaveStorage][Action] Saving storage settings.");
|
||||||
|
await updateStorageSettings(settings.settings.storage);
|
||||||
|
addToast('Storage settings saved', 'success');
|
||||||
|
console.log("[Settings.handleSaveStorage][Coherence:OK] Storage settings saved.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Settings.handleSaveStorage][Coherence:Failed] Failed to save storage settings:", error);
|
||||||
|
addToast(error.message || 'Failed to save storage settings', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [/DEF:handleSaveStorage:Function]
|
||||||
|
|
||||||
// [DEF:handleAddOrUpdateEnv:Function]
|
// [DEF:handleAddOrUpdateEnv:Function]
|
||||||
/* @PURPOSE: Adds a new environment or updates an existing one.
|
/* @PURPOSE: Adds a new environment or updates an existing one.
|
||||||
@PRE: newEnv must contain valid environment details.
|
@PRE: newEnv must contain valid environment details.
|
||||||
@@ -166,6 +184,42 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<Card title={$t.settings?.storage_title || "File Storage Configuration"}>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<Input
|
||||||
|
label={$t.settings?.storage_root || "Storage Root Path"}
|
||||||
|
bind:value={settings.settings.storage.root_path}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label={$t.settings?.storage_backup_pattern || "Backup Directory Pattern"}
|
||||||
|
bind:value={settings.settings.storage.backup_structure_pattern}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={$t.settings?.storage_repo_pattern || "Repository Directory Pattern"}
|
||||||
|
bind:value={settings.settings.storage.repo_structure_pattern}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={$t.settings?.storage_filename_pattern || "Filename Pattern"}
|
||||||
|
bind:value={settings.settings.storage.filename_pattern}
|
||||||
|
/>
|
||||||
|
<div class="bg-gray-50 p-4 rounded border border-gray-200">
|
||||||
|
<span class="block text-xs font-semibold text-gray-500 uppercase mb-2">{$t.settings?.storage_preview || "Path Preview"}</span>
|
||||||
|
<code class="text-sm text-indigo-600">
|
||||||
|
{settings.settings.storage.root_path}/backups/sample_backup.zip
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6">
|
||||||
|
<Button on:click={handleSaveStorage}>
|
||||||
|
{$t.common.save}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
<Card title={$t.settings?.env_title || "Superset Environments"}>
|
<Card title={$t.settings?.env_title || "Superset Environments"}>
|
||||||
|
|
||||||
|
|||||||
125
frontend/src/routes/tools/storage/+page.svelte
Normal file
125
frontend/src/routes/tools/storage/+page.svelte
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<!-- [DEF:StoragePage:Component] -->
|
||||||
|
<!--
|
||||||
|
@SEMANTICS: storage, files, management
|
||||||
|
@PURPOSE: Main page for file storage management.
|
||||||
|
@LAYER: Feature
|
||||||
|
@RELATION: DEPENDS_ON -> storageService
|
||||||
|
@RELATION: CONTAINS -> FileList
|
||||||
|
@RELATION: CONTAINS -> FileUpload
|
||||||
|
|
||||||
|
@INVARIANT: Always displays tabs for Backups and Repositories.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// [SECTION: IMPORTS]
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { listFiles, deleteFile } from '../../../services/storageService';
|
||||||
|
import { addToast } from '../../../lib/toasts';
|
||||||
|
import FileList from '../../../components/storage/FileList.svelte';
|
||||||
|
import FileUpload from '../../../components/storage/FileUpload.svelte';
|
||||||
|
// [/SECTION: IMPORTS]
|
||||||
|
|
||||||
|
// [DEF:loadFiles:Function]
|
||||||
|
/**
|
||||||
|
* @purpose Fetches the list of files from the server.
|
||||||
|
* @post Updates the `files` array with the latest data.
|
||||||
|
*/
|
||||||
|
let files = [];
|
||||||
|
let isLoading = false;
|
||||||
|
let activeTab = 'all';
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
const category = activeTab === 'all' ? null : activeTab;
|
||||||
|
files = await listFiles(category);
|
||||||
|
} catch (error) {
|
||||||
|
addToast(`Failed to load files: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [/DEF:loadFiles:Function]
|
||||||
|
|
||||||
|
// [DEF:handleDelete:Function]
|
||||||
|
/**
|
||||||
|
* @purpose Handles the file deletion process.
|
||||||
|
* @param {CustomEvent} event - The delete event containing category and filename.
|
||||||
|
*/
|
||||||
|
async function handleDelete(event) {
|
||||||
|
const { category, filename } = event.detail;
|
||||||
|
if (!confirm(`Are you sure you want to delete ${filename}?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteFile(category, filename);
|
||||||
|
addToast(`File ${filename} deleted.`, 'success');
|
||||||
|
await loadFiles();
|
||||||
|
} catch (error) {
|
||||||
|
addToast(`Delete failed: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [/DEF:handleDelete:Function]
|
||||||
|
|
||||||
|
onMount(loadFiles);
|
||||||
|
|
||||||
|
$: if (activeTab) {
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- [SECTION: TEMPLATE] -->
|
||||||
|
<div class="container mx-auto p-4 max-w-6xl">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">File Storage Management</h1>
|
||||||
|
<button
|
||||||
|
on:click={loadFiles}
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Main Content: File List -->
|
||||||
|
<div class="lg:col-span-2 space-y-4">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-gray-200">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<button
|
||||||
|
on:click={() => activeTab = 'all'}
|
||||||
|
class="py-4 px-1 border-b-2 font-medium text-sm {activeTab === 'all' ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"
|
||||||
|
>
|
||||||
|
All Files
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => activeTab = 'backup'}
|
||||||
|
class="py-4 px-1 border-b-2 font-medium text-sm {activeTab === 'backup' ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"
|
||||||
|
>
|
||||||
|
Backups
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => activeTab = 'repository'}
|
||||||
|
class="py-4 px-1 border-b-2 font-medium text-sm {activeTab === 'repository' ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"
|
||||||
|
>
|
||||||
|
Repositories
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileList {files} on:delete={handleDelete} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar: Upload -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<FileUpload on:uploaded={loadFiles} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- [/SECTION: TEMPLATE] -->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ... */
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- [/DEF:StoragePage:Component] -->
|
||||||
92
frontend/src/services/storageService.js
Normal file
92
frontend/src/services/storageService.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// [DEF:storageService:Module]
|
||||||
|
/**
|
||||||
|
* @purpose Frontend API client for file storage management.
|
||||||
|
* @layer Service
|
||||||
|
* @relation DEPENDS_ON -> backend.api.storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = '/api/storage';
|
||||||
|
|
||||||
|
// [DEF:listFiles:Function]
|
||||||
|
/**
|
||||||
|
* @purpose Fetches the list of files for a given category.
|
||||||
|
* @param {string} [category] - Optional category filter.
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function listFiles(category) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (category) {
|
||||||
|
params.append('category', category);
|
||||||
|
}
|
||||||
|
const response = await fetch(`${API_BASE}/files?${params.toString()}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch files: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
// [/DEF:listFiles:Function]
|
||||||
|
|
||||||
|
// [DEF:uploadFile:Function]
|
||||||
|
/**
|
||||||
|
* @purpose Uploads a file to the storage system.
|
||||||
|
* @param {File} file - The file to upload.
|
||||||
|
* @param {string} category - Target category.
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
export async function uploadFile(file, category) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('category', category);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || `Failed to upload file: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
// [/DEF:uploadFile:Function]
|
||||||
|
|
||||||
|
// [DEF:deleteFile:Function]
|
||||||
|
/**
|
||||||
|
* @purpose Deletes a file from storage.
|
||||||
|
* @param {string} category - File category.
|
||||||
|
* @param {string} filename - Name of the file.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function deleteFile(category, filename) {
|
||||||
|
const response = await fetch(`${API_BASE}/files/${category}/${filename}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || `Failed to delete file: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [/DEF:deleteFile:Function]
|
||||||
|
|
||||||
|
// [DEF:downloadFileUrl:Function]
|
||||||
|
/**
|
||||||
|
* @purpose Returns the URL for downloading a file.
|
||||||
|
* @param {string} category - File category.
|
||||||
|
* @param {string} filename - Name of the file.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function downloadFileUrl(category, filename) {
|
||||||
|
return `${API_BASE}/download/${category}/${filename}`;
|
||||||
|
}
|
||||||
|
// [/DEF:downloadFileUrl:Function]
|
||||||
|
|
||||||
|
export default {
|
||||||
|
listFiles,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
downloadFileUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
// [/DEF:storageService:Module]
|
||||||
@@ -5,64 +5,64 @@
|
|||||||
## Phase 1: Setup
|
## Phase 1: Setup
|
||||||
*Goal: Initialize backend plugin structure and frontend route scaffolding.*
|
*Goal: Initialize backend plugin structure and frontend route scaffolding.*
|
||||||
|
|
||||||
- [ ] T001 Create storage plugin directory and `__init__.py` in `backend/src/plugins/storage/`
|
- [x] T001 Create storage plugin directory and `__init__.py` in `backend/src/plugins/storage/`
|
||||||
- [ ] T002 Create storage models file `backend/src/models/storage.py` with `StorageConfig` and `StoredFile` Pydantic models
|
- [x] T002 Create storage models file `backend/src/models/storage.py` with `StorageConfig` and `StoredFile` Pydantic models
|
||||||
- [ ] T003 Create empty storage route handler `backend/src/api/routes/storage.py` and register in `backend/src/api/routes/__init__.py`
|
- [x] T003 Create empty storage route handler `backend/src/api/routes/storage.py` and register in `backend/src/api/routes/__init__.py`
|
||||||
- [ ] T004 Create frontend storage route directory `frontend/src/routes/tools/storage/` and empty `+page.svelte`
|
- [x] T004 Create frontend storage route directory `frontend/src/routes/tools/storage/` and empty `+page.svelte`
|
||||||
- [ ] T005 Create frontend service `frontend/src/services/storageService.js` stub
|
- [x] T005 Create frontend service `frontend/src/services/storageService.js` stub
|
||||||
|
|
||||||
## Phase 2: Foundational
|
## Phase 2: Foundational
|
||||||
*Goal: Implement core backend logic for storage management, configuration, and security.*
|
*Goal: Implement core backend logic for storage management, configuration, and security.*
|
||||||
|
|
||||||
- [ ] T006 Implement `StoragePlugin` class in `backend/src/plugins/storage/plugin.py` inheriting from `PluginBase`
|
- [x] T006 Implement `StoragePlugin` class in `backend/src/plugins/storage/plugin.py` inheriting from `PluginBase`
|
||||||
- [ ] T007 Implement `get_storage_root()` method in `StoragePlugin` with default path logic (`../ss-tools-storage`)
|
- [x] T007 Implement `get_storage_root()` method in `StoragePlugin` with default path logic (`../ss-tools-storage`)
|
||||||
- [ ] T008 Implement `ensure_directories()` method to create `backups/` and `repositories/` subfolders on init
|
- [x] T008 Implement `ensure_directories()` method to create `backups/` and `repositories/` subfolders on init
|
||||||
- [ ] T009 Implement path traversal protection helper `validate_path(path)` in `StoragePlugin`
|
- [x] T009 Implement path traversal protection helper `validate_path(path)` in `StoragePlugin`
|
||||||
- [ ] T010 Implement `list_files(category)` method in `StoragePlugin` returning `StoredFile` objects
|
- [x] T010 Implement `list_files(category)` method in `StoragePlugin` returning `StoredFile` objects
|
||||||
- [ ] T011 Implement `save_file(file, category)` method in `StoragePlugin` handling uploads
|
- [x] T011 Implement `save_file(file, category)` method in `StoragePlugin` handling uploads
|
||||||
- [ ] T012 Implement `delete_file(category, filename)` method in `StoragePlugin`
|
- [x] T012 Implement `delete_file(category, filename)` method in `StoragePlugin`
|
||||||
- [ ] T013 Implement `get_file_path(category, filename)` method in `StoragePlugin` for downloads
|
- [x] T013 Implement `get_file_path(category, filename)` method in `StoragePlugin` for downloads
|
||||||
- [ ] T014 Register `StoragePlugin` in `backend/src/core/plugin_loader.py` (if manual registration needed)
|
- [x] T014 Register `StoragePlugin` in `backend/src/core/plugin_loader.py` (if manual registration needed)
|
||||||
|
|
||||||
## Phase 3: User Story 1 - File Management Dashboard (Priority: P1)
|
## Phase 3: User Story 1 - File Management Dashboard (Priority: P1)
|
||||||
*Goal: Enable users to list, upload, download, and delete files via Web UI.*
|
*Goal: Enable users to list, upload, download, and delete files via Web UI.*
|
||||||
|
|
||||||
### Backend Endpoints
|
### Backend Endpoints
|
||||||
- [ ] T015 [US1] Implement `GET /api/storage/files` endpoint in `backend/src/api/routes/storage.py` using `StoragePlugin.list_files`
|
- [x] T015 [US1] Implement `GET /api/storage/files` endpoint in `backend/src/api/routes/storage.py` using `StoragePlugin.list_files`
|
||||||
- [ ] T016 [US1] Implement `POST /api/storage/upload` endpoint in `backend/src/api/routes/storage.py` using `StoragePlugin.save_file`
|
- [x] T016 [US1] Implement `POST /api/storage/upload` endpoint in `backend/src/api/routes/storage.py` using `StoragePlugin.save_file`
|
||||||
- [ ] T017 [US1] Implement `DELETE /api/storage/files/{category}/{filename}` endpoint in `backend/src/api/routes/storage.py`
|
- [x] T017 [US1] Implement `DELETE /api/storage/files/{category}/{filename}` endpoint in `backend/src/api/routes/storage.py`
|
||||||
- [ ] T018 [US1] Implement `GET /api/storage/download/{category}/{filename}` endpoint in `backend/src/api/routes/storage.py`
|
- [x] T018 [US1] Implement `GET /api/storage/download/{category}/{filename}` endpoint in `backend/src/api/routes/storage.py`
|
||||||
|
|
||||||
### Frontend Implementation
|
### Frontend Implementation
|
||||||
- [ ] T019 [US1] Implement `listFiles`, `uploadFile`, `deleteFile`, `downloadFileUrl` in `frontend/src/services/storageService.js`
|
- [x] T019 [US1] Implement `listFiles`, `uploadFile`, `deleteFile`, `downloadFileUrl` in `frontend/src/services/storageService.js`
|
||||||
- [ ] T020 [US1] Create `frontend/src/components/storage/FileList.svelte` to display files in a table with metadata
|
- [x] T020 [US1] Create `frontend/src/components/storage/FileList.svelte` to display files in a table with metadata
|
||||||
- [ ] T021 [US1] Create `frontend/src/components/storage/FileUpload.svelte` with category selection and drag-drop support
|
- [x] T021 [US1] Create `frontend/src/components/storage/FileUpload.svelte` with category selection and drag-drop support
|
||||||
- [ ] T022 [US1] Implement main logic in `frontend/src/routes/tools/storage/+page.svelte` to fetch files and handle tabs (Backups vs Repositories)
|
- [x] T022 [US1] Implement main logic in `frontend/src/routes/tools/storage/+page.svelte` to fetch files and handle tabs (Backups vs Repositories)
|
||||||
- [ ] T023 [US1] Integrate `FileList` and `FileUpload` components into `+page.svelte`
|
- [x] T023 [US1] Integrate `FileList` and `FileUpload` components into `+page.svelte`
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Storage Location Configuration (Priority: P2)
|
## Phase 4: User Story 2 - Storage Location Configuration (Priority: P2)
|
||||||
*Goal: Allow administrators to configure the storage root path via Settings.*
|
*Goal: Allow administrators to configure the storage root path via Settings.*
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- [ ] T024 [US2] Add `storage_path` field to main configuration model in `backend/src/core/config_models.py` (if not using separate storage config)
|
- [x] T024 [US2] Add `storage_path` field to main configuration model in `backend/src/core/config_models.py` (if not using separate storage config)
|
||||||
- [ ] T025 [US2] Implement `GET /api/settings/storage` and `PUT /api/settings/storage` endpoints in `backend/src/api/routes/settings.py` (or `storage.py`)
|
- [x] T025 [US2] Implement `GET /api/settings/storage` and `PUT /api/settings/storage` endpoints in `backend/src/api/routes/settings.py` (or `storage.py`)
|
||||||
- [ ] T026 [US2] Update `StoragePlugin` to read root path from global configuration instead of hardcoded default
|
- [x] T026 [US2] Update `StoragePlugin` to read root path from global configuration instead of hardcoded default
|
||||||
- [ ] T027 [US2] Add validation logic to `PUT` endpoint to ensure new path is writable
|
- [x] T027 [US2] Add validation logic to `PUT` endpoint to ensure new path is writable
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
- [ ] T028 [US2] Add `getStorageConfig` and `updateStorageConfig` to `frontend/src/services/storageService.js`
|
- [x] T028 [US2] Add `getStorageConfig` and `updateStorageConfig` to `frontend/src/services/storageService.js`
|
||||||
- [ ] T029 [US2] Create configuration section in `frontend/src/routes/settings/+page.svelte` (or dedicated Storage Settings component)
|
- [x] T029 [US2] Create configuration section in `frontend/src/routes/settings/+page.svelte` (or dedicated Storage Settings component)
|
||||||
- [ ] T030 [US2] Implement form to update storage path with validation feedback
|
- [x] T030 [US2] Implement form to update storage path with validation feedback
|
||||||
- [ ] T031 [US2] Add configuration fields for directory structure and filename patterns in `backend/src/models/storage.py` and `frontend/src/routes/settings/+page.svelte`
|
- [x] T031 [US2] Add configuration fields for directory structure and filename patterns in `backend/src/models/storage.py` and `frontend/src/routes/settings/+page.svelte`
|
||||||
- [ ] T032 [US2] Implement logic in `StoragePlugin` to resolve dynamic paths based on configured patterns
|
- [x] T032 [US2] Implement logic in `StoragePlugin` to resolve dynamic paths based on configured patterns
|
||||||
|
|
||||||
## Phase 5: Polish & Cross-Cutting
|
## Phase 5: Polish & Cross-Cutting
|
||||||
*Goal: Finalize UI/UX and ensure robustness.*
|
*Goal: Finalize UI/UX and ensure robustness.*
|
||||||
|
|
||||||
- [ ] T033 Add link to "File Storage" in main navigation `frontend/src/components/Navbar.svelte`
|
- [x] T033 Add link to "File Storage" in main navigation `frontend/src/components/Navbar.svelte`
|
||||||
- [ ] T034 Add error handling toasts for failed uploads or file operations
|
- [x] T034 Add error handling toasts for failed uploads or file operations
|
||||||
- [ ] T035 Verify large file upload support (50MB+) in Nginx/FastAPI config if applicable
|
- [x] T035 Verify large file upload support (50MB+) in Nginx/FastAPI config if applicable
|
||||||
- [ ] T036 Add confirmation modal for file deletion
|
- [x] T036 Add confirmation modal for file deletion
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user