Передаем на тест

This commit is contained in:
2026-01-25 18:33:00 +03:00
parent a863807cf2
commit a542e7d2df
17 changed files with 954 additions and 40 deletions

View File

@@ -1 +1 @@
from . import plugins, tasks, settings, connections, environments, mappings, migration, git
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage

View File

@@ -13,6 +13,7 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import List
from ...core.config_models import AppConfig, Environment, GlobalSettings
from ...models.storage import StorageConfig
from ...dependencies import get_config_manager
from ...core.config_manager import ConfigManager
from ...core.logger import logger, belief_scope
@@ -56,6 +57,33 @@ async def update_global_settings(
return settings
# [/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]
# @PURPOSE: Lists all configured Superset environments.
# @PRE: Config manager is available.

View 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]

View File

@@ -18,7 +18,7 @@ import os
from .dependencies import get_task_manager, get_scheduler_service
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
# [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(migration.router)
app.include_router(git.router)
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
# [DEF:websocket_endpoint:Function]
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task.

View File

@@ -7,6 +7,7 @@
from pydantic import BaseModel, Field
from typing import List, Optional
from ..models.storage import StorageConfig
# [DEF:Schedule:DataClass]
# @PURPOSE: Represents a backup schedule configuration.
@@ -43,6 +44,7 @@ class LoggingConfig(BaseModel):
# @PURPOSE: Represents global application settings.
class GlobalSettings(BaseModel):
backup_path: str
storage: StorageConfig = Field(default_factory=StorageConfig)
default_environment_id: Optional[str] = None
logging: LoggingConfig = Field(default_factory=LoggingConfig)

View File

@@ -50,9 +50,18 @@ class PluginLoader:
sys.path.insert(0, plugin_parent_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":
module_name = filename[:-3]
file_path = os.path.join(self.plugin_dir, filename)
self._load_module(module_name, file_path)
# [/DEF:_load_plugins:Function]

View 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]

View File

@@ -0,0 +1,3 @@
from .plugin import StoragePlugin
__all__ = ["StoragePlugin"]

View 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]

View File

@@ -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/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/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 class="relative inline-block group">

View 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] -->

View 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] -->

View File

@@ -123,6 +123,8 @@ export const api = {
deleteEnvironment: (id) => requestApi(`/settings/environments/${id}`, 'DELETE'),
testEnvironmentConnection: (id) => postApi(`/settings/environments/${id}/test`, {}),
updateEnvironmentSchedule: (id, schedule) => requestApi(`/environments/${id}/schedule`, 'PUT', schedule),
getStorageSettings: () => fetchApi('/settings/storage'),
updateStorageSettings: (storage) => requestApi('/settings/storage', 'PUT', storage),
getEnvironmentsList: () => fetchApi('/environments'),
};
// [/DEF:api:Data]
@@ -143,3 +145,5 @@ export const deleteEnvironment = api.deleteEnvironment;
export const testEnvironmentConnection = api.testEnvironmentConnection;
export const updateEnvironmentSchedule = api.updateEnvironmentSchedule;
export const getEnvironmentsList = api.getEnvironmentsList;
export const getStorageSettings = api.getStorageSettings;
export const updateStorageSettings = api.updateStorageSettings;

View File

