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).
This commit is contained in:
@@ -0,0 +1,185 @@
|
|||||||
|
<!--
|
||||||
|
Inline combobox for selecting or typing a Docker image tag.
|
||||||
|
Shows filtered suggestions from registry tags and local images.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
registryTags?: string[];
|
||||||
|
localTags?: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id = '',
|
||||||
|
value = $bindable(''),
|
||||||
|
registryTags = [],
|
||||||
|
localTags = [],
|
||||||
|
placeholder = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let highlightIndex = $state(-1);
|
||||||
|
let inputEl: HTMLInputElement | undefined = $state(undefined);
|
||||||
|
let blurTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
interface TagSuggestion {
|
||||||
|
tag: string;
|
||||||
|
source: 'registry' | 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTags = $derived.by(() => {
|
||||||
|
const items: TagSuggestion[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const tag of registryTags) {
|
||||||
|
if (!seen.has(tag)) { seen.add(tag); items.push({ tag, source: 'registry' }); }
|
||||||
|
}
|
||||||
|
for (const tag of localTags) {
|
||||||
|
if (!seen.has(tag)) { seen.add(tag); items.push({ tag, source: 'local' }); }
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const q = value.toLowerCase().trim();
|
||||||
|
if (!q) return allTags;
|
||||||
|
return allTags.filter((s) => s.tag.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasSuggestions = $derived(allTags.length > 0);
|
||||||
|
|
||||||
|
function select(tag: string) {
|
||||||
|
value = tag;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
highlightIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (open) {
|
||||||
|
close();
|
||||||
|
} else if (hasSuggestions) {
|
||||||
|
open = true;
|
||||||
|
inputEl?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPendingBlur() {
|
||||||
|
if (blurTimeout) { clearTimeout(blurTimeout); blurTimeout = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
cancelPendingBlur();
|
||||||
|
if (hasSuggestions) open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
cancelPendingBlur();
|
||||||
|
blurTimeout = setTimeout(close, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
cancelPendingBlur();
|
||||||
|
open = true;
|
||||||
|
highlightIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && hasSuggestions) {
|
||||||
|
open = true;
|
||||||
|
highlightIndex = 0;
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIndex = filtered.length > 0 ? (highlightIndex + 1) % filtered.length : -1;
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIndex = filtered.length > 0 ? (highlightIndex - 1 + filtered.length) % filtered.length : -1;
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (highlightIndex >= 0 && highlightIndex < filtered.length) {
|
||||||
|
select(filtered[highlightIndex].tag);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative mt-1">
|
||||||
|
<div class="flex">
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value
|
||||||
|
type="text"
|
||||||
|
{placeholder}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="block w-full rounded-lg {hasSuggestions ? 'rounded-r-none' : ''} border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
|
||||||
|
oninput={handleInput}
|
||||||
|
onfocus={handleFocus}
|
||||||
|
onblur={handleBlur}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
/>
|
||||||
|
{#if hasSuggestions}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex={-1}
|
||||||
|
class="flex items-center border border-l-0 border-[var(--border-input)] bg-[var(--surface-input)] px-2 rounded-r-lg text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
|
||||||
|
onmousedown={(e) => { e.preventDefault(); toggle(); }}
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 transition-transform {open ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if open && filtered.length > 0}
|
||||||
|
<div
|
||||||
|
class="absolute z-[100] mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-xl"
|
||||||
|
role="listbox"
|
||||||
|
onmousedown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{#each filtered as item, i (item.tag + item.source)}
|
||||||
|
{@const highlighted = i === highlightIndex}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={highlighted}
|
||||||
|
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors
|
||||||
|
{highlighted
|
||||||
|
? 'bg-[var(--color-brand-50)] text-[var(--text-primary)]'
|
||||||
|
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
|
||||||
|
onmousedown={() => { cancelPendingBlur(); select(item.tag); }}
|
||||||
|
onmouseenter={() => { highlightIndex = i; }}
|
||||||
|
>
|
||||||
|
<span class="truncate font-mono">{item.tag}</span>
|
||||||
|
<span class="ml-2 shrink-0 rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 text-[10px] text-[var(--text-tertiary)]">
|
||||||
|
{item.source === 'registry' ? $t('projectDetail.registryTag') : $t('projectDetail.localTag')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -33,7 +33,14 @@
|
|||||||
"loadFailed": "Failed to load dashboard",
|
"loadFailed": "Failed to load dashboard",
|
||||||
"staleContainers": "Stale Containers",
|
"staleContainers": "Stale Containers",
|
||||||
"unusedImagesWarning": "Unused Docker images are taking up disk space",
|
"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": {
|
"projects": {
|
||||||
"title": "Projects",
|
"title": "Projects",
|
||||||
@@ -80,6 +87,11 @@
|
|||||||
"loadingTags": "Loading tags...",
|
"loadingTags": "Loading tags...",
|
||||||
"chooseTag": "Choose a tag...",
|
"chooseTag": "Choose a tag...",
|
||||||
"enterTag": "Enter image tag (e.g., dev-abc123)",
|
"enterTag": "Enter image tag (e.g., dev-abc123)",
|
||||||
|
"registryTag": "Registry",
|
||||||
|
"localTag": "Local",
|
||||||
|
"alsoLocal": "Also available locally",
|
||||||
|
"searchTags": "Search tags...",
|
||||||
|
"deployTag": "Tag",
|
||||||
"deploy": "Deploy",
|
"deploy": "Deploy",
|
||||||
"deploying": "Deploying...",
|
"deploying": "Deploying...",
|
||||||
"recentDeploys": "Recent Deploys",
|
"recentDeploys": "Recent Deploys",
|
||||||
@@ -107,7 +119,7 @@
|
|||||||
"autoDeployLabel": "Auto Deploy",
|
"autoDeployLabel": "Auto Deploy",
|
||||||
"enableProxy": "Enable Proxy",
|
"enableProxy": "Enable Proxy",
|
||||||
"accessListId": "NPM Access List ID",
|
"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",
|
"localImages": "Local Docker Images",
|
||||||
"imageTag": "Tag",
|
"imageTag": "Tag",
|
||||||
"imageId": "Image ID",
|
"imageId": "Image ID",
|
||||||
@@ -124,6 +136,8 @@
|
|||||||
"deleteStage": "Delete stage",
|
"deleteStage": "Delete stage",
|
||||||
"deleteStageConfirm": "Delete stage \"{name}\"?",
|
"deleteStageConfirm": "Delete stage \"{name}\"?",
|
||||||
"stageCreated": "Stage \"{name}\" created",
|
"stageCreated": "Stage \"{name}\" created",
|
||||||
|
"stageUpdated": "Stage updated",
|
||||||
|
"stageUpdateFailed": "Failed to update stage",
|
||||||
"stageDeleted": "Stage \"{name}\" deleted",
|
"stageDeleted": "Stage \"{name}\" deleted",
|
||||||
"projectUpdated": "Project updated",
|
"projectUpdated": "Project updated",
|
||||||
"updateFailed": "Failed to update project",
|
"updateFailed": "Failed to update project",
|
||||||
@@ -135,6 +149,7 @@
|
|||||||
"description": "Manage per-stage environment variable overrides. Stage-level values override project-level defaults.",
|
"description": "Manage per-stage environment variable overrides. Stage-level values override project-level defaults.",
|
||||||
"stage": "Stage",
|
"stage": "Stage",
|
||||||
"projectDefaults": "Project-Level Defaults",
|
"projectDefaults": "Project-Level Defaults",
|
||||||
|
"noProjectEnv": "No project-level environment variables defined yet.",
|
||||||
"stageOverrides": "Stage Overrides",
|
"stageOverrides": "Stage Overrides",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
"value": "Value",
|
"value": "Value",
|
||||||
@@ -160,7 +175,9 @@
|
|||||||
"updateFailed": "Failed to update env var",
|
"updateFailed": "Failed to update env var",
|
||||||
"deleteFailed": "Failed to delete env var",
|
"deleteFailed": "Failed to delete env var",
|
||||||
"loadEnvFailed": "Failed to load env vars",
|
"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": {
|
"volumeEditor": {
|
||||||
"title": "Volume Mounts",
|
"title": "Volume Mounts",
|
||||||
@@ -405,7 +422,7 @@
|
|||||||
"remoteModeWarning": "Requires Server IP in General settings. Ports are auto-mapped to random host ports.",
|
"remoteModeWarning": "Requires Server IP in General settings. Ports are auto-mapped to random host ports.",
|
||||||
"accessList": "Default Access List",
|
"accessList": "Default Access List",
|
||||||
"accessListHelp": "NPM access list for HTTP authentication on proxy hosts. Can be overridden per project.",
|
"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",
|
"selectAccessList": "Select an access list",
|
||||||
"noAccessLists": "No access lists found in NPM",
|
"noAccessLists": "No access lists found in NPM",
|
||||||
"accessListLoadFailed": "Failed to load access lists"
|
"accessListLoadFailed": "Failed to load access lists"
|
||||||
@@ -560,6 +577,8 @@
|
|||||||
"openSite": "Open Site",
|
"openSite": "Open Site",
|
||||||
"confirmDelete": "Delete Site",
|
"confirmDelete": "Delete Site",
|
||||||
"confirmDeleteMsg": "This will permanently delete the site and remove its container",
|
"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",
|
"siteInfo": "Site Information",
|
||||||
"folder": "Folder",
|
"folder": "Folder",
|
||||||
"syncTrigger": "Sync Trigger",
|
"syncTrigger": "Sync Trigger",
|
||||||
@@ -609,13 +628,26 @@
|
|||||||
"provider": "Git Provider",
|
"provider": "Git Provider",
|
||||||
"detectedProvider": "Auto-detected",
|
"detectedProvider": "Auto-detected",
|
||||||
"browseRepos": "Browse repositories",
|
"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": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"change": "Change",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
|||||||
@@ -33,7 +33,14 @@
|
|||||||
"loadFailed": "Не удалось загрузить панель",
|
"loadFailed": "Не удалось загрузить панель",
|
||||||
"staleContainers": "Устаревшие контейнеры",
|
"staleContainers": "Устаревшие контейнеры",
|
||||||
"unusedImagesWarning": "Неиспользуемые Docker-образы занимают дисковое пространство",
|
"unusedImagesWarning": "Неиспользуемые Docker-образы занимают дисковое пространство",
|
||||||
"unusedImages": "неиспользуемых образов"
|
"unusedImages": "неиспользуемых образов",
|
||||||
|
"staticSites": "Статические сайты",
|
||||||
|
"totalSites": "Всего сайтов",
|
||||||
|
"deployedSites": "развёрнуто",
|
||||||
|
"failedSites": "с ошибкой",
|
||||||
|
"noSites": "Статических сайтов пока нет.",
|
||||||
|
"addFirstSite": "Разверните первый сайт",
|
||||||
|
"viewAllSites": "Все сайты"
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"title": "Проекты",
|
"title": "Проекты",
|
||||||
@@ -80,6 +87,11 @@
|
|||||||
"loadingTags": "Загрузка тегов...",
|
"loadingTags": "Загрузка тегов...",
|
||||||
"chooseTag": "Выберите тег...",
|
"chooseTag": "Выберите тег...",
|
||||||
"enterTag": "Введите тег образа (напр., dev-abc123)",
|
"enterTag": "Введите тег образа (напр., dev-abc123)",
|
||||||
|
"registryTag": "Реестр",
|
||||||
|
"localTag": "Локальный",
|
||||||
|
"alsoLocal": "Также доступен локально",
|
||||||
|
"searchTags": "Поиск тегов...",
|
||||||
|
"deployTag": "Тег",
|
||||||
"deploy": "Развернуть",
|
"deploy": "Развернуть",
|
||||||
"deploying": "Развёртывание...",
|
"deploying": "Развёртывание...",
|
||||||
"recentDeploys": "Последние деплои",
|
"recentDeploys": "Последние деплои",
|
||||||
@@ -107,7 +119,7 @@
|
|||||||
"autoDeployLabel": "Авто-деплой",
|
"autoDeployLabel": "Авто-деплой",
|
||||||
"enableProxy": "Включить прокси",
|
"enableProxy": "Включить прокси",
|
||||||
"accessListId": "ID списка доступа NPM",
|
"accessListId": "ID списка доступа NPM",
|
||||||
"accessListIdHelp": "Переопределение для проекта. 0 = использовать глобальное из настроек NPM.",
|
"accessListIdHelp": "Переопределить глобальный список доступа для этого проекта. Очистите, чтобы наследовать из настроек NPM.",
|
||||||
"localImages": "Локальные Docker-образы",
|
"localImages": "Локальные Docker-образы",
|
||||||
"imageTag": "Тег",
|
"imageTag": "Тег",
|
||||||
"imageId": "ID образа",
|
"imageId": "ID образа",
|
||||||
@@ -124,6 +136,8 @@
|
|||||||
"deleteStage": "Удалить стадию",
|
"deleteStage": "Удалить стадию",
|
||||||
"deleteStageConfirm": "Удалить стадию \"{name}\"?",
|
"deleteStageConfirm": "Удалить стадию \"{name}\"?",
|
||||||
"stageCreated": "Стадия \"{name}\" создана",
|
"stageCreated": "Стадия \"{name}\" создана",
|
||||||
|
"stageUpdated": "Стадия обновлена",
|
||||||
|
"stageUpdateFailed": "Не удалось обновить стадию",
|
||||||
"stageDeleted": "Стадия \"{name}\" удалена",
|
"stageDeleted": "Стадия \"{name}\" удалена",
|
||||||
"projectUpdated": "Проект обновлён",
|
"projectUpdated": "Проект обновлён",
|
||||||
"updateFailed": "Не удалось обновить проект",
|
"updateFailed": "Не удалось обновить проект",
|
||||||
@@ -135,6 +149,7 @@
|
|||||||
"description": "Управление переопределениями переменных окружения на уровне стадий. Значения стадий переопределяют значения проекта.",
|
"description": "Управление переопределениями переменных окружения на уровне стадий. Значения стадий переопределяют значения проекта.",
|
||||||
"stage": "Стадия",
|
"stage": "Стадия",
|
||||||
"projectDefaults": "Значения проекта по умолчанию",
|
"projectDefaults": "Значения проекта по умолчанию",
|
||||||
|
"noProjectEnv": "Переменные окружения на уровне проекта ещё не определены.",
|
||||||
"stageOverrides": "Переопределения стадии",
|
"stageOverrides": "Переопределения стадии",
|
||||||
"key": "Ключ",
|
"key": "Ключ",
|
||||||
"value": "Значение",
|
"value": "Значение",
|
||||||
@@ -160,7 +175,9 @@
|
|||||||
"updateFailed": "Не удалось обновить переменную",
|
"updateFailed": "Не удалось обновить переменную",
|
||||||
"deleteFailed": "Не удалось удалить переменную",
|
"deleteFailed": "Не удалось удалить переменную",
|
||||||
"loadEnvFailed": "Не удалось загрузить переменные",
|
"loadEnvFailed": "Не удалось загрузить переменные",
|
||||||
"leaveEmptyToKeep": "Оставьте пустым, чтобы сохранить текущее"
|
"leaveEmptyToKeep": "Оставьте пустым, чтобы сохранить текущее",
|
||||||
|
"deleteTitle": "Удалить переменную окружения",
|
||||||
|
"deleteMessage": "Вы уверены, что хотите удалить эту переменную окружения? Это действие нельзя отменить."
|
||||||
},
|
},
|
||||||
"volumeEditor": {
|
"volumeEditor": {
|
||||||
"title": "Тома",
|
"title": "Тома",
|
||||||
@@ -405,7 +422,7 @@
|
|||||||
"remoteModeWarning": "Требуется IP сервера в общих настройках. Порты автоматически привязываются к случайным портам хоста.",
|
"remoteModeWarning": "Требуется IP сервера в общих настройках. Порты автоматически привязываются к случайным портам хоста.",
|
||||||
"accessList": "Список доступа по умолчанию",
|
"accessList": "Список доступа по умолчанию",
|
||||||
"accessListHelp": "Список доступа NPM для HTTP-аутентификации на прокси-хостах. Можно переопределить для каждого проекта.",
|
"accessListHelp": "Список доступа NPM для HTTP-аутентификации на прокси-хостах. Можно переопределить для каждого проекта.",
|
||||||
"noAccessList": "Нет (публичный)",
|
"noAccessList": "Глобальные настройки",
|
||||||
"selectAccessList": "Выберите список доступа",
|
"selectAccessList": "Выберите список доступа",
|
||||||
"noAccessLists": "Списки доступа в NPM не найдены",
|
"noAccessLists": "Списки доступа в NPM не найдены",
|
||||||
"accessListLoadFailed": "Не удалось загрузить списки доступа"
|
"accessListLoadFailed": "Не удалось загрузить списки доступа"
|
||||||
@@ -560,6 +577,8 @@
|
|||||||
"openSite": "Открыть сайт",
|
"openSite": "Открыть сайт",
|
||||||
"confirmDelete": "Удалить сайт",
|
"confirmDelete": "Удалить сайт",
|
||||||
"confirmDeleteMsg": "Это удалит сайт и его контейнер",
|
"confirmDeleteMsg": "Это удалит сайт и его контейнер",
|
||||||
|
"confirmDeleteSecret": "Удалить секрет",
|
||||||
|
"confirmDeleteSecretMsg": "Вы уверены, что хотите удалить секрет",
|
||||||
"siteInfo": "Информация о сайте",
|
"siteInfo": "Информация о сайте",
|
||||||
"folder": "Папка",
|
"folder": "Папка",
|
||||||
"syncTrigger": "Триггер синхр.",
|
"syncTrigger": "Триггер синхр.",
|
||||||
@@ -609,13 +628,26 @@
|
|||||||
"provider": "Git-провайдер",
|
"provider": "Git-провайдер",
|
||||||
"detectedProvider": "Автоопределён",
|
"detectedProvider": "Автоопределён",
|
||||||
"browseRepos": "Обзор репозиториев",
|
"browseRepos": "Обзор репозиториев",
|
||||||
"selectRepo": "Выберите репозиторий"
|
"selectRepo": "Выберите репозиторий",
|
||||||
|
"storage": "Хранилище данных",
|
||||||
|
"enableStorage": "Включить хранилище данных",
|
||||||
|
"storageHelp": "Подключает Docker-том в /app/data, чтобы Deno-бэкенд мог читать и записывать файлы, сохраняющиеся между деплоями.",
|
||||||
|
"storageLimitMB": "Лимит хранилища (МБ)",
|
||||||
|
"storageLimitHelp": "Максимальный размер хранилища в мегабайтах. 0 = без ограничений.",
|
||||||
|
"storageVolume": "Том",
|
||||||
|
"dataPath": "Путь к данным",
|
||||||
|
"storageMountPath": "Путь монтирования",
|
||||||
|
"storageLimit": "Лимит",
|
||||||
|
"storageUsed": "Использовано",
|
||||||
|
"storageOfLimit": "от лимита использовано",
|
||||||
|
"unlimited": "Без ограничений"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"edit": "Изменить",
|
"edit": "Изменить",
|
||||||
|
"change": "Изменить",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"retry": "Повторить",
|
"retry": "Повторить",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.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 Breadcrumb from '$lib/components/Breadcrumb.svelte';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
@@ -30,7 +30,51 @@
|
|||||||
let deployLoading = $state(false);
|
let deployLoading = $state(false);
|
||||||
let deployError = $state('');
|
let deployError = $state('');
|
||||||
|
|
||||||
let availableTags = $state<string[]>([]);
|
|
||||||
|
// 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
|
// Add stage form
|
||||||
let showAddStage = $state(false);
|
let showAddStage = $state(false);
|
||||||
@@ -149,32 +193,42 @@
|
|||||||
async function handleDeleteStage(stageId: string, name: string) {
|
async function handleDeleteStage(stageId: string, name: string) {
|
||||||
try {
|
try {
|
||||||
await api.deleteStage(projectId, stageId);
|
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 }));
|
toasts.success($t('projectDetail.stageDeleted', { name }));
|
||||||
await loadProject();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageDeleteFailed'));
|
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageDeleteFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let tagsLoading = $state(false);
|
|
||||||
let settingsDomain = $state('');
|
let settingsDomain = $state('');
|
||||||
let localImages = $state<LocalImage[]>([]);
|
let localImages = $state<LocalImage[]>([]);
|
||||||
|
|
||||||
let showDeleteConfirm = $state(false);
|
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
|
const projectId = $derived($page.params.id!); // always present on [id] route
|
||||||
|
|
||||||
async function loadProject() {
|
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;
|
if (!project) loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const detail = await api.getProject(projectId);
|
const detail = await api.getProject(projectId, signal);
|
||||||
project = detail.project;
|
project = detail.project;
|
||||||
stages = detail.stages ?? [];
|
stages = detail.stages ?? [];
|
||||||
|
|
||||||
const instanceResults = await Promise.all(
|
const instanceResults = await Promise.all(
|
||||||
stages.map(async (s) => {
|
stages.map(async (s) => {
|
||||||
try {
|
try {
|
||||||
const instances = await api.listInstances(projectId, s.id);
|
const instances = await api.listInstances(projectId, s.id, signal);
|
||||||
return { stageId: s.id, instances };
|
return { stageId: s.id, instances };
|
||||||
} catch {
|
} catch {
|
||||||
return { stageId: s.id, instances: [] };
|
return { stageId: s.id, instances: [] };
|
||||||
@@ -190,9 +244,9 @@
|
|||||||
|
|
||||||
// Fetch deploys, settings, and images in parallel (independent of each other).
|
// Fetch deploys, settings, and images in parallel (independent of each other).
|
||||||
const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([
|
const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([
|
||||||
api.listDeploys(20),
|
api.listDeploys(20, signal),
|
||||||
api.getSettings(),
|
api.getSettings(signal),
|
||||||
api.listProjectImages(projectId)
|
api.listProjectImages(projectId, signal)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
deploys = deploysResult.status === 'fulfilled'
|
deploys = deploysResult.status === 'fulfilled'
|
||||||
@@ -205,32 +259,92 @@
|
|||||||
? imagesResult.value
|
? imagesResult.value
|
||||||
: [];
|
: [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||||
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
|
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTags(stageId: string) {
|
let tagPickerOpen = $state(false);
|
||||||
|
let tagPickerItems = $state<EntityPickerItem[]>([]);
|
||||||
|
|
||||||
|
async function openTagPicker(stageId: string) {
|
||||||
deployStageId = stageId;
|
deployStageId = stageId;
|
||||||
deployTag = '';
|
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 {
|
try {
|
||||||
// Look up registry ID from name.
|
|
||||||
const registries = await api.listRegistries();
|
const registries = await api.listRegistries();
|
||||||
const reg = registries.find(r => r.name === project?.registry);
|
// Match by registry URL hostname (project.registry stores the hostname)
|
||||||
if (reg) {
|
// or by name, or try all registries if project.registry is empty.
|
||||||
availableTags = await api.listRegistryTags(reg.id, project.image);
|
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 = [];
|
if (reg) {
|
||||||
} finally {
|
// Strip registry hostname from image if present (registry API expects owner/name).
|
||||||
tagsLoading = false;
|
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() {
|
async function handleDeploy() {
|
||||||
@@ -267,6 +381,10 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
void projectId;
|
void projectId;
|
||||||
if (!deleted) loadProject();
|
if (!deleted) loadProject();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
loadController?.abort();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -470,94 +588,117 @@
|
|||||||
<div class="mt-4 space-y-4">
|
<div class="mt-4 space-y-4">
|
||||||
{#each stages as stage (stage.id)}
|
{#each stages as stage (stage.id)}
|
||||||
{@const stageInstances = instancesByStage[stage.id] ?? []}
|
{@const stageInstances = instancesByStage[stage.id] ?? []}
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)] overflow-hidden">
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||||
<!-- Stage header -->
|
<!-- Stage header -->
|
||||||
<div class="flex items-center justify-between flex-wrap gap-2 border-b border-[var(--border-secondary)] px-5 py-4">
|
{#if editingStageId === stage.id}
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="border-b border-[var(--border-secondary)] px-5 py-4">
|
||||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3>
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<span class="rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 font-mono text-xs text-[var(--text-tertiary)]">{stage.tag_pattern}</span>
|
<FormField label={$t('projectDetail.nameLabel')} name="editStageName" bind:value={editStageName} />
|
||||||
{#if stage.auto_deploy}
|
<FormField label={$t('projectDetail.tagPattern')} name="editStagePattern" bind:value={editStageTagPattern} />
|
||||||
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.autoDeploy')}</span>
|
<FormField label={$t('projectDetail.maxInstances')} name="editStageMax" type="number" bind:value={editStageMaxInstances} />
|
||||||
{/if}
|
<FormField label={$t('projectDetail.cpuLimit')} name="editStageCpu" type="number" bind:value={editStageCpuLimit} placeholder="0" />
|
||||||
{#if stage.confirm}
|
<FormField label={$t('projectDetail.memoryLimit')} name="editStageMem" type="number" bind:value={editStageMemoryLimit} placeholder="0" />
|
||||||
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.requiresConfirm')}</span>
|
<div class="flex gap-4 items-end pb-1">
|
||||||
{/if}
|
<div class="flex flex-col items-center gap-1">
|
||||||
{#if !stage.enable_proxy}
|
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.autoDeployLabel')}</span>
|
||||||
<span class="rounded-full badge-gray rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.noProxy')}</span>
|
<ToggleSwitch bind:checked={editStageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
<div class="flex flex-col items-center gap-1">
|
||||||
<div class="flex items-center gap-3">
|
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.enableProxy')}</span>
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">
|
<ToggleSwitch bind:checked={editStageEnableProxy} label={$t('projectDetail.enableProxy')} />
|
||||||
{stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')}
|
</div>
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors active:animate-press"
|
|
||||||
onclick={() => loadTags(stage.id)}
|
|
||||||
>
|
|
||||||
<IconDeploy size={14} />
|
|
||||||
{$t('projectDetail.deployNewVersion')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={$t('projectDetail.deleteStage')}
|
|
||||||
onclick={() => { if (confirm($t('projectDetail.deleteStageConfirm', { name: stage.name }))) handleDeleteStage(stage.id, stage.name); }}
|
|
||||||
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--color-danger-light)] hover:text-[var(--color-danger)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconTrash size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Deploy form -->
|
|
||||||
{#if deployStageId === stage.id}
|
|
||||||
<div class="border-b border-[var(--border-secondary)] bg-[var(--surface-card-hover)] px-5 py-4 animate-scale-in">
|
|
||||||
<div class="flex items-end gap-3">
|
|
||||||
<div class="flex-1">
|
|
||||||
<label for="deploy-tag-{stage.id}" class="block text-xs font-medium text-[var(--text-secondary)]">
|
|
||||||
{$t('projectDetail.selectTag')}
|
|
||||||
</label>
|
|
||||||
{#if tagsLoading}
|
|
||||||
<div class="mt-1 flex items-center gap-2 text-sm text-[var(--text-tertiary)]">
|
|
||||||
<IconLoader size={16} />
|
|
||||||
{$t('projectDetail.loadingTags')}
|
|
||||||
</div>
|
|
||||||
{:else if availableTags.length > 0}
|
|
||||||
<select
|
|
||||||
id="deploy-tag-{stage.id}"
|
|
||||||
bind:value={deployTag}
|
|
||||||
class="mt-1 block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">{$t('projectDetail.chooseTag')}</option>
|
|
||||||
{#each availableTags as tag}
|
|
||||||
<option value={tag}>{tag}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{:else}
|
|
||||||
<input
|
|
||||||
id="deploy-tag-{stage.id}"
|
|
||||||
type="text"
|
|
||||||
bind:value={deployTag}
|
|
||||||
placeholder={$t('projectDetail.enterTag')}
|
|
||||||
class="mt-1 block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center gap-2 justify-end">
|
||||||
|
<button type="button" onclick={() => { editingStageId = ''; }}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||||
|
<IconX size={14} />
|
||||||
|
{$t('projects.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={handleUpdateStage} disabled={savingStage || !editStageName.trim()}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors">
|
||||||
|
<IconCheck size={14} />
|
||||||
|
{savingStage ? $t('projectDetail.saving') : $t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-2 border-b border-[var(--border-secondary)] px-5 py-4">
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3>
|
||||||
|
<span class="rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 font-mono text-xs text-[var(--text-tertiary)]">{stage.tag_pattern}</span>
|
||||||
|
{#if stage.auto_deploy}
|
||||||
|
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.autoDeploy')}</span>
|
||||||
|
{/if}
|
||||||
|
{#if stage.confirm}
|
||||||
|
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.requiresConfirm')}</span>
|
||||||
|
{/if}
|
||||||
|
{#if !stage.enable_proxy}
|
||||||
|
<span class="rounded-full badge-gray rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.noProxy')}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">
|
||||||
|
{stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors active:animate-press"
|
||||||
disabled={!deployTag.trim() || deployLoading}
|
onclick={() => openTagPicker(stage.id)}
|
||||||
onclick={handleDeploy}
|
|
||||||
>
|
>
|
||||||
{deployLoading ? $t('projectDetail.deploying') : $t('projectDetail.deploy')}
|
<IconDeploy size={14} />
|
||||||
|
{$t('projectDetail.deployNewVersion')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
|
title={$t('common.edit')}
|
||||||
onclick={() => { deployStageId = ''; }}
|
onclick={() => startEditStage(stage)}
|
||||||
|
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"
|
||||||
>
|
>
|
||||||
{$t('common.cancel')}
|
<IconEdit size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={$t('projectDetail.deleteStage')}
|
||||||
|
onclick={() => { stageDeleteTarget = { id: stage.id, name: stage.name }; }}
|
||||||
|
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--color-danger-light)] hover:text-[var(--color-danger)] transition-colors"
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Deploy confirmation -->
|
||||||
|
{#if deployStageId === stage.id && deployTag}
|
||||||
|
<div class="border-b border-[var(--border-secondary)] bg-[var(--surface-card-hover)] px-5 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.deployTag')}:</span>
|
||||||
|
<span class="rounded-md bg-[var(--surface-card)] px-2.5 py-1 font-mono text-sm font-medium text-[var(--text-primary)] border border-[var(--border-primary)]">{deployTag}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors"
|
||||||
|
onclick={() => openTagPicker(stage.id)}
|
||||||
|
>
|
||||||
|
{$t('common.change')}
|
||||||
|
</button>
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
||||||
|
disabled={deployLoading}
|
||||||
|
onclick={handleDeploy}
|
||||||
|
>
|
||||||
|
{deployLoading ? $t('projectDetail.deploying') : $t('projectDetail.deploy')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
|
||||||
|
onclick={() => { deployStageId = ''; deployTag = ''; }}
|
||||||
|
>
|
||||||
|
{$t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if deployError}
|
{#if deployError}
|
||||||
<p class="mt-2 text-xs text-[var(--color-danger)]">{deployError}</p>
|
<p class="mt-2 text-xs text-[var(--color-danger)]">{deployError}</p>
|
||||||
@@ -671,6 +812,20 @@
|
|||||||
oncancel={() => { showDeleteConfirm = false; }}
|
oncancel={() => { showDeleteConfirm = false; }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={stageDeleteTarget !== null}
|
||||||
|
title={$t('projectDetail.deleteStage')}
|
||||||
|
message={stageDeleteTarget ? $t('projectDetail.deleteStageConfirm', { name: stageDeleteTarget.name }) : ''}
|
||||||
|
confirmLabel={$t('common.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={async () => {
|
||||||
|
const target = stageDeleteTarget;
|
||||||
|
stageDeleteTarget = null;
|
||||||
|
if (target) await handleDeleteStage(target.id, target.name);
|
||||||
|
}}
|
||||||
|
oncancel={() => { stageDeleteTarget = null; }}
|
||||||
|
/>
|
||||||
|
|
||||||
<EntityPicker
|
<EntityPicker
|
||||||
bind:open={accessListPickerOpen}
|
bind:open={accessListPickerOpen}
|
||||||
items={accessListPickerItems}
|
items={accessListPickerItems}
|
||||||
@@ -679,4 +834,14 @@
|
|||||||
onselect={handleProjectAccessListSelect}
|
onselect={handleProjectAccessListSelect}
|
||||||
onclose={() => { accessListPickerOpen = false; }}
|
onclose={() => { accessListPickerOpen = false; }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EntityPicker
|
||||||
|
bind:open={tagPickerOpen}
|
||||||
|
items={tagPickerItems}
|
||||||
|
current={deployTag}
|
||||||
|
title={$t('projectDetail.selectTag')}
|
||||||
|
placeholder={$t('projectDetail.searchTags')}
|
||||||
|
onselect={handleTagSelect}
|
||||||
|
onclose={() => { tagPickerOpen = false; }}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+154
-37
@@ -31,8 +31,68 @@
|
|||||||
|
|
||||||
let envDeleteTarget = $state<string | null>(null);
|
let envDeleteTarget = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Project-level env editing
|
||||||
|
let newProjectKey = $state('');
|
||||||
|
let newProjectValue = $state('');
|
||||||
|
let savingProject = $state(false);
|
||||||
|
let editingProjectKey = $state('');
|
||||||
|
let editProjectValue = $state('');
|
||||||
|
let projectEnvDeleteTarget = $state<string | null>(null);
|
||||||
|
|
||||||
const projectId = $derived($page.params.id);
|
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() {
|
async function loadProject() {
|
||||||
if (stages.length === 0) loading = true;
|
if (stages.length === 0) loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -169,41 +229,42 @@
|
|||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Stage selector -->
|
<!-- Project-level env -->
|
||||||
<div>
|
|
||||||
<label for="stage-select" class="block text-sm font-medium text-[var(--text-primary)]">{$t('envEditor.stage')}</label>
|
|
||||||
<select
|
|
||||||
id="stage-select"
|
|
||||||
bind:value={selectedStageId}
|
|
||||||
class="mt-1 block w-64 rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
|
|
||||||
>
|
|
||||||
{#each stages as stage (stage.id)}
|
|
||||||
<option value={stage.id}>{stage.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if stages.length === 0}
|
{#if stages.length === 0}
|
||||||
<EmptyState title={$t('envEditor.noStages')} icon="instances" />
|
<EmptyState title={$t('envEditor.noStages')} icon="instances" />
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Project-level env -->
|
<div>
|
||||||
{#if Object.keys(projectEnv).length > 0}
|
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
|
||||||
<div>
|
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||||
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
|
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||||
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card-hover)]">
|
<thead class="bg-[var(--surface-card-hover)]">
|
||||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
<tr>
|
||||||
<thead>
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th>
|
||||||
<tr>
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th>
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th>
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th>
|
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.actions')}</th>
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
{#each Object.entries(projectEnv) as [key, val] (key)}
|
||||||
{#each Object.entries(projectEnv) as [key, value] (key)}
|
{#if editingProjectKey === key}
|
||||||
<tr class={isOverridden(key) ? 'opacity-50' : ''}>
|
<tr class="bg-[var(--color-brand-50)]/30">
|
||||||
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
|
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
|
||||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{value}</td>
|
<td class="px-4 py-2.5">
|
||||||
|
<input type="text" bind:value={editProjectValue} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] 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"></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={savingProject} onclick={handleUpdateProjectEnv}><IconCheck size={16} /></button>
|
||||||
|
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { editingProjectKey = ''; }}><IconX size={16} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors {isOverridden(key) ? 'opacity-50' : ''}">
|
||||||
|
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
|
||||||
|
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{val}</td>
|
||||||
<td class="px-4 py-2.5 text-sm">
|
<td class="px-4 py-2.5 text-sm">
|
||||||
{#if isOverridden(key)}
|
{#if isOverridden(key)}
|
||||||
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.overridden')}</span>
|
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.overridden')}</span>
|
||||||
@@ -211,17 +272,59 @@
|
|||||||
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.inherited')}</span>
|
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.inherited')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="whitespace-nowrap 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-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEditProjectEnv(key)}><IconEdit size={16} /></button>
|
||||||
|
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { projectEnvDeleteTarget = key; }}><IconTrash size={16} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/if}
|
||||||
</tbody>
|
{/each}
|
||||||
</table>
|
|
||||||
</div>
|
<!-- Add new project env row -->
|
||||||
|
<tr class="bg-[var(--surface-card-hover)]">
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<input type="text" bind:value={newProjectKey} placeholder="KEY_NAME" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] 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">
|
||||||
|
<input type="text" bind:value={newProjectValue} placeholder="value" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] 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"></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={!newProjectKey.trim() || savingProject}
|
||||||
|
onclick={handleAddProjectEnv}
|
||||||
|
>
|
||||||
|
<IconPlus size={14} />
|
||||||
|
{savingProject ? $t('envEditor.adding') : $t('envEditor.add')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if Object.keys(projectEnv).length === 0}
|
||||||
|
<p class="mt-2 text-center text-xs text-[var(--text-tertiary)]">{$t('envEditor.noProjectEnv')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stage-level overrides -->
|
<!-- Stage-level overrides -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.stageOverrides')}</h2>
|
<div class="flex items-center gap-4">
|
||||||
|
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.stageOverrides')}</h2>
|
||||||
|
<select
|
||||||
|
id="stage-select"
|
||||||
|
bind:value={selectedStageId}
|
||||||
|
class="block w-48 rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
|
||||||
|
>
|
||||||
|
{#each stages as stage (stage.id)}
|
||||||
|
<option value={stage.id}>{stage.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if envLoading}
|
{#if envLoading}
|
||||||
<div class="mt-4 flex items-center justify-center gap-2 py-8 text-[var(--text-tertiary)]">
|
<div class="mt-4 flex items-center justify-center gap-2 py-8 text-[var(--text-tertiary)]">
|
||||||
@@ -346,3 +449,17 @@
|
|||||||
}}
|
}}
|
||||||
oncancel={() => { envDeleteTarget = null; }}
|
oncancel={() => { envDeleteTarget = null; }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={projectEnvDeleteTarget !== null}
|
||||||
|
title={$t('envEditor.deleteTitle')}
|
||||||
|
message={$t('envEditor.deleteMessage')}
|
||||||
|
confirmLabel={$t('common.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={async () => {
|
||||||
|
const key = projectEnvDeleteTarget;
|
||||||
|
projectEnvDeleteTarget = null;
|
||||||
|
if (key) await handleDeleteProjectEnv(key);
|
||||||
|
}}
|
||||||
|
oncancel={() => { projectEnvDeleteTarget = null; }}
|
||||||
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user