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

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

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