diff --git a/internal/api/deploys.go b/internal/api/deploys.go index b1f5678..ca38144 100644 --- a/internal/api/deploys.go +++ b/internal/api/deploys.go @@ -81,7 +81,13 @@ func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) { info, err := s.docker.InspectImage(ctx, req.Image) if err != nil { 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 } @@ -103,6 +109,7 @@ type quickDeployRequest struct { Port int `json:"port"` Force bool `json:"force"` // skip duplicate check 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. @@ -172,11 +179,15 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) { if req.EnableProxy != nil { enableProxy = *req.EnableProxy } + shouldDeploy := true + if req.AutoDeploy != nil { + shouldDeploy = *req.AutoDeploy + } stage, err := s.store.CreateStage(store.Stage{ ProjectID: project.ID, Name: "dev", TagPattern: "*", - AutoDeploy: true, + AutoDeploy: shouldDeploy, MaxInstances: 1, EnableProxy: enableProxy, }) @@ -186,12 +197,20 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) { return } - // Trigger deploy asynchronously. - deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag) - if err != nil { - slog.Error("failed to trigger deploy", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return + // Only trigger deploy if auto_deploy is enabled. + var deployID string + if shouldDeploy { + deployID, err = s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag) + if err != nil { + 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{ @@ -199,7 +218,7 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) { "stage": stage, "tag": req.Tag, "deploy_id": deployID, - "status": "deploying", + "status": status, }) } diff --git a/internal/api/settings.go b/internal/api/settings.go index 2ea8464..5d220d0 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -239,6 +239,7 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { proxyChanged := existing.Domain != updated.Domain || existing.ProxyProvider != updated.ProxyProvider || existing.NpmRemote != updated.NpmRemote || + existing.NpmAccessListID != updated.NpmAccessListID || sslChanged if proxyChanged { go s.resyncAllProxies(existing, updated) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f97304f..5fee1a5 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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); } diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 4184aac..df539b2 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -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.", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 14cdb47..2cb0565 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -233,6 +233,7 @@ "noImages": "Образы не найдены", "loadingImages": "Загрузка...", "imageLoadFailed": "Не удалось загрузить образы", + "autoDeployLabel": "Развернуть сразу", "lowercaseHint": "Строчные буквы и дефисы", "imageAlreadyExists": "Образ уже развёрнут", "conflictDescription": "Проект с этим образом уже существует. Откройте существующий проект для развёртывания новой версии или создайте отдельный проект.", diff --git a/web/src/routes/deploy/+page.svelte b/web/src/routes/deploy/+page.svelte index d589d9a..01517a7 100644 --- a/web/src/routes/deploy/+page.svelte +++ b/web/src/routes/deploy/+page.svelte @@ -22,6 +22,7 @@ let subdomain = $state(''); let envVars = $state(''); let enableProxy = $state(true); + let autoDeploy = $state(false); let errors = $state>({}); @@ -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 @@ -
- - {$t('projectDetail.enableProxy')} +
+
+ + {$t('projectDetail.enableProxy')} +
+
+ + {$t('quickDeploy.autoDeployLabel')} +
diff --git a/web/src/routes/projects/+page.svelte b/web/src/routes/projects/+page.svelte index e8900d2..ac7b71a 100644 --- a/web/src/routes/projects/+page.svelte +++ b/web/src/routes/projects/+page.svelte @@ -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; }} /> - + diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte index d7206ac..0a43583 100644 --- a/web/src/routes/projects/[id]/+page.svelte +++ b/web/src/routes/projects/[id]/+page.svelte @@ -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([]); + 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 @@ - +
+ +
+ + {#if editAccessListId > 0} + + {/if} +
+

{$t('projectDetail.accessListIdHelp')}

+