From 9ec25a8d5a0a981fbd53fb01ba9430ee38c72718 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 13 Apr 2026 00:12:34 +0300 Subject: [PATCH] feat: project detail UX improvements - Tag picker: replace raw text input with EntityPicker modal showing registry tags (auto-detected by image hostname) and local images. Fix URL encoding bug where encodeURIComponent encoded slashes in image paths, causing 502 on registry tag API. - Stage editing: inline edit form for name, tag pattern, max instances, CPU/memory limits, auto-deploy and proxy toggles. - Stage delete: use ConfirmDialog modal instead of window.confirm(). Immediately remove stage from local state after deletion. - Project-level env: add/edit/delete project env vars (stored in project.env JSON field). Move stage selector inline with Stage Overrides heading so it's clear project env is independent. - Access list UX: rename "None (public)" to "Global default", clarify help text. - Add missing i18n keys for all new UI (en + ru). --- web/src/lib/components/TagCombobox.svelte | 185 +++++++++ web/src/lib/i18n/en.json | 42 +- web/src/lib/i18n/ru.json | 42 +- web/src/routes/projects/[id]/+page.svelte | 363 +++++++++++++----- web/src/routes/projects/[id]/env/+page.svelte | 191 +++++++-- 5 files changed, 677 insertions(+), 146 deletions(-) create mode 100644 web/src/lib/components/TagCombobox.svelte diff --git a/web/src/lib/components/TagCombobox.svelte b/web/src/lib/components/TagCombobox.svelte new file mode 100644 index 0000000..feca417 --- /dev/null +++ b/web/src/lib/components/TagCombobox.svelte @@ -0,0 +1,185 @@ + + + +
+
+ + {#if hasSuggestions} + + {/if} +
+ + {#if open && filtered.length > 0} +
e.preventDefault()} + > + {#each filtered as item, i (item.tag + item.source)} + {@const highlighted = i === highlightIndex} + + {/each} +
+ {/if} +
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 5abe7d1..719fcb9 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -33,7 +33,14 @@ "loadFailed": "Failed to load dashboard", "staleContainers": "Stale Containers", "unusedImagesWarning": "Unused Docker images are taking up disk space", - "unusedImages": "unused images" + "unusedImages": "unused images", + "staticSites": "Static Sites", + "totalSites": "Total Sites", + "deployedSites": "deployed", + "failedSites": "failed", + "noSites": "No static sites yet.", + "addFirstSite": "Deploy your first site", + "viewAllSites": "View all sites" }, "projects": { "title": "Projects", @@ -80,6 +87,11 @@ "loadingTags": "Loading tags...", "chooseTag": "Choose a tag...", "enterTag": "Enter image tag (e.g., dev-abc123)", + "registryTag": "Registry", + "localTag": "Local", + "alsoLocal": "Also available locally", + "searchTags": "Search tags...", + "deployTag": "Tag", "deploy": "Deploy", "deploying": "Deploying...", "recentDeploys": "Recent Deploys", @@ -107,7 +119,7 @@ "autoDeployLabel": "Auto Deploy", "enableProxy": "Enable Proxy", "accessListId": "NPM Access List ID", - "accessListIdHelp": "Per-project override. 0 = use global default from NPM settings.", + "accessListIdHelp": "Override the global access list for this project. Clear to inherit from NPM settings.", "localImages": "Local Docker Images", "imageTag": "Tag", "imageId": "Image ID", @@ -124,6 +136,8 @@ "deleteStage": "Delete stage", "deleteStageConfirm": "Delete stage \"{name}\"?", "stageCreated": "Stage \"{name}\" created", + "stageUpdated": "Stage updated", + "stageUpdateFailed": "Failed to update stage", "stageDeleted": "Stage \"{name}\" deleted", "projectUpdated": "Project updated", "updateFailed": "Failed to update project", @@ -135,6 +149,7 @@ "description": "Manage per-stage environment variable overrides. Stage-level values override project-level defaults.", "stage": "Stage", "projectDefaults": "Project-Level Defaults", + "noProjectEnv": "No project-level environment variables defined yet.", "stageOverrides": "Stage Overrides", "key": "Key", "value": "Value", @@ -160,7 +175,9 @@ "updateFailed": "Failed to update env var", "deleteFailed": "Failed to delete env var", "loadEnvFailed": "Failed to load env vars", - "leaveEmptyToKeep": "Leave empty to keep current" + "leaveEmptyToKeep": "Leave empty to keep current", + "deleteTitle": "Delete Environment Variable", + "deleteMessage": "Are you sure you want to delete this environment variable? This action cannot be undone." }, "volumeEditor": { "title": "Volume Mounts", @@ -405,7 +422,7 @@ "remoteModeWarning": "Requires Server IP in General settings. Ports are auto-mapped to random host ports.", "accessList": "Default Access List", "accessListHelp": "NPM access list for HTTP authentication on proxy hosts. Can be overridden per project.", - "noAccessList": "None (public)", + "noAccessList": "Global default", "selectAccessList": "Select an access list", "noAccessLists": "No access lists found in NPM", "accessListLoadFailed": "Failed to load access lists" @@ -560,6 +577,8 @@ "openSite": "Open Site", "confirmDelete": "Delete Site", "confirmDeleteMsg": "This will permanently delete the site and remove its container", + "confirmDeleteSecret": "Delete Secret", + "confirmDeleteSecretMsg": "Are you sure you want to delete secret", "siteInfo": "Site Information", "folder": "Folder", "syncTrigger": "Sync Trigger", @@ -609,13 +628,26 @@ "provider": "Git Provider", "detectedProvider": "Auto-detected", "browseRepos": "Browse repositories", - "selectRepo": "Select a repository" + "selectRepo": "Select a repository", + "storage": "Persistent Storage", + "enableStorage": "Enable persistent storage", + "storageHelp": "Mounts a Docker volume at /app/data for your Deno backend to read and write files that persist across deployments.", + "storageLimitMB": "Storage Limit (MB)", + "storageLimitHelp": "Maximum storage size in megabytes. 0 = unlimited.", + "storageVolume": "Volume", + "dataPath": "Data Path", + "storageMountPath": "Mount Path", + "storageLimit": "Limit", + "storageUsed": "Used", + "storageOfLimit": "of limit used", + "unlimited": "Unlimited" }, "common": { "cancel": "Cancel", "confirm": "Confirm", "delete": "Delete", "edit": "Edit", + "change": "Change", "save": "Save", "retry": "Retry", "loading": "Loading...", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index f2d9745..60645b0 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -33,7 +33,14 @@ "loadFailed": "Не удалось загрузить панель", "staleContainers": "Устаревшие контейнеры", "unusedImagesWarning": "Неиспользуемые Docker-образы занимают дисковое пространство", - "unusedImages": "неиспользуемых образов" + "unusedImages": "неиспользуемых образов", + "staticSites": "Статические сайты", + "totalSites": "Всего сайтов", + "deployedSites": "развёрнуто", + "failedSites": "с ошибкой", + "noSites": "Статических сайтов пока нет.", + "addFirstSite": "Разверните первый сайт", + "viewAllSites": "Все сайты" }, "projects": { "title": "Проекты", @@ -80,6 +87,11 @@ "loadingTags": "Загрузка тегов...", "chooseTag": "Выберите тег...", "enterTag": "Введите тег образа (напр., dev-abc123)", + "registryTag": "Реестр", + "localTag": "Локальный", + "alsoLocal": "Также доступен локально", + "searchTags": "Поиск тегов...", + "deployTag": "Тег", "deploy": "Развернуть", "deploying": "Развёртывание...", "recentDeploys": "Последние деплои", @@ -107,7 +119,7 @@ "autoDeployLabel": "Авто-деплой", "enableProxy": "Включить прокси", "accessListId": "ID списка доступа NPM", - "accessListIdHelp": "Переопределение для проекта. 0 = использовать глобальное из настроек NPM.", + "accessListIdHelp": "Переопределить глобальный список доступа для этого проекта. Очистите, чтобы наследовать из настроек NPM.", "localImages": "Локальные Docker-образы", "imageTag": "Тег", "imageId": "ID образа", @@ -124,6 +136,8 @@ "deleteStage": "Удалить стадию", "deleteStageConfirm": "Удалить стадию \"{name}\"?", "stageCreated": "Стадия \"{name}\" создана", + "stageUpdated": "Стадия обновлена", + "stageUpdateFailed": "Не удалось обновить стадию", "stageDeleted": "Стадия \"{name}\" удалена", "projectUpdated": "Проект обновлён", "updateFailed": "Не удалось обновить проект", @@ -135,6 +149,7 @@ "description": "Управление переопределениями переменных окружения на уровне стадий. Значения стадий переопределяют значения проекта.", "stage": "Стадия", "projectDefaults": "Значения проекта по умолчанию", + "noProjectEnv": "Переменные окружения на уровне проекта ещё не определены.", "stageOverrides": "Переопределения стадии", "key": "Ключ", "value": "Значение", @@ -160,7 +175,9 @@ "updateFailed": "Не удалось обновить переменную", "deleteFailed": "Не удалось удалить переменную", "loadEnvFailed": "Не удалось загрузить переменные", - "leaveEmptyToKeep": "Оставьте пустым, чтобы сохранить текущее" + "leaveEmptyToKeep": "Оставьте пустым, чтобы сохранить текущее", + "deleteTitle": "Удалить переменную окружения", + "deleteMessage": "Вы уверены, что хотите удалить эту переменную окружения? Это действие нельзя отменить." }, "volumeEditor": { "title": "Тома", @@ -405,7 +422,7 @@ "remoteModeWarning": "Требуется IP сервера в общих настройках. Порты автоматически привязываются к случайным портам хоста.", "accessList": "Список доступа по умолчанию", "accessListHelp": "Список доступа NPM для HTTP-аутентификации на прокси-хостах. Можно переопределить для каждого проекта.", - "noAccessList": "Нет (публичный)", + "noAccessList": "Глобальные настройки", "selectAccessList": "Выберите список доступа", "noAccessLists": "Списки доступа в NPM не найдены", "accessListLoadFailed": "Не удалось загрузить списки доступа" @@ -560,6 +577,8 @@ "openSite": "Открыть сайт", "confirmDelete": "Удалить сайт", "confirmDeleteMsg": "Это удалит сайт и его контейнер", + "confirmDeleteSecret": "Удалить секрет", + "confirmDeleteSecretMsg": "Вы уверены, что хотите удалить секрет", "siteInfo": "Информация о сайте", "folder": "Папка", "syncTrigger": "Триггер синхр.", @@ -609,13 +628,26 @@ "provider": "Git-провайдер", "detectedProvider": "Автоопределён", "browseRepos": "Обзор репозиториев", - "selectRepo": "Выберите репозиторий" + "selectRepo": "Выберите репозиторий", + "storage": "Хранилище данных", + "enableStorage": "Включить хранилище данных", + "storageHelp": "Подключает Docker-том в /app/data, чтобы Deno-бэкенд мог читать и записывать файлы, сохраняющиеся между деплоями.", + "storageLimitMB": "Лимит хранилища (МБ)", + "storageLimitHelp": "Максимальный размер хранилища в мегабайтах. 0 = без ограничений.", + "storageVolume": "Том", + "dataPath": "Путь к данным", + "storageMountPath": "Путь монтирования", + "storageLimit": "Лимит", + "storageUsed": "Использовано", + "storageOfLimit": "от лимита использовано", + "unlimited": "Без ограничений" }, "common": { "cancel": "Отмена", "confirm": "Подтвердить", "delete": "Удалить", "edit": "Изменить", + "change": "Изменить", "save": "Сохранить", "retry": "Повторить", "loading": "Загрузка...", diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte index 45175cd..06d3817 100644 --- a/web/src/routes/projects/[id]/+page.svelte +++ b/web/src/routes/projects/[id]/+page.svelte @@ -8,7 +8,7 @@ import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import EmptyState from '$lib/components/EmptyState.svelte'; import Skeleton from '$lib/components/Skeleton.svelte'; - import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconLoader, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons'; + import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons'; import Breadcrumb from '$lib/components/Breadcrumb.svelte'; import FormField from '$lib/components/FormField.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; @@ -30,7 +30,51 @@ let deployLoading = $state(false); let deployError = $state(''); - let availableTags = $state([]); + + // Edit stage + let editingStageId = $state(''); + let editStageName = $state(''); + let editStageTagPattern = $state(''); + let editStageAutoDeploy = $state(true); + let editStageEnableProxy = $state(true); + let editStageMaxInstances = $state('1'); + let editStageCpuLimit = $state(''); + let editStageMemoryLimit = $state(''); + let savingStage = $state(false); + + function startEditStage(stage: Stage) { + editingStageId = stage.id; + editStageName = stage.name; + editStageTagPattern = stage.tag_pattern; + editStageAutoDeploy = stage.auto_deploy; + editStageEnableProxy = stage.enable_proxy; + editStageMaxInstances = String(stage.max_instances); + editStageCpuLimit = stage.cpu_limit ? String(stage.cpu_limit) : ''; + editStageMemoryLimit = stage.memory_limit ? String(stage.memory_limit) : ''; + } + + async function handleUpdateStage() { + if (!editStageName.trim()) return; + savingStage = true; + try { + await api.updateStage(projectId, editingStageId, { + name: editStageName.trim(), + tag_pattern: editStageTagPattern.trim() || '*', + auto_deploy: editStageAutoDeploy, + enable_proxy: editStageEnableProxy, + max_instances: parseInt(editStageMaxInstances) || 1, + cpu_limit: parseFloat(editStageCpuLimit) || 0, + memory_limit: parseInt(editStageMemoryLimit) || 0, + }); + toasts.success($t('projectDetail.stageUpdated')); + editingStageId = ''; + await loadProject(); + } catch (e) { + toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageUpdateFailed')); + } finally { + savingStage = false; + } + } // Add stage form let showAddStage = $state(false); @@ -149,32 +193,42 @@ async function handleDeleteStage(stageId: string, name: string) { try { await api.deleteStage(projectId, stageId); + // Update local state immediately so the UI reflects the change. + stages = stages.filter((s) => s.id !== stageId); + const { [stageId]: _, ...rest } = instancesByStage; + instancesByStage = rest; toasts.success($t('projectDetail.stageDeleted', { name })); - await loadProject(); } catch (e) { toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageDeleteFailed')); } } - let tagsLoading = $state(false); let settingsDomain = $state(''); let localImages = $state([]); let showDeleteConfirm = $state(false); + let stageDeleteTarget = $state<{ id: string; name: string } | null>(null); + let loadController: AbortController | null = null; const projectId = $derived($page.params.id!); // always present on [id] route async function loadProject() { + // Abort any previous in-flight load before starting a new one. + loadController?.abort(); + const controller = new AbortController(); + loadController = controller; + const signal = controller.signal; + if (!project) loading = true; error = ''; try { - const detail = await api.getProject(projectId); + const detail = await api.getProject(projectId, signal); project = detail.project; stages = detail.stages ?? []; const instanceResults = await Promise.all( stages.map(async (s) => { try { - const instances = await api.listInstances(projectId, s.id); + const instances = await api.listInstances(projectId, s.id, signal); return { stageId: s.id, instances }; } catch { return { stageId: s.id, instances: [] }; @@ -190,9 +244,9 @@ // Fetch deploys, settings, and images in parallel (independent of each other). const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([ - api.listDeploys(20), - api.getSettings(), - api.listProjectImages(projectId) + api.listDeploys(20, signal), + api.getSettings(signal), + api.listProjectImages(projectId, signal) ]); deploys = deploysResult.status === 'fulfilled' @@ -205,32 +259,92 @@ ? imagesResult.value : []; } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') return; error = e instanceof Error ? e.message : $t('projectDetail.loadFailed'); } finally { loading = false; } } - async function loadTags(stageId: string) { + let tagPickerOpen = $state(false); + let tagPickerItems = $state([]); + + async function openTagPicker(stageId: string) { deployStageId = stageId; deployTag = ''; - availableTags = []; - if (!project?.registry || !project?.image) return; + // Build local image suggestions. + const imgs = localImages; + const localItems: EntityPickerItem[] = imgs + .filter((img) => img.tag) + .map((img) => ({ + value: img.tag, + label: img.tag, + group: $t('projectDetail.localTag'), + description: `${(img.size / (1024 * 1024)).toFixed(0)} MB` + })); - tagsLoading = true; + // Try to fetch registry tags. + let registryItems: EntityPickerItem[] = []; try { - // Look up registry ID from name. const registries = await api.listRegistries(); - const reg = registries.find(r => r.name === project?.registry); - if (reg) { - availableTags = await api.listRegistryTags(reg.id, project.image); + // Match by registry URL hostname (project.registry stores the hostname) + // or by name, or try all registries if project.registry is empty. + const projectRegistry = project?.registry || ''; + const projectImage = project?.image || ''; + + let reg = registries.find(r => { + if (!projectRegistry) return false; + const urlHost = new URL(r.url).hostname; + return r.name === projectRegistry || urlHost === projectRegistry; + }); + + // If project has no registry set but image contains a hostname, try matching by image prefix. + if (!reg && projectImage.includes('/')) { + const imageHost = projectImage.split('/')[0]; + if (imageHost.includes('.')) { + reg = registries.find(r => { + try { return new URL(r.url).hostname === imageHost; } catch { return false; } + }); + } } - } catch { - availableTags = []; - } finally { - tagsLoading = false; + + if (reg) { + // Strip registry hostname from image if present (registry API expects owner/name). + let imageForRegistry = projectImage; + try { + const urlHost = new URL(reg.url).hostname; + if (imageForRegistry.startsWith(urlHost + '/')) { + imageForRegistry = imageForRegistry.substring(urlHost.length + 1); + } + } catch { /* keep as-is */ } + + const tags = await api.listRegistryTags(reg.id, imageForRegistry); + const localTagSet = new Set(imgs.map((img) => img.tag)); + registryItems = tags.map((tag) => ({ + value: tag, + label: tag, + group: $t('projectDetail.registryTag'), + description: localTagSet.has(tag) ? $t('projectDetail.alsoLocal') : undefined + })); + } + } catch { /* ignore registry errors */ } + + // Merge: registry tags first, then local-only tags. + if (registryItems.length > 0) { + const registryTagSet = new Set(registryItems.map((item) => item.value)); + const localOnly = localItems.filter((item) => !registryTagSet.has(item.value)); + tagPickerItems = [...registryItems, ...localOnly]; + } else { + tagPickerItems = localItems; } + + tagPickerOpen = true; + } + + function handleTagSelect(tag: string) { + deployTag = tag; + tagPickerOpen = false; } async function handleDeploy() { @@ -267,6 +381,10 @@ $effect(() => { void projectId; if (!deleted) loadProject(); + + return () => { + loadController?.abort(); + }; }); @@ -470,94 +588,117 @@
{#each stages as stage (stage.id)} {@const stageInstances = instancesByStage[stage.id] ?? []} -
+
-
-
-

{stage.name}

- {stage.tag_pattern} - {#if stage.auto_deploy} - {$t('projectDetail.autoDeploy')} - {/if} - {#if stage.confirm} - {$t('projectDetail.requiresConfirm')} - {/if} - {#if !stage.enable_proxy} - {$t('projectDetail.noProxy')} - {/if} -
-
- - {stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')} - - - -
-
- - - {#if deployStageId === stage.id} -
-
-
- - {#if tagsLoading} -
- - {$t('projectDetail.loadingTags')} -
- {:else if availableTags.length > 0} - - {:else} - - {/if} + {#if editingStageId === stage.id} +
+
+ + + + + +
+
+ {$t('projectDetail.autoDeployLabel')} + +
+
+ {$t('projectDetail.enableProxy')} + +
+
+
+ + +
+
+ {:else} +
+
+

{stage.name}

+ {stage.tag_pattern} + {#if stage.auto_deploy} + {$t('projectDetail.autoDeploy')} + {/if} + {#if stage.confirm} + {$t('projectDetail.requiresConfirm')} + {/if} + {#if !stage.enable_proxy} + {$t('projectDetail.noProxy')} + {/if} +
+
+ + {stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')} + + +
+
+ {/if} + + + {#if deployStageId === stage.id && deployTag} +
+
+ {$t('projectDetail.deployTag')}: + {deployTag} + +
+ + +
{#if deployError}

{deployError}

@@ -671,6 +812,20 @@ oncancel={() => { showDeleteConfirm = false; }} /> + { + const target = stageDeleteTarget; + stageDeleteTarget = null; + if (target) await handleDeleteStage(target.id, target.name); + }} + oncancel={() => { stageDeleteTarget = null; }} + /> + { accessListPickerOpen = false; }} /> + + { tagPickerOpen = false; }} + /> {/if} diff --git a/web/src/routes/projects/[id]/env/+page.svelte b/web/src/routes/projects/[id]/env/+page.svelte index 5fa2a11..37fae90 100644 --- a/web/src/routes/projects/[id]/env/+page.svelte +++ b/web/src/routes/projects/[id]/env/+page.svelte @@ -31,8 +31,68 @@ let envDeleteTarget = $state(null); + // Project-level env editing + let newProjectKey = $state(''); + let newProjectValue = $state(''); + let savingProject = $state(false); + let editingProjectKey = $state(''); + let editProjectValue = $state(''); + let projectEnvDeleteTarget = $state(null); + const projectId = $derived($page.params.id); + async function handleAddProjectEnv() { + if (!newProjectKey.trim()) return; + savingProject = true; + try { + const updated = { ...projectEnv, [newProjectKey.trim()]: newProjectValue }; + await api.updateProject(projectId!, { env: JSON.stringify(updated) }); + projectEnv = updated; + newProjectKey = ''; + newProjectValue = ''; + toasts.success($t('envEditor.envAdded')); + } catch (e) { + toasts.error(e instanceof Error ? e.message : $t('envEditor.addFailed')); + } finally { + savingProject = false; + } + } + + function startEditProjectEnv(key: string) { + editingProjectKey = key; + editProjectValue = projectEnv[key] ?? ''; + } + + async function handleUpdateProjectEnv() { + if (!editingProjectKey) return; + savingProject = true; + try { + const updated = { ...projectEnv, [editingProjectKey]: editProjectValue }; + await api.updateProject(projectId!, { env: JSON.stringify(updated) }); + projectEnv = updated; + editingProjectKey = ''; + toasts.success($t('envEditor.envUpdated')); + } catch (e) { + toasts.error(e instanceof Error ? e.message : $t('envEditor.updateFailed')); + } finally { + savingProject = false; + } + } + + async function handleDeleteProjectEnv(key: string) { + savingProject = true; + try { + const { [key]: _, ...rest } = projectEnv; + await api.updateProject(projectId!, { env: JSON.stringify(rest) }); + projectEnv = rest; + toasts.success($t('envEditor.envDeleted')); + } catch (e) { + toasts.error(e instanceof Error ? e.message : $t('envEditor.deleteFailed')); + } finally { + savingProject = false; + } + } + async function loadProject() { if (stages.length === 0) loading = true; error = ''; @@ -169,41 +229,42 @@

{error}

{:else} - -
- - -
- + {#if stages.length === 0} {:else} - - {#if Object.keys(projectEnv).length > 0} -
-

{$t('envEditor.projectDefaults')}

-
- - - - - - - - - - {#each Object.entries(projectEnv) as [key, value] (key)} - +
+

{$t('envEditor.projectDefaults')}

+
+
{$t('envEditor.key')}{$t('envEditor.value')}{$t('envEditor.source')}
+ + + + + + + + + + {#each Object.entries(projectEnv) as [key, val] (key)} + {#if editingProjectKey === key} + - + + + + + {:else} + + + + - {/each} - -
{$t('envEditor.key')}{$t('envEditor.value')}{$t('envEditor.source')}{$t('envEditor.actions')}
{key}{value} + + +
+ + +
+
{key}{val} {#if isOverridden(key)} {$t('envEditor.overridden')} @@ -211,17 +272,59 @@ {$t('envEditor.inherited')} {/if} +
+ + +
+
-
+ {/if} + {/each} + + + + + + + + + + + + + + + +
- {/if} + {#if Object.keys(projectEnv).length === 0} +

{$t('envEditor.noProjectEnv')}

+ {/if} +
-

{$t('envEditor.stageOverrides')}

+
+

{$t('envEditor.stageOverrides')}

+ +
{#if envLoading}
@@ -346,3 +449,17 @@ }} oncancel={() => { envDeleteTarget = null; }} /> + + { + const key = projectEnvDeleteTarget; + projectEnvDeleteTarget = null; + if (key) await handleDeleteProjectEnv(key); + }} + oncancel={() => { projectEnvDeleteTarget = null; }} +/>