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:
@@ -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<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
|
||||
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<LocalImage[]>([]);
|
||||
|
||||
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<EntityPickerItem[]>([]);
|
||||
|
||||
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();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -470,94 +588,117 @@
|
||||
<div class="mt-4 space-y-4">
|
||||
{#each stages as stage (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 -->
|
||||
<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
|
||||
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}
|
||||
{#if editingStageId === stage.id}
|
||||
<div class="border-b border-[var(--border-secondary)] px-5 py-4">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<FormField label={$t('projectDetail.nameLabel')} name="editStageName" bind:value={editStageName} />
|
||||
<FormField label={$t('projectDetail.tagPattern')} name="editStagePattern" bind:value={editStageTagPattern} />
|
||||
<FormField label={$t('projectDetail.maxInstances')} name="editStageMax" type="number" bind:value={editStageMaxInstances} />
|
||||
<FormField label={$t('projectDetail.cpuLimit')} name="editStageCpu" type="number" bind:value={editStageCpuLimit} placeholder="0" />
|
||||
<FormField label={$t('projectDetail.memoryLimit')} name="editStageMem" type="number" bind:value={editStageMemoryLimit} placeholder="0" />
|
||||
<div class="flex gap-4 items-end pb-1">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.autoDeployLabel')}</span>
|
||||
<ToggleSwitch bind:checked={editStageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.enableProxy')}</span>
|
||||
<ToggleSwitch bind:checked={editStageEnableProxy} label={$t('projectDetail.enableProxy')} />
|
||||
</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
|
||||
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"
|
||||
disabled={!deployTag.trim() || deployLoading}
|
||||
onclick={handleDeploy}
|
||||
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={() => openTagPicker(stage.id)}
|
||||
>
|
||||
{deployLoading ? $t('projectDetail.deploying') : $t('projectDetail.deploy')}
|
||||
<IconDeploy size={14} />
|
||||
{$t('projectDetail.deployNewVersion')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
|
||||
onclick={() => { deployStageId = ''; }}
|
||||
title={$t('common.edit')}
|
||||
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
|
||||
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>
|
||||
{#if deployError}
|
||||
<p class="mt-2 text-xs text-[var(--color-danger)]">{deployError}</p>
|
||||
@@ -671,6 +812,20 @@
|
||||
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
|
||||
bind:open={accessListPickerOpen}
|
||||
items={accessListPickerItems}
|
||||
@@ -679,4 +834,14 @@
|
||||
onselect={handleProjectAccessListSelect}
|
||||
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}
|
||||
|
||||
+154
-37
@@ -31,8 +31,68 @@
|
||||
|
||||
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);
|
||||
|
||||
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 @@
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Stage selector -->
|
||||
<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>
|
||||
|
||||
<!-- Project-level env -->
|
||||
{#if stages.length === 0}
|
||||
<EmptyState title={$t('envEditor.noStages')} icon="instances" />
|
||||
{:else}
|
||||
<!-- Project-level env -->
|
||||
{#if Object.keys(projectEnv).length > 0}
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
|
||||
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card-hover)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead>
|
||||
<tr>
|
||||
<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.value')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each Object.entries(projectEnv) as [key, value] (key)}
|
||||
<tr class={isOverridden(key) ? 'opacity-50' : ''}>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
|
||||
<div class="mt-2 overflow-x-auto 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-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.value')}</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-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each Object.entries(projectEnv) as [key, val] (key)}
|
||||
{#if editingProjectKey === key}
|
||||
<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="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">
|
||||
{#if isOverridden(key)}
|
||||
<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>
|
||||
{/if}
|
||||
</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>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- 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>
|
||||
{/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 -->
|
||||
<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}
|
||||
<div class="mt-4 flex items-center justify-center gap-2 py-8 text-[var(--text-tertiary)]">
|
||||
@@ -346,3 +449,17 @@
|
||||
}}
|
||||
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