Выполнено, передано на тестирование
This commit is contained in:
@@ -1,60 +0,0 @@
|
||||
<!-- [DEF:EnvSelector:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: environment, selector, dropdown, migration
|
||||
@PURPOSE: Provides a UI component for selecting source and target environments.
|
||||
@LAYER: Feature
|
||||
@RELATION: BINDS_TO -> environments store
|
||||
|
||||
@INVARIANT: Source and target environments must be selectable from the list of configured environments.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let label: string = "Select Environment";
|
||||
export let selectedId: string = "";
|
||||
export let environments: Array<{id: string, name: string, url: string}> = [];
|
||||
// [/SECTION]
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:handleSelect:Function]
|
||||
/**
|
||||
* @purpose Dispatches the selection change event.
|
||||
* @pre event.target must be an HTMLSelectElement.
|
||||
* @post selectedId is updated and 'change' event is dispatched.
|
||||
* @param {Event} event - The change event from the select element.
|
||||
*/
|
||||
function handleSelect(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
selectedId = target.value;
|
||||
dispatch('change', { id: selectedId });
|
||||
}
|
||||
// [/DEF:handleSelect:Function]
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="flex flex-col space-y-1">
|
||||
<label for="env-select" class="text-sm font-medium text-gray-700">{label}</label>
|
||||
<select
|
||||
id="env-select"
|
||||
class="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
value={selectedId}
|
||||
on:change={handleSelect}
|
||||
>
|
||||
<option value="" disabled>-- Choose an environment --</option>
|
||||
{#each environments as env}
|
||||
<option value={env.id}>{env.name} ({env.url})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
/* Component specific styles */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:EnvSelector:Component] -->
|
||||
@@ -25,35 +25,12 @@
|
||||
>
|
||||
{$t.nav.dashboard}
|
||||
</a>
|
||||
<a
|
||||
href="/migration"
|
||||
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/migration') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
|
||||
>
|
||||
{$t.nav.migration}
|
||||
</a>
|
||||
<a
|
||||
href="/git"
|
||||
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/git') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
|
||||
>
|
||||
{$t.nav.git}
|
||||
</a>
|
||||
<a
|
||||
href="/tasks"
|
||||
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/tasks') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
|
||||
>
|
||||
{$t.nav.tasks}
|
||||
</a>
|
||||
<div class="relative inline-block group">
|
||||
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/tools') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
|
||||
{$t.nav.tools}
|
||||
</button>
|
||||
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
|
||||
<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">
|
||||
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/settings') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
|
||||
{$t.nav.settings}
|
||||
@@ -62,7 +39,6 @@
|
||||
<a href="/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_general}</a>
|
||||
<a href="/settings/connections" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_connections}</a>
|
||||
<a href="/settings/git" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_git}</a>
|
||||
<a href="/settings/environments" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_environments}</a>
|
||||
</div>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
|
||||
74
frontend/src/components/backups/BackupList.svelte
Normal file
74
frontend/src/components/backups/BackupList.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<!-- [DEF:BackupList:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: backup, list, table
|
||||
@PURPOSE: Displays a list of existing backups.
|
||||
@LAYER: Component
|
||||
@RELATION: USED_BY -> frontend/src/components/backups/BackupManager.svelte
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { t } from '../../lib/i18n';
|
||||
import { Button } from '../../lib/ui';
|
||||
import type { Backup } from '../../types/backup';
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let backups: Backup[] = [];
|
||||
// [/SECTION]
|
||||
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table class="min-w-full divide-y divide-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">
|
||||
{$t.storage.table.name}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{$t.tasks.target_env}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{$t.storage.table.created_at}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{$t.storage.table.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each backups as backup}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{backup.name}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{backup.environment}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(backup.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Button variant="ghost" size="sm" class="text-blue-600 hover:text-blue-900">
|
||||
{$t.storage.table.download}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-10 text-center text-gray-500">
|
||||
{$t.storage.no_files}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:BackupList:Component] -->
|
||||
@@ -1,186 +0,0 @@
|
||||
<!-- [DEF:SearchTool:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: search, tool, dataset, regex
|
||||
@PURPOSE: UI component for searching datasets using the SearchPlugin.
|
||||
@LAYER: UI
|
||||
@RELATION: USES -> frontend/src/services/toolsService.js
|
||||
-->
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount } from 'svelte';
|
||||
import { runTask, getTaskStatus } from '../../services/toolsService.js';
|
||||
import { selectedTask } from '../../lib/stores.js';
|
||||
import { addToast } from '../../lib/toasts.js';
|
||||
// [/SECTION]
|
||||
|
||||
let envs = [];
|
||||
let selectedEnv = '';
|
||||
let searchQuery = '';
|
||||
let isRunning = false;
|
||||
let results = null;
|
||||
let pollInterval;
|
||||
|
||||
// [DEF:fetchEnvironments:Function]
|
||||
// @PURPOSE: Fetches the list of available environments.
|
||||
// @PRE: None.
|
||||
// @POST: envs array is populated.
|
||||
async function fetchEnvironments() {
|
||||
try {
|
||||
const res = await fetch('/api/environments');
|
||||
envs = await res.json();
|
||||
} catch (e) {
|
||||
addToast('Failed to fetch environments', 'error');
|
||||
}
|
||||
}
|
||||
// [/DEF:fetchEnvironments:Function]
|
||||
|
||||
// [DEF:handleSearch:Function]
|
||||
// @PURPOSE: Triggers the SearchPlugin task.
|
||||
// @PRE: selectedEnv and searchQuery must be set.
|
||||
// @POST: Task is started and polling begins.
|
||||
async function handleSearch() {
|
||||
if (!selectedEnv || !searchQuery) {
|
||||
addToast('Please select environment and enter query', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
results = null;
|
||||
try {
|
||||
// Find the environment name from ID
|
||||
const env = envs.find(e => e.id === selectedEnv);
|
||||
const task = await runTask('search-datasets', {
|
||||
env: env.name,
|
||||
query: searchQuery
|
||||
});
|
||||
|
||||
selectedTask.set(task);
|
||||
startPolling(task.id);
|
||||
} catch (e) {
|
||||
isRunning = false;
|
||||
addToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
// [/DEF:handleSearch:Function]
|
||||
|
||||
// [DEF:startPolling:Function]
|
||||
// @PURPOSE: Polls for task completion and results.
|
||||
// @PRE: taskId is provided.
|
||||
// @POST: pollInterval is set and results are updated on success.
|
||||
function startPolling(taskId) {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
|
||||
pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const task = await getTaskStatus(taskId);
|
||||
selectedTask.set(task);
|
||||
|
||||
if (task.status === 'SUCCESS') {
|
||||
clearInterval(pollInterval);
|
||||
isRunning = false;
|
||||
results = task.result;
|
||||
addToast('Search completed', 'success');
|
||||
} else if (task.status === 'FAILED') {
|
||||
clearInterval(pollInterval);
|
||||
isRunning = false;
|
||||
addToast('Search failed', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
clearInterval(pollInterval);
|
||||
isRunning = false;
|
||||
addToast('Error polling task status', 'error');
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
// [/DEF:startPolling:Function]
|
||||
|
||||
onMount(fetchEnvironments);
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Search Dataset Metadata</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
|
||||
<div>
|
||||
<label for="env-select" class="block text-sm font-medium text-gray-700">Environment</label>
|
||||
<select
|
||||
id="env-select"
|
||||
bind:value={selectedEnv}
|
||||
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option value="" disabled>-- Select Environment --</option>
|
||||
{#each envs as env}
|
||||
<option value={env.id}>{env.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="search-query" class="block text-sm font-medium text-gray-700">Regex Pattern</label>
|
||||
<input
|
||||
type="text"
|
||||
id="search-query"
|
||||
bind:value={searchQuery}
|
||||
placeholder="e.g. from dm.*\.account"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
on:click={handleSearch}
|
||||
disabled={isRunning}
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{#if isRunning}
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Searching...
|
||||
{:else}
|
||||
Search
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if results}
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md border border-gray-200">
|
||||
<div class="px-4 py-5 sm:px-6 flex justify-between items-center bg-gray-50 border-b border-gray-200">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Search Results
|
||||
</h3>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{results.count} matches
|
||||
</span>
|
||||
</div>
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{#each results.results as item}
|
||||
<li class="p-4 hover:bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-indigo-600 truncate">
|
||||
{item.dataset_name} (ID: {item.dataset_id})
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0 flex">
|
||||
<p class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Field: {item.field}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<pre class="text-xs text-gray-500 bg-gray-50 p-2 rounded border border-gray-100 overflow-x-auto">{item.match_context}</pre>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
{#if results.count === 0}
|
||||
<li class="p-8 text-center text-gray-500 italic">
|
||||
No matches found for the given pattern.
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
<!-- [/DEF:SearchTool:Component] -->
|
||||
@@ -27,6 +27,12 @@
|
||||
goto('/migration');
|
||||
} else if (plugin.id === 'git-integration') {
|
||||
goto('/git');
|
||||
} else if (plugin.id === 'superset-backup') {
|
||||
goto('/tools/backups');
|
||||
} else if (plugin.id === 'superset-storage') {
|
||||
goto('/tools/storage');
|
||||
} else if (plugin.id === 'superset-mapper') {
|
||||
goto('/tools/mapper');
|
||||
} else {
|
||||
selectedPlugin.set(plugin);
|
||||
}
|
||||
@@ -82,7 +88,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each data.plugins as plugin}
|
||||
{#each data.plugins.filter(p => p.id !== 'superset-search') as plugin}
|
||||
<div
|
||||
on:click={() => selectPlugin(plugin)}
|
||||
role="button"
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { gitService } from '../../../services/gitService';
|
||||
import { addToast as toast } from '../../../lib/toasts.js';
|
||||
|
||||
let environments = [];
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
environments = await gitService.getEnvironments();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="text-2xl font-bold mb-6">Deployment Environments</h1>
|
||||
|
||||
<div class="bg-white p-6 rounded shadow">
|
||||
<h2 class="text-xl font-semibold mb-4">Target Environments</h2>
|
||||
{#if environments.length === 0}
|
||||
<p class="text-gray-500">No deployment environments configured.</p>
|
||||
{:else}
|
||||
<ul class="divide-y">
|
||||
{#each environments as env}
|
||||
<li class="py-3 flex justify-between items-center">
|
||||
<div>
|
||||
<span class="font-medium">{env.name}</span>
|
||||
<div class="text-xs text-gray-400">{env.superset_url}</div>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs rounded {env.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">
|
||||
{env.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,13 +116,7 @@
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-4 max-w-6xl">
|
||||
<PageHeader title={$t.tasks.management}>
|
||||
<div slot="actions">
|
||||
<Button on:click={() => showBackupModal = true}>
|
||||
{$t.tasks.run_backup}
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageHeader title={$t.tasks.management} />
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-1">
|
||||
|
||||
27
frontend/src/routes/tools/backups/+page.svelte
Normal file
27
frontend/src/routes/tools/backups/+page.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<!-- [DEF:BackupPage:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: backup, page, tools
|
||||
@PURPOSE: Entry point for the Backup Management interface.
|
||||
@LAYER: Page
|
||||
@RELATION: USES -> BackupManager
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { t } from '../../../lib/i18n';
|
||||
import { PageHeader } from '../../../lib/ui';
|
||||
import BackupManager from '../../../components/backups/BackupManager.svelte';
|
||||
// [/SECTION]
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="container mx-auto p-4 max-w-6xl">
|
||||
<PageHeader title={$t.nav.tools_backups} />
|
||||
|
||||
<div class="mt-6">
|
||||
<BackupManager />
|
||||
</div>
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:BackupPage:Component] -->
|
||||
@@ -1,25 +0,0 @@
|
||||
<!-- [DEF:SearchPage:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: search, page, tool
|
||||
@PURPOSE: Page for the dataset search tool.
|
||||
@LAYER: UI
|
||||
-->
|
||||
<script>
|
||||
import SearchTool from '../../../components/tools/SearchTool.svelte';
|
||||
import TaskRunner from '../../../components/TaskRunner.svelte';
|
||||
import { PageHeader } from '$lib/ui';
|
||||
</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto p-6">
|
||||
<PageHeader title="Dataset Search" />
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div class="lg:col-span-2">
|
||||
<SearchTool />
|
||||
</div>
|
||||
<div class="lg:col-span-1">
|
||||
<TaskRunner />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- [/DEF:SearchPage:Component] -->
|
||||
22
frontend/src/types/backup.ts
Normal file
22
frontend/src/types/backup.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* [DEF:BackupTypes:Module]
|
||||
* @SEMANTICS: types, backup, interface
|
||||
* @PURPOSE: Defines types and interfaces for the Backup Management UI.
|
||||
*/
|
||||
|
||||
export interface Backup {
|
||||
id: string;
|
||||
name: string;
|
||||
environment: string;
|
||||
created_at: string;
|
||||
size_bytes?: number;
|
||||
status: 'success' | 'failed' | 'in_progress';
|
||||
}
|
||||
|
||||
export interface BackupCreateRequest {
|
||||
environment_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* [/DEF:BackupTypes:Module]
|
||||
*/
|
||||
Reference in New Issue
Block a user