fix: replace access list ID field with EntityPicker, add deploy toggle, improve UX
- Replace raw NPM access list ID input with EntityPicker on project edit form - Resolve access list name from NPM API when editing project - Add "Deploy immediately" toggle to Quick Deploy (off by default) - Fix stage form layout: all fields on same row with toggles - Fix empty port default on project creation (placeholder instead of pre-filled) - Improve inspect error message when Docker is unavailable - Trigger proxy resync when NPM access list changes - Resolve access list name on NPM settings page load
This commit is contained in:
@@ -217,6 +217,7 @@ export function quickDeploy(data: {
|
||||
port?: number;
|
||||
force?: boolean;
|
||||
enable_proxy?: boolean;
|
||||
auto_deploy?: boolean;
|
||||
}): Promise<{ project: Project; status: string }> {
|
||||
return post<{ project: Project; status: string }>('/api/deploy/quick', data);
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"noImages": "No images found",
|
||||
"loadingImages": "Loading...",
|
||||
"imageLoadFailed": "Failed to load images",
|
||||
"autoDeployLabel": "Deploy immediately",
|
||||
"lowercaseHint": "Lowercase with hyphens",
|
||||
"imageAlreadyExists": "Image already deployed",
|
||||
"conflictDescription": "A project using this image already exists. You can open the existing project to deploy a new version, or create a separate project.",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"noImages": "Образы не найдены",
|
||||
"loadingImages": "Загрузка...",
|
||||
"imageLoadFailed": "Не удалось загрузить образы",
|
||||
"autoDeployLabel": "Развернуть сразу",
|
||||
"lowercaseHint": "Строчные буквы и дефисы",
|
||||
"imageAlreadyExists": "Образ уже развёрнут",
|
||||
"conflictDescription": "Проект с этим образом уже существует. Откройте существующий проект для развёртывания новой версии или создайте отдельный проект.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
let subdomain = $state('');
|
||||
let envVars = $state('');
|
||||
let enableProxy = $state(true);
|
||||
let autoDeploy = $state(false);
|
||||
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
@@ -142,7 +143,7 @@
|
||||
if (!validateAll()) return;
|
||||
deploying = true;
|
||||
try {
|
||||
const result = await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10), force, enable_proxy: enableProxy });
|
||||
const result = await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10), force, enable_proxy: enableProxy, auto_deploy: autoDeploy });
|
||||
toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
|
||||
// Redirect to the new project page.
|
||||
if (result.project?.id) {
|
||||
@@ -291,9 +292,15 @@
|
||||
<FormField label={$t('quickDeploy.envVars')} name="envVars" type="textarea" bind:value={envVars} placeholder={"KEY=value\nANOTHER_KEY=another_value"} helpText={$t('quickDeploy.envVarsHelp')} />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<ToggleSwitch bind:checked={enableProxy} label={$t('projectDetail.enableProxy')} />
|
||||
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.enableProxy')}</span>
|
||||
<div class="mt-4 flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch bind:checked={enableProxy} label={$t('projectDetail.enableProxy')} />
|
||||
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.enableProxy')}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch bind:checked={autoDeploy} label={$t('quickDeploy.autoDeployLabel')} />
|
||||
<span class="text-sm text-[var(--text-secondary)]">{$t('quickDeploy.autoDeployLabel')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
let formName = $state('');
|
||||
let formImage = $state('');
|
||||
let formRegistry = $state('');
|
||||
let formPort = $state('3000');
|
||||
let formPort = $state('');
|
||||
let formHealthcheck = $state('');
|
||||
let formSubmitting = $state(false);
|
||||
let formError = $state('');
|
||||
@@ -123,7 +123,7 @@
|
||||
formName = '';
|
||||
formImage = '';
|
||||
formRegistry = '';
|
||||
formPort = '3000';
|
||||
formPort = '';
|
||||
formHealthcheck = '';
|
||||
showAddForm = false;
|
||||
await loadProjects();
|
||||
@@ -196,7 +196,7 @@
|
||||
onselect={selectPickedImage}
|
||||
onclose={() => { showImagePicker = false; }}
|
||||
/>
|
||||
<FormField label={$t('projects.port')} name="port" type="number" bind:value={formPort} helpText={$t('projects.portHelpText')} />
|
||||
<FormField label={$t('projects.port')} name="port" type="number" bind:value={formPort} placeholder="3000" helpText={$t('projects.portHelpText')} />
|
||||
<FormField label={$t('projects.healthcheck')} name="healthcheck" bind:value={formHealthcheck} placeholder="/api/health" helpText={$t('projects.healthcheckHelpText')} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
import Breadcrumb from '$lib/components/Breadcrumb.svelte';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import { IconShield } from '$lib/components/icons';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
@@ -69,17 +72,56 @@
|
||||
let editImage = $state('');
|
||||
let editPort = $state('');
|
||||
let editHealthcheck = $state('');
|
||||
let editAccessListId = $state('');
|
||||
let editAccessListId = $state(0);
|
||||
let editAccessListName = $state('');
|
||||
let accessListPickerOpen = $state(false);
|
||||
let accessListPickerItems = $state<EntityPickerItem[]>([]);
|
||||
let loadingAccessLists = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
async function openProjectAccessListPicker() {
|
||||
loadingAccessLists = true;
|
||||
try {
|
||||
const lists = await api.listNpmAccessLists();
|
||||
if (lists.length === 0) { toasts.info($t('settingsNpm.noAccessLists')); return; }
|
||||
accessListPickerItems = lists.map((al): EntityPickerItem => ({
|
||||
value: String(al.id),
|
||||
label: al.name || `Access List #${al.id}`,
|
||||
}));
|
||||
accessListPickerOpen = true;
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsNpm.accessListLoadFailed'));
|
||||
} finally { loadingAccessLists = false; }
|
||||
}
|
||||
|
||||
function handleProjectAccessListSelect(value: string) {
|
||||
editAccessListId = parseInt(value, 10);
|
||||
const item = accessListPickerItems.find((i) => i.value === value);
|
||||
editAccessListName = item?.label ?? '';
|
||||
accessListPickerOpen = false;
|
||||
}
|
||||
|
||||
function clearProjectAccessList() {
|
||||
editAccessListId = 0;
|
||||
editAccessListName = '';
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
if (!project) return;
|
||||
editName = project.name;
|
||||
editImage = project.image;
|
||||
editPort = String(project.port || '');
|
||||
editHealthcheck = project.healthcheck || '';
|
||||
editAccessListId = String(project.npm_access_list_id || '0');
|
||||
editAccessListId = project.npm_access_list_id || 0;
|
||||
editAccessListName = editAccessListId > 0 ? `Access List #${editAccessListId}` : '';
|
||||
editing = true;
|
||||
// Resolve access list name in background.
|
||||
if (editAccessListId > 0) {
|
||||
api.listNpmAccessLists().then(lists => {
|
||||
const match = lists.find(al => al.id === editAccessListId);
|
||||
if (match) editAccessListName = match.name;
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
@@ -91,7 +133,7 @@
|
||||
image: editImage.trim(),
|
||||
port: parseInt(editPort) || 0,
|
||||
healthcheck: editHealthcheck.trim(),
|
||||
npm_access_list_id: parseInt(editAccessListId) || 0,
|
||||
npm_access_list_id: editAccessListId,
|
||||
});
|
||||
toasts.success($t('projectDetail.projectUpdated'));
|
||||
editing = false;
|
||||
@@ -288,7 +330,29 @@
|
||||
<FormField label={$t('projectDetail.imageLabel')} name="editImage" bind:value={editImage} />
|
||||
<FormField label={$t('projectDetail.portLabel')} name="editPort" type="number" bind:value={editPort} />
|
||||
<FormField label={$t('projectDetail.healthcheckLabel')} name="editHealthcheck" bind:value={editHealthcheck} placeholder="/api/health" />
|
||||
<FormField label={$t('projectDetail.accessListId')} name="editAccessListId" type="number" bind:value={editAccessListId} placeholder="0" helpText={$t('projectDetail.accessListIdHelp')} />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsNpm.accessList')}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick={openProjectAccessListPicker} disabled={loadingAccessLists}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50">
|
||||
<IconShield size={14} />
|
||||
{#if loadingAccessLists}
|
||||
{$t('common.loading')}
|
||||
{:else if editAccessListId > 0 && editAccessListName}
|
||||
{editAccessListName}
|
||||
{:else}
|
||||
{$t('settingsNpm.noAccessList')}
|
||||
{/if}
|
||||
</button>
|
||||
{#if editAccessListId > 0}
|
||||
<button type="button" onclick={clearProjectAccessList}
|
||||
class="rounded-lg border border-[var(--border-input)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('projectDetail.accessListIdHelp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2 justify-end">
|
||||
<button
|
||||
@@ -357,28 +421,22 @@
|
||||
|
||||
{#if showAddStage}
|
||||
<div class="mt-3 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 animate-scale-in">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<FormField label={$t('projectDetail.nameLabel')} name="stageName" bind:value={stageName} placeholder="dev" />
|
||||
<FormField label={$t('projectDetail.tagPattern')} name="stagePattern" bind:value={stageTagPattern} placeholder="dev-*" helpText={$t('projectDetail.tagPatternHelp')} />
|
||||
<FormField label={$t('projectDetail.maxInstances')} name="stageMax" type="number" bind:value={stageMaxInstances} />
|
||||
<div class="flex gap-4 items-end">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('projectDetail.autoDeployLabel')}</label>
|
||||
<div class="flex items-center h-[38px]">
|
||||
<ToggleSwitch bind:checked={stageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('projectDetail.enableProxy')}</label>
|
||||
<div class="flex items-center h-[38px]">
|
||||
<ToggleSwitch bind:checked={stageEnableProxy} label={$t('projectDetail.enableProxy')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<FormField label={$t('projectDetail.cpuLimit')} name="stageCpu" type="number" bind:value={stageCpuLimit} placeholder="0" helpText={$t('projectDetail.cpuLimitHelp')} />
|
||||
<FormField label={$t('projectDetail.memoryLimit')} name="stageMem" type="number" bind:value={stageMemoryLimit} placeholder="0" helpText={$t('projectDetail.memoryLimitHelp')} />
|
||||
<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={stageAutoDeploy} 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={stageEnableProxy} label={$t('projectDetail.enableProxy')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button
|
||||
@@ -570,4 +628,13 @@
|
||||
onconfirm={handleDeleteProject}
|
||||
oncancel={() => { showDeleteConfirm = false; }}
|
||||
/>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={accessListPickerOpen}
|
||||
items={accessListPickerItems}
|
||||
current={String(editAccessListId)}
|
||||
title={$t('settingsNpm.selectAccessList')}
|
||||
onselect={handleProjectAccessListSelect}
|
||||
onclose={() => { accessListPickerOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user