feat: volume scopes redesign — replace shared/isolated with 6 scopes
Replace confusing shared/isolated volume modes with explicit scopes: - instance: per-deploy isolated directory - stage: shared within a stage across deploys - project: shared across all stages - project_named: named group within a project - named: global named volume across projects - ephemeral: tmpfs in-memory mount Includes schema migration (shared→project, isolated→instance), backward-compatible deployer resolution, scope metadata API endpoint, and redesigned volume editor UI with scope guide cards and hints.
This commit is contained in:
+8
-3
@@ -20,7 +20,8 @@ import type {
|
||||
StageEnv,
|
||||
StandaloneProxy,
|
||||
ValidationResult,
|
||||
Volume
|
||||
Volume,
|
||||
VolumeScopeInfo
|
||||
} from './types';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
@@ -327,7 +328,7 @@ export function listVolumes(projectId: string): Promise<Volume[]> {
|
||||
|
||||
export function createVolume(
|
||||
projectId: string,
|
||||
data: { source: string; target: string; mode?: string }
|
||||
data: { source: string; target: string; scope: string; name?: string; mode?: string }
|
||||
): Promise<Volume> {
|
||||
return post<Volume>(`/api/projects/${projectId}/volumes`, data);
|
||||
}
|
||||
@@ -335,11 +336,15 @@ export function createVolume(
|
||||
export function updateVolume(
|
||||
projectId: string,
|
||||
volId: string,
|
||||
data: { source?: string; target?: string; mode?: string }
|
||||
data: { source?: string; target?: string; scope?: string; name?: string; mode?: string }
|
||||
): Promise<Volume> {
|
||||
return put<Volume>(`/api/projects/${projectId}/volumes/${volId}`, data);
|
||||
}
|
||||
|
||||
export function listVolumeScopes(): Promise<VolumeScopeInfo[]> {
|
||||
return get<VolumeScopeInfo[]>('/api/volumes/scopes');
|
||||
}
|
||||
|
||||
export function deleteVolume(
|
||||
projectId: string,
|
||||
volId: string
|
||||
|
||||
@@ -120,15 +120,16 @@
|
||||
},
|
||||
"volumeEditor": {
|
||||
"title": "Volume Mounts",
|
||||
"description": "Configure volume mounts for containers.",
|
||||
"sharedDesc": "Shared mode uses the source path as-is for all instances.",
|
||||
"isolatedDesc": "Isolated mode appends /{stage}-{tag}/ to the source, giving each instance its own directory.",
|
||||
"description": "Configure volume mounts for containers. Choose a scope to control how volumes are shared between deploys.",
|
||||
"sourceHost": "Source (Host)",
|
||||
"targetContainer": "Target (Container)",
|
||||
"mode": "Mode",
|
||||
"scope": "Scope",
|
||||
"nameColumn": "Name",
|
||||
"namePlaceholder": "e.g. shared-db",
|
||||
"requiresName": "requires name",
|
||||
"noHostPath": "no host path",
|
||||
"tmpfs": "tmpfs (in-memory)",
|
||||
"actions": "Actions",
|
||||
"shared": "Shared",
|
||||
"isolated": "Isolated",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
|
||||
@@ -120,15 +120,16 @@
|
||||
},
|
||||
"volumeEditor": {
|
||||
"title": "Тома",
|
||||
"description": "Настройка монтирования томов для контейнеров.",
|
||||
"sharedDesc": "Режим «Общий» использует путь источника как есть для всех экземпляров.",
|
||||
"isolatedDesc": "Режим «Изолированный» добавляет /{stage}-{tag}/ к источнику, создавая свою директорию для каждого экземпляра.",
|
||||
"description": "Настройка монтирования томов для контейнеров. Выберите область видимости для управления общим доступом между развёртываниями.",
|
||||
"sourceHost": "Источник (хост)",
|
||||
"targetContainer": "Цель (контейнер)",
|
||||
"mode": "Режим",
|
||||
"scope": "Область",
|
||||
"nameColumn": "Имя",
|
||||
"namePlaceholder": "напр. shared-db",
|
||||
"requiresName": "требуется имя",
|
||||
"noHostPath": "нет пути на хосте",
|
||||
"tmpfs": "tmpfs (в памяти)",
|
||||
"actions": "Действия",
|
||||
"shared": "Общий",
|
||||
"isolated": "Изолированный",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"save": "Сохранить",
|
||||
|
||||
+14
-1
@@ -161,17 +161,30 @@ export interface EntityPickerItem {
|
||||
disabledHint?: string;
|
||||
}
|
||||
|
||||
/** Volume scope determines the sharing level. */
|
||||
export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral';
|
||||
|
||||
/** Volume mount configuration for a project. */
|
||||
export interface Volume {
|
||||
id: string;
|
||||
project_id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
mode: 'shared' | 'isolated';
|
||||
mode?: string;
|
||||
scope: VolumeScope;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Scope metadata returned by GET /api/volumes/scopes. */
|
||||
export interface VolumeScopeInfo {
|
||||
scope: VolumeScope;
|
||||
description: string;
|
||||
needs_name: boolean;
|
||||
path_example: string;
|
||||
}
|
||||
|
||||
/** Docker daemon health check result. */
|
||||
export interface DockerHealth {
|
||||
connected: boolean;
|
||||
|
||||
@@ -1,34 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import type { Volume } from '$lib/types';
|
||||
import type { Volume, VolumeScope, VolumeScopeInfo } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLoader } from '$lib/components/icons';
|
||||
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX } from '$lib/components/icons';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
|
||||
let volumes = $state<Volume[]>([]);
|
||||
let scopeInfos = $state<VolumeScopeInfo[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
let newSource = $state('');
|
||||
let newTarget = $state('');
|
||||
let newMode = $state<'shared' | 'isolated'>('shared');
|
||||
let newScope = $state<VolumeScope>('project');
|
||||
let newName = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
let editingId = $state('');
|
||||
let editSource = $state('');
|
||||
let editTarget = $state('');
|
||||
let editMode = $state<'shared' | 'isolated'>('shared');
|
||||
let editScope = $state<VolumeScope>('project');
|
||||
let editName = $state('');
|
||||
|
||||
const projectId = $derived($page.params.id);
|
||||
const projectId = $derived($page.params.id ?? '');
|
||||
|
||||
async function loadVolumes() {
|
||||
const newNeedsName = $derived(newScope === 'project_named' || newScope === 'named');
|
||||
const editNeedsName = $derived(editScope === 'project_named' || editScope === 'named');
|
||||
const newIsEphemeral = $derived(newScope === 'ephemeral');
|
||||
const editIsEphemeral = $derived(editScope === 'ephemeral');
|
||||
|
||||
function scopeDescription(scope: VolumeScope): string {
|
||||
return scopeInfos.find(s => s.scope === scope)?.description ?? '';
|
||||
}
|
||||
|
||||
function scopePathExample(scope: VolumeScope): string {
|
||||
return scopeInfos.find(s => s.scope === scope)?.path_example ?? '';
|
||||
}
|
||||
|
||||
const scopeColors: Record<string, { bg: string; text: string }> = {
|
||||
instance: { bg: 'bg-amber-50 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-400' },
|
||||
stage: { bg: 'bg-blue-50 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-400' },
|
||||
project: { bg: 'bg-emerald-50 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-400' },
|
||||
project_named: { bg: 'bg-violet-50 dark:bg-violet-900/30', text: 'text-violet-700 dark:text-violet-400' },
|
||||
named: { bg: 'bg-purple-50 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-400' },
|
||||
ephemeral: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-600 dark:text-gray-400' },
|
||||
};
|
||||
|
||||
function scopeColor(scope: string) {
|
||||
return scopeColors[scope] ?? scopeColors.project;
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
volumes = await api.listVolumes(projectId);
|
||||
const [vols, scopes] = await Promise.all([
|
||||
api.listVolumes(projectId),
|
||||
api.listVolumeScopes()
|
||||
]);
|
||||
volumes = vols;
|
||||
scopeInfos = scopes;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed');
|
||||
} finally {
|
||||
@@ -37,15 +70,23 @@
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!newSource.trim() || !newTarget.trim()) return;
|
||||
if (!newIsEphemeral && !newSource.trim()) return;
|
||||
if (!newTarget.trim()) return;
|
||||
if (newNeedsName && !newName.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
await api.createVolume(projectId, { source: newSource.trim(), target: newTarget.trim(), mode: newMode });
|
||||
await api.createVolume(projectId, {
|
||||
source: newSource.trim(),
|
||||
target: newTarget.trim(),
|
||||
scope: newScope,
|
||||
name: newNeedsName ? newName.trim() : undefined
|
||||
});
|
||||
newSource = '';
|
||||
newTarget = '';
|
||||
newMode = 'shared';
|
||||
newScope = 'project';
|
||||
newName = '';
|
||||
toasts.success($t('volumeEditor.volumeAdded'));
|
||||
await loadVolumes();
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.addFailed'));
|
||||
} finally {
|
||||
@@ -57,19 +98,27 @@
|
||||
editingId = vol.id;
|
||||
editSource = vol.source;
|
||||
editTarget = vol.target;
|
||||
editMode = vol.mode;
|
||||
editScope = vol.scope || 'project';
|
||||
editName = vol.name || '';
|
||||
}
|
||||
|
||||
function cancelEdit() { editingId = ''; }
|
||||
|
||||
async function handleUpdate() {
|
||||
if (!editSource.trim() || !editTarget.trim()) return;
|
||||
if (!editIsEphemeral && !editSource.trim()) return;
|
||||
if (!editTarget.trim()) return;
|
||||
if (editNeedsName && !editName.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
await api.updateVolume(projectId, editingId, { source: editSource.trim(), target: editTarget.trim(), mode: editMode });
|
||||
await api.updateVolume(projectId, editingId, {
|
||||
source: editSource.trim(),
|
||||
target: editTarget.trim(),
|
||||
scope: editScope,
|
||||
name: editNeedsName ? editName.trim() : ''
|
||||
});
|
||||
editingId = '';
|
||||
toasts.success($t('volumeEditor.volumeUpdated'));
|
||||
await loadVolumes();
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.updateFailed'));
|
||||
} finally {
|
||||
@@ -81,7 +130,7 @@
|
||||
try {
|
||||
await api.deleteVolume(projectId, volId);
|
||||
toasts.success($t('volumeEditor.volumeDeleted'));
|
||||
await loadVolumes();
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.deleteFailed'));
|
||||
}
|
||||
@@ -89,7 +138,7 @@
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
loadVolumes();
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -105,51 +154,79 @@
|
||||
<IconChevronRight size={14} />
|
||||
</div>
|
||||
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('volumeEditor.title')}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
{$t('volumeEditor.description')}
|
||||
<strong>{$t('volumeEditor.shared')}</strong> — {$t('volumeEditor.sharedDesc')}
|
||||
<strong>{$t('volumeEditor.isolated')}</strong> — {$t('volumeEditor.isolatedDesc')}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('volumeEditor.description')}</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
<Skeleton height="12rem" />
|
||||
<!-- Scope guide -->
|
||||
{#if scopeInfos.length > 0}
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each scopeInfos as info}
|
||||
{@const colors = scopeColor(info.scope)}
|
||||
<div class="rounded-lg border border-[var(--border-primary)] p-3 {colors.bg}">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-semibold {colors.text} {colors.bg}">{info.scope}</span>
|
||||
{#if info.needs_name}
|
||||
<span class="text-[10px] text-[var(--text-tertiary)]">({$t('volumeEditor.requiresName')})</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-[var(--text-secondary)]">{info.description}</p>
|
||||
<code class="mt-1 block text-[10px] text-[var(--text-tertiary)]">{info.path_example}</code>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<Skeleton height="12rem" />
|
||||
{:else if error}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadVolumes}>
|
||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadData}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Volumes table -->
|
||||
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.sourceHost')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.targetContainer')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.mode')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.scope')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.nameColumn')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each volumes as vol (vol.id)}
|
||||
{#if editingId === vol.id}
|
||||
<!-- Edit row -->
|
||||
<tr class="bg-[var(--color-brand-50)]/30">
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={editSource} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
{#if editIsEphemeral}
|
||||
<span class="text-xs text-[var(--text-tertiary)] italic">{$t('volumeEditor.noHostPath')}</span>
|
||||
{:else}
|
||||
<input type="text" bind:value={editSource} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={editTarget} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<select bind:value={editMode} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||
<option value="shared">{$t('volumeEditor.shared')}</option>
|
||||
<option value="isolated">{$t('volumeEditor.isolated')}</option>
|
||||
<select bind:value={editScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||
{#each scopeInfos as info}
|
||||
<option value={info.scope}>{info.scope}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
{#if editNeedsName}
|
||||
<input type="text" bind:value={editName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={saving} onclick={handleUpdate}><IconCheck size={16} /></button>
|
||||
@@ -158,15 +235,21 @@
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<!-- Display row -->
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{vol.source}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">
|
||||
{#if vol.scope === 'ephemeral'}
|
||||
<span class="text-xs text-[var(--text-tertiary)] italic">{$t('volumeEditor.tmpfs')}</span>
|
||||
{:else}
|
||||
{vol.source}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{vol.target}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
{#if vol.mode === 'shared'}
|
||||
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{$t('volumeEditor.shared')}</span>
|
||||
{:else}
|
||||
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('volumeEditor.isolated')}</span>
|
||||
{/if}
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(vol.scope).bg} {scopeColor(vol.scope).text}">{vol.scope}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">
|
||||
{vol.name || '—'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@@ -181,22 +264,34 @@
|
||||
<!-- Add new row -->
|
||||
<tr class="bg-[var(--surface-card-hover)]">
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={newSource} placeholder="/data/my-app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
{#if newIsEphemeral}
|
||||
<span class="text-xs text-[var(--text-tertiary)] italic">{$t('volumeEditor.noHostPath')}</span>
|
||||
{:else}
|
||||
<input type="text" bind:value={newSource} placeholder="/data/my-app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={newTarget} placeholder="/app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<select bind:value={newMode} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||
<option value="shared">{$t('volumeEditor.shared')}</option>
|
||||
<option value="isolated">{$t('volumeEditor.isolated')}</option>
|
||||
<select bind:value={newScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||
{#each scopeInfos as info}
|
||||
<option value={info.scope}>{info.scope}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
{#if newNeedsName}
|
||||
<input type="text" bind:value={newName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
||||
disabled={!newSource.trim() || !newTarget.trim() || saving}
|
||||
disabled={(!newIsEphemeral && !newSource.trim()) || !newTarget.trim() || (newNeedsName && !newName.trim()) || saving}
|
||||
onclick={handleAdd}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
@@ -208,6 +303,17 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Scope hint for current selection -->
|
||||
{#if scopeDescription(newScope)}
|
||||
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-3">
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
<strong class="text-[var(--text-primary)]">{newScope}:</strong>
|
||||
{scopeDescription(newScope)}
|
||||
</p>
|
||||
<code class="mt-1 block text-[10px] text-[var(--text-tertiary)]">{scopePathExample(newScope)}</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if volumes.length === 0}
|
||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('volumeEditor.noVolumes')}</p>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user