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:
2026-04-05 13:07:09 +03:00
parent feec97fe9e
commit a830378c5b
8 changed files with 134 additions and 37 deletions
+28 -9
View File
@@ -81,7 +81,13 @@ func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
info, err := s.docker.InspectImage(ctx, req.Image) info, err := s.docker.InspectImage(ctx, req.Image)
if err != nil { if err != nil {
slog.Error("failed to inspect image", "image", req.Image, "error", err) slog.Error("failed to inspect image", "image", req.Image, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error") errMsg := "Failed to inspect image. "
if strings.Contains(err.Error(), "docker_engine") || strings.Contains(err.Error(), "docker.sock") {
errMsg += "Docker is not available on this machine. Enter port and project name manually."
} else {
errMsg += "Image may not exist or registry requires authentication."
}
respondError(w, http.StatusBadGateway, errMsg)
return return
} }
@@ -103,6 +109,7 @@ type quickDeployRequest struct {
Port int `json:"port"` Port int `json:"port"`
Force bool `json:"force"` // skip duplicate check Force bool `json:"force"` // skip duplicate check
EnableProxy *bool `json:"enable_proxy"` // nil defaults to true EnableProxy *bool `json:"enable_proxy"` // nil defaults to true
AutoDeploy *bool `json:"auto_deploy"` // nil defaults to true (deploy immediately)
} }
// quickDeploy handles POST /api/deploy/quick. // quickDeploy handles POST /api/deploy/quick.
@@ -172,11 +179,15 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
if req.EnableProxy != nil { if req.EnableProxy != nil {
enableProxy = *req.EnableProxy enableProxy = *req.EnableProxy
} }
shouldDeploy := true
if req.AutoDeploy != nil {
shouldDeploy = *req.AutoDeploy
}
stage, err := s.store.CreateStage(store.Stage{ stage, err := s.store.CreateStage(store.Stage{
ProjectID: project.ID, ProjectID: project.ID,
Name: "dev", Name: "dev",
TagPattern: "*", TagPattern: "*",
AutoDeploy: true, AutoDeploy: shouldDeploy,
MaxInstances: 1, MaxInstances: 1,
EnableProxy: enableProxy, EnableProxy: enableProxy,
}) })
@@ -186,12 +197,20 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
return return
} }
// Trigger deploy asynchronously. // Only trigger deploy if auto_deploy is enabled.
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag) var deployID string
if err != nil { if shouldDeploy {
slog.Error("failed to trigger deploy", "error", err) deployID, err = s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag)
respondError(w, http.StatusInternalServerError, "internal server error") if err != nil {
return slog.Error("failed to trigger deploy", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
}
status := "created"
if shouldDeploy {
status = "deploying"
} }
respondJSON(w, http.StatusAccepted, map[string]any{ respondJSON(w, http.StatusAccepted, map[string]any{
@@ -199,7 +218,7 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
"stage": stage, "stage": stage,
"tag": req.Tag, "tag": req.Tag,
"deploy_id": deployID, "deploy_id": deployID,
"status": "deploying", "status": status,
}) })
} }
+1
View File
@@ -239,6 +239,7 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
proxyChanged := existing.Domain != updated.Domain || proxyChanged := existing.Domain != updated.Domain ||
existing.ProxyProvider != updated.ProxyProvider || existing.ProxyProvider != updated.ProxyProvider ||
existing.NpmRemote != updated.NpmRemote || existing.NpmRemote != updated.NpmRemote ||
existing.NpmAccessListID != updated.NpmAccessListID ||
sslChanged sslChanged
if proxyChanged { if proxyChanged {
go s.resyncAllProxies(existing, updated) go s.resyncAllProxies(existing, updated)
+1
View File
@@ -217,6 +217,7 @@ export function quickDeploy(data: {
port?: number; port?: number;
force?: boolean; force?: boolean;
enable_proxy?: boolean; enable_proxy?: boolean;
auto_deploy?: boolean;
}): Promise<{ project: Project; status: string }> { }): Promise<{ project: Project; status: string }> {
return post<{ project: Project; status: string }>('/api/deploy/quick', data); return post<{ project: Project; status: string }>('/api/deploy/quick', data);
} }
+1
View File
@@ -233,6 +233,7 @@
"noImages": "No images found", "noImages": "No images found",
"loadingImages": "Loading...", "loadingImages": "Loading...",
"imageLoadFailed": "Failed to load images", "imageLoadFailed": "Failed to load images",
"autoDeployLabel": "Deploy immediately",
"lowercaseHint": "Lowercase with hyphens", "lowercaseHint": "Lowercase with hyphens",
"imageAlreadyExists": "Image already deployed", "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.", "conflictDescription": "A project using this image already exists. You can open the existing project to deploy a new version, or create a separate project.",
+1
View File
@@ -233,6 +233,7 @@
"noImages": "Образы не найдены", "noImages": "Образы не найдены",
"loadingImages": "Загрузка...", "loadingImages": "Загрузка...",
"imageLoadFailed": "Не удалось загрузить образы", "imageLoadFailed": "Не удалось загрузить образы",
"autoDeployLabel": "Развернуть сразу",
"lowercaseHint": "Строчные буквы и дефисы", "lowercaseHint": "Строчные буквы и дефисы",
"imageAlreadyExists": "Образ уже развёрнут", "imageAlreadyExists": "Образ уже развёрнут",
"conflictDescription": "Проект с этим образом уже существует. Откройте существующий проект для развёртывания новой версии или создайте отдельный проект.", "conflictDescription": "Проект с этим образом уже существует. Откройте существующий проект для развёртывания новой версии или создайте отдельный проект.",
+11 -4
View File
@@ -22,6 +22,7 @@
let subdomain = $state(''); let subdomain = $state('');
let envVars = $state(''); let envVars = $state('');
let enableProxy = $state(true); let enableProxy = $state(true);
let autoDeploy = $state(false);
let errors = $state<Record<string, string>>({}); let errors = $state<Record<string, string>>({});
@@ -142,7 +143,7 @@
if (!validateAll()) return; if (!validateAll()) return;
deploying = true; deploying = true;
try { 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 })); toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
// Redirect to the new project page. // Redirect to the new project page.
if (result.project?.id) { 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')} /> <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>
<div class="mt-4 flex items-center gap-3"> <div class="mt-4 flex items-center gap-6">
<ToggleSwitch bind:checked={enableProxy} label={$t('projectDetail.enableProxy')} /> <div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.enableProxy')}</span> <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>
</div> </div>
+3 -3
View File
@@ -28,7 +28,7 @@
let formName = $state(''); let formName = $state('');
let formImage = $state(''); let formImage = $state('');
let formRegistry = $state(''); let formRegistry = $state('');
let formPort = $state('3000'); let formPort = $state('');
let formHealthcheck = $state(''); let formHealthcheck = $state('');
let formSubmitting = $state(false); let formSubmitting = $state(false);
let formError = $state(''); let formError = $state('');
@@ -123,7 +123,7 @@
formName = ''; formName = '';
formImage = ''; formImage = '';
formRegistry = ''; formRegistry = '';
formPort = '3000'; formPort = '';
formHealthcheck = ''; formHealthcheck = '';
showAddForm = false; showAddForm = false;
await loadProjects(); await loadProjects();
@@ -196,7 +196,7 @@
onselect={selectPickedImage} onselect={selectPickedImage}
onclose={() => { showImagePicker = false; }} 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')} /> <FormField label={$t('projects.healthcheck')} name="healthcheck" bind:value={formHealthcheck} placeholder="/api/health" helpText={$t('projects.healthcheckHelpText')} />
</div> </div>
+88 -21
View File
@@ -11,6 +11,9 @@
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';
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 { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
@@ -69,17 +72,56 @@
let editImage = $state(''); let editImage = $state('');
let editPort = $state(''); let editPort = $state('');
let editHealthcheck = $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); 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() { function startEditing() {
if (!project) return; if (!project) return;
editName = project.name; editName = project.name;
editImage = project.image; editImage = project.image;
editPort = String(project.port || ''); editPort = String(project.port || '');
editHealthcheck = project.healthcheck || ''; 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; 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() { async function saveProject() {
@@ -91,7 +133,7 @@
image: editImage.trim(), image: editImage.trim(),
port: parseInt(editPort) || 0, port: parseInt(editPort) || 0,
healthcheck: editHealthcheck.trim(), healthcheck: editHealthcheck.trim(),
npm_access_list_id: parseInt(editAccessListId) || 0, npm_access_list_id: editAccessListId,
}); });
toasts.success($t('projectDetail.projectUpdated')); toasts.success($t('projectDetail.projectUpdated'));
editing = false; editing = false;
@@ -288,7 +330,29 @@
<FormField label={$t('projectDetail.imageLabel')} name="editImage" bind:value={editImage} /> <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.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.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>
<div class="mt-4 flex items-center gap-2 justify-end"> <div class="mt-4 flex items-center gap-2 justify-end">
<button <button
@@ -357,28 +421,22 @@
{#if showAddStage} {#if showAddStage}
<div class="mt-3 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 animate-scale-in"> <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.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.tagPattern')} name="stagePattern" bind:value={stageTagPattern} placeholder="dev-*" helpText={$t('projectDetail.tagPatternHelp')} />
<FormField label={$t('projectDetail.maxInstances')} name="stageMax" type="number" bind:value={stageMaxInstances} /> <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.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')} /> <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>
<div class="mt-3 flex justify-end"> <div class="mt-3 flex justify-end">
<button <button
@@ -570,4 +628,13 @@
onconfirm={handleDeleteProject} onconfirm={handleDeleteProject}
oncancel={() => { showDeleteConfirm = false; }} oncancel={() => { showDeleteConfirm = false; }}
/> />
<EntityPicker
bind:open={accessListPickerOpen}
items={accessListPickerItems}
current={String(editAccessListId)}
title={$t('settingsNpm.selectAccessList')}
onselect={handleProjectAccessListSelect}
onclose={() => { accessListPickerOpen = false; }}
/>
{/if} {/if}