@@ -1,6 +1,6 @@
<script>
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 { t } from '$lib/i18n';
import { Button, Input, Card, PageHeader } from '$lib/ui';
@@ -41,6 +41,24 @@
}
// [/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]
/* @PURPOSE: Adds a new environment or updates an existing one.
@PRE: newEnv must contain valid environment details.
@@ -166,6 +184,42 @@
</Card>
</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">
<Card title={$t.settings?.env_title || "Superset Environments"}>

View 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] -->

View 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]

View File

@@ -5,64 +5,64 @@
## Phase 1: Setup
*Goal: Initialize backend plugin structure and frontend route scaffolding.*
- [ ] 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
- [ ] 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`
- [ ] T005 Create frontend service `frontend/src/services/storageService.js` stub
- [x] T001 Create storage plugin directory and `__init__.py` in `backend/src/plugins/storage/`
- [x] T002 Create storage models file `backend/src/models/storage.py` with `StorageConfig` and `StoredFile` Pydantic models
- [x] T003 Create empty storage route handler `backend/src/api/routes/storage.py` and register in `backend/src/api/routes/__init__.py`
- [x] T004 Create frontend storage route directory `frontend/src/routes/tools/storage/` and empty `+page.svelte`
- [x] T005 Create frontend service `frontend/src/services/storageService.js` stub
## Phase 2: Foundational
*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`
- [ ] 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
- [ ] T009 Implement path traversal protection helper `validate_path(path)` in `StoragePlugin`
- [ ] T010 Implement `list_files(category)` method in `StoragePlugin` returning `StoredFile` objects
- [ ] T011 Implement `save_file(file, category)` method in `StoragePlugin` handling uploads
- [ ] T012 Implement `delete_file(category, filename)` method in `StoragePlugin`
- [ ] 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] T006 Implement `StoragePlugin` class in `backend/src/plugins/storage/plugin.py` inheriting from `PluginBase`
- [x] T007 Implement `get_storage_root()` method in `StoragePlugin` with default path logic (`../ss-tools-storage`)
- [x] T008 Implement `ensure_directories()` method to create `backups/` and `repositories/` subfolders on init
- [x] T009 Implement path traversal protection helper `validate_path(path)` in `StoragePlugin`
- [x] T010 Implement `list_files(category)` method in `StoragePlugin` returning `StoredFile` objects
- [x] T011 Implement `save_file(file, category)` method in `StoragePlugin` handling uploads
- [x] T012 Implement `delete_file(category, filename)` method in `StoragePlugin`
- [x] T013 Implement `get_file_path(category, filename)` method in `StoragePlugin` for downloads
- [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)
*Goal: Enable users to list, upload, download, and delete files via Web UI.*
### Backend Endpoints
- [ ] 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`
- [ ] 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] T015 [US1] Implement `GET /api/storage/files` endpoint in `backend/src/api/routes/storage.py` using `StoragePlugin.list_files`
- [x] T016 [US1] Implement `POST /api/storage/upload` endpoint in `backend/src/api/routes/storage.py` using `StoragePlugin.save_file`
- [x] T017 [US1] Implement `DELETE /api/storage/files/{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
- [ ] 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
- [ ] 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)
- [ ] T023 [US1] Integrate `FileList` and `FileUpload` components into `+page.svelte`
- [x] T019 [US1] Implement `listFiles`, `uploadFile`, `deleteFile`, `downloadFileUrl` in `frontend/src/services/storageService.js`
- [x] T020 [US1] Create `frontend/src/components/storage/FileList.svelte` to display files in a table with metadata
- [x] T021 [US1] Create `frontend/src/components/storage/FileUpload.svelte` with category selection and drag-drop support
- [x] T022 [US1] Implement main logic in `frontend/src/routes/tools/storage/+page.svelte` to fetch files and handle tabs (Backups vs Repositories)
- [x] T023 [US1] Integrate `FileList` and `FileUpload` components into `+page.svelte`
## Phase 4: User Story 2 - Storage Location Configuration (Priority: P2)
*Goal: Allow administrators to configure the storage root path via Settings.*
### Backend
- [ ] 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`)
- [ ] 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] T024 [US2] Add `storage_path` field to main configuration model in `backend/src/core/config_models.py` (if not using separate storage config)
- [x] T025 [US2] Implement `GET /api/settings/storage` and `PUT /api/settings/storage` endpoints in `backend/src/api/routes/settings.py` (or `storage.py`)
- [x] T026 [US2] Update `StoragePlugin` to read root path from global configuration instead of hardcoded default
- [x] T027 [US2] Add validation logic to `PUT` endpoint to ensure new path is writable
### Frontend
- [ ] 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)
- [ ] 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`
- [ ] T032 [US2] Implement logic in `StoragePlugin` to resolve dynamic paths based on configured patterns
- [x] T028 [US2] Add `getStorageConfig` and `updateStorageConfig` to `frontend/src/services/storageService.js`
- [x] T029 [US2] Create configuration section in `frontend/src/routes/settings/+page.svelte` (or dedicated Storage Settings component)
- [x] T030 [US2] Implement form to update storage path with validation feedback
- [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`
- [x] T032 [US2] Implement logic in `StoragePlugin` to resolve dynamic paths based on configured patterns
## Phase 5: Polish & Cross-Cutting
*Goal: Finalize UI/UX and ensure robustness.*
- [ ] 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
- [ ] T035 Verify large file upload support (50MB+) in Nginx/FastAPI config if applicable
- [ ] T036 Add confirmation modal for file deletion
- [x] T033 Add link to "File Storage" in main navigation `frontend/src/components/Navbar.svelte`
- [x] T034 Add error handling toasts for failed uploads or file operations
- [x] T035 Verify large file upload support (50MB+) in Nginx/FastAPI config if applicable
- [x] T036 Add confirmation modal for file deletion
## Dependencies