diff --git a/internal/api/stages.go b/internal/api/stages.go index 489d310..fa89c99 100644 --- a/internal/api/stages.go +++ b/internal/api/stages.go @@ -13,14 +13,16 @@ import ( // stageRequest is the expected JSON body for creating/updating a stage. type stageRequest struct { - Name string `json:"name"` - TagPattern string `json:"tag_pattern"` - AutoDeploy *bool `json:"auto_deploy"` - MaxInstances *int `json:"max_instances"` - Confirm *bool `json:"confirm"` - PromoteFrom string `json:"promote_from"` - Subdomain string `json:"subdomain"` - NotificationURL string `json:"notification_url"` + Name string `json:"name"` + TagPattern string `json:"tag_pattern"` + AutoDeploy *bool `json:"auto_deploy"` + MaxInstances *int `json:"max_instances"` + Confirm *bool `json:"confirm"` + PromoteFrom string `json:"promote_from"` + Subdomain string `json:"subdomain"` + NotificationURL string `json:"notification_url"` + CpuLimit *float64 `json:"cpu_limit"` + MemoryLimit *int `json:"memory_limit"` } // createStage handles POST /api/projects/{id}/stages. @@ -64,6 +66,15 @@ func (s *Server) createStage(w http.ResponseWriter, r *http.Request) { confirm = *req.Confirm } + var cpuLimit float64 + if req.CpuLimit != nil { + cpuLimit = *req.CpuLimit + } + var memoryLimit int + if req.MemoryLimit != nil { + memoryLimit = *req.MemoryLimit + } + stage, err := s.store.CreateStage(store.Stage{ ProjectID: projectID, Name: req.Name, @@ -74,6 +85,8 @@ func (s *Server) createStage(w http.ResponseWriter, r *http.Request) { PromoteFrom: req.PromoteFrom, Subdomain: req.Subdomain, NotificationURL: req.NotificationURL, + CpuLimit: cpuLimit, + MemoryLimit: memoryLimit, }) if err != nil { slog.Error("failed to create stage", "error", err) @@ -131,6 +144,12 @@ func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) { updated.PromoteFrom = req.PromoteFrom updated.Subdomain = req.Subdomain updated.NotificationURL = req.NotificationURL + if req.CpuLimit != nil { + updated.CpuLimit = *req.CpuLimit + } + if req.MemoryLimit != nil { + updated.MemoryLimit = *req.MemoryLimit + } if err := s.store.UpdateStage(updated); err != nil { slog.Error("failed to update stage", "error", err) diff --git a/internal/docker/container.go b/internal/docker/container.go index 352a781..bec6aeb 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -49,6 +49,12 @@ type ContainerConfig struct { // Mounts is a list of bind mounts to attach to the container. Mounts []mount.Mount + + // CpuLimit is the CPU limit in cores (e.g., 0.5, 1, 2). 0 = unlimited. + CpuLimit float64 + + // MemoryLimit is the memory limit in megabytes. 0 = unlimited. + MemoryLimit int } // sanitizeTag replaces characters that are invalid in Docker container names @@ -101,6 +107,7 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri PortBindings: portBindings, RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyDisabled}, Mounts: cfg.Mounts, + Resources: containerResources(cfg.CpuLimit, cfg.MemoryLimit), } // Attach to network at creation time if specified. @@ -128,6 +135,19 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri return resp.ID, nil } +// containerResources builds Docker resource constraints from CPU cores and memory MB. +func containerResources(cpuLimit float64, memoryLimitMB int) container.Resources { + r := container.Resources{} + if cpuLimit > 0 { + // NanoCPUs is in units of 1e-9 CPUs. 1 core = 1e9 nanoCPUs. + r.NanoCPUs = int64(cpuLimit * 1e9) + } + if memoryLimitMB > 0 { + r.Memory = int64(memoryLimitMB) * 1024 * 1024 + } + return r +} + // StartContainer starts a stopped container. func (c *Client) StartContainer(ctx context.Context, containerID string) error { if _, err := c.api.ContainerStart(ctx, containerID, client.ContainerStartOptions{}); err != nil { diff --git a/internal/proxy/npm_provider.go b/internal/proxy/npm_provider.go index d494103..938010d 100644 --- a/internal/proxy/npm_provider.go +++ b/internal/proxy/npm_provider.go @@ -58,6 +58,7 @@ func (p *NpmProvider) ConfigureRoute(ctx context.Context, domain, targetHost str BlockExploits: true, AllowWebsocket: true, HTTP2Support: true, + AccessListID: opts.AccessListID, Meta: npm.Meta{}, Locations: []any{}, } diff --git a/internal/proxy/provider.go b/internal/proxy/provider.go index 1c5a50e..cd2705f 100644 --- a/internal/proxy/provider.go +++ b/internal/proxy/provider.go @@ -5,6 +5,7 @@ import "context" // RouteOptions holds optional configuration for a proxy route. type RouteOptions struct { SSLCertificateID int + AccessListID int // NPM access list ID for authentication, 0 = public ForwardScheme string // "http" or "https", defaults to "http" } diff --git a/internal/store/models.go b/internal/store/models.go index ce30427..6d95add 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -8,10 +8,11 @@ type Project struct { Image string `json:"image"` Port int `json:"port"` Healthcheck string `json:"healthcheck"` - Env string `json:"env"` // JSON-encoded map - Volumes string `json:"volumes"` // JSON-encoded map - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + Env string `json:"env"` // JSON-encoded map + Volumes string `json:"volumes"` // JSON-encoded map + NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // Stage represents a deployment stage within a project (e.g. dev, rel, prod). @@ -23,10 +24,12 @@ type Stage struct { AutoDeploy bool `json:"auto_deploy"` MaxInstances int `json:"max_instances"` Confirm bool `json:"confirm"` - EnableProxy bool `json:"enable_proxy"` - PromoteFrom string `json:"promote_from"` - Subdomain string `json:"subdomain"` - NotificationURL string `json:"notification_url"` + EnableProxy bool `json:"enable_proxy"` + PromoteFrom string `json:"promote_from"` + Subdomain string `json:"subdomain"` + NotificationURL string `json:"notification_url"` + CpuLimit float64 `json:"cpu_limit"` // CPU cores (e.g., 0.5, 1, 2), 0 = unlimited + MemoryLimit int `json:"memory_limit"` // megabytes, 0 = unlimited CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -64,6 +67,7 @@ type Settings struct { CloudflareAPIToken string `json:"cloudflare_api_token"` CloudflareZoneID string `json:"cloudflare_zone_id"` NpmRemote bool `json:"npm_remote"` + NpmAccessListID int `json:"npm_access_list_id"` ProxyProvider string `json:"proxy_provider"` TraefikEntrypoint string `json:"traefik_entrypoint"` TraefikCertResolver string `json:"traefik_cert_resolver"` diff --git a/internal/store/stages.go b/internal/store/stages.go index 1445e0c..7c01098 100644 --- a/internal/store/stages.go +++ b/internal/store/stages.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" ) -const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, created_at, updated_at` +const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, cpu_limit, memory_limit, created_at, updated_at` // CreateStage inserts a new stage for a project. func (s *Store) CreateStage(st Stage) (Stage, error) { @@ -17,9 +17,10 @@ func (s *Store) CreateStage(st Stage) (Stage, error) { st.UpdatedAt = st.CreatedAt _, err := s.db.Exec( - `INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances, - BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL, st.CreatedAt, st.UpdatedAt, + BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL, + st.CpuLimit, st.MemoryLimit, st.CreatedAt, st.UpdatedAt, ) if err != nil { return Stage{}, fmt.Errorf("insert stage: %w", err) @@ -55,7 +56,8 @@ func (s *Store) GetStageByID(id string) (Stage, error) { err := s.db.QueryRow( `SELECT `+stageColumns+` FROM stages WHERE id = ?`, id, ).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances, - &confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL, &st.CreatedAt, &st.UpdatedAt) + &confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL, + &st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound) } @@ -72,10 +74,11 @@ func (s *Store) GetStageByID(id string) (Stage, error) { func (s *Store) UpdateStage(st Stage) error { st.UpdatedAt = Now() result, err := s.db.Exec( - `UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, enable_proxy=?, promote_from=?, subdomain=?, notification_url=?, updated_at=? + `UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, enable_proxy=?, promote_from=?, subdomain=?, notification_url=?, cpu_limit=?, memory_limit=?, updated_at=? WHERE id=?`, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances, - BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL, st.UpdatedAt, st.ID, + BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL, + st.CpuLimit, st.MemoryLimit, st.UpdatedAt, st.ID, ) if err != nil { return fmt.Errorf("update stage: %w", err) @@ -113,7 +116,8 @@ func scanStage(rows *sql.Rows) (Stage, error) { var st Stage var autoDeploy, confirm, enableProxy int err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances, - &confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL, &st.CreatedAt, &st.UpdatedAt) + &confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL, + &st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt) if err != nil { return Stage{}, fmt.Errorf("scan stage: %w", err) } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4a613a2..f97304f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -10,6 +10,7 @@ import type { InspectResult, Instance, NpmCertificate, + NpmAccessList, ProxyRoute, Project, ProjectDetail, @@ -282,6 +283,10 @@ export function listNpmCertificates(): Promise { return get('/api/settings/npm-certificates'); } +export function listNpmAccessLists(): Promise { + return get('/api/settings/npm-access-lists'); +} + // ── DNS ──────────────────────────────────────────────────────────── export function testDnsConnection(provider: string, token: string, zoneId: string): Promise<{ success: boolean; error?: string }> { diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 9add980..4184aac 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -103,6 +103,12 @@ "maxInstances": "Max Instances", "autoDeployLabel": "Auto Deploy", "enableProxy": "Enable Proxy", + "accessListId": "NPM Access List ID", + "accessListIdHelp": "Per-project override. 0 = use global default from NPM settings.", + "cpuLimit": "CPU Limit (cores)", + "cpuLimitHelp": "e.g., 0.5, 1, 2. Leave 0 for unlimited", + "memoryLimit": "Memory Limit (MB)", + "memoryLimitHelp": "e.g., 256, 512, 1024. Leave 0 for unlimited", "npmProxy": "NPM Proxy", "creating": "Creating...", "createStage": "Create Stage", @@ -376,7 +382,13 @@ "saveFailedConnection": "Cannot save \u2014 connection test failed", "remoteMode": "Remote NPM", "remoteModeHelp": "Enable when NPM runs on a different machine than Docker. Forwards to Server IP with published host ports.", - "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", + "accessListHelp": "NPM access list for HTTP authentication on proxy hosts. Can be overridden per project.", + "noAccessList": "None (public)", + "selectAccessList": "Select an access list", + "noAccessLists": "No access lists found in NPM", + "accessListLoadFailed": "Failed to load access lists" }, "settingsCredentials": { "title": "Credentials", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 19e2455..14cdb47 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -103,6 +103,12 @@ "maxInstances": "Макс. экземпляров", "autoDeployLabel": "Авто-деплой", "enableProxy": "Включить прокси", + "accessListId": "ID списка доступа NPM", + "accessListIdHelp": "Переопределение для проекта. 0 = использовать глобальное из настроек NPM.", + "cpuLimit": "Лимит CPU (ядра)", + "cpuLimitHelp": "напр., 0.5, 1, 2. Оставьте 0 для без ограничений", + "memoryLimit": "Лимит памяти (МБ)", + "memoryLimitHelp": "напр., 256, 512, 1024. Оставьте 0 для без ограничений", "npmProxy": "NPM прокси", "creating": "Создание...", "createStage": "Создать стадию", @@ -376,7 +382,13 @@ "saveFailedConnection": "Невозможно сохранить — проверка соединения не пройдена", "remoteMode": "Удалённый NPM", "remoteModeHelp": "Включите, если NPM работает на другой машине. Перенаправление на IP сервера с опубликованными портами.", - "remoteModeWarning": "Требуется IP сервера в общих настройках. Порты автоматически привязываются к случайным портам хоста." + "remoteModeWarning": "Требуется IP сервера в общих настройках. Порты автоматически привязываются к случайным портам хоста.", + "accessList": "Список доступа по умолчанию", + "accessListHelp": "Список доступа NPM для HTTP-аутентификации на прокси-хостах. Можно переопределить для каждого проекта.", + "noAccessList": "Нет (публичный)", + "selectAccessList": "Выберите список доступа", + "noAccessLists": "Списки доступа в NPM не найдены", + "accessListLoadFailed": "Не удалось загрузить списки доступа" }, "settingsCredentials": { "title": "Учётные данные", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 528392e..89b967e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -9,6 +9,7 @@ export interface Project { healthcheck: string; env: string; volumes: string; + npm_access_list_id: number; created_at: string; updated_at: string; } @@ -24,6 +25,8 @@ export interface Stage { enable_proxy: boolean; promote_from: string; subdomain: string; + cpu_limit: number; + memory_limit: number; created_at: string; updated_at: string; } @@ -107,6 +110,7 @@ export interface Settings { /** Sent on PUT to update the password; never returned by GET. */ npm_password?: string; npm_remote: boolean; + npm_access_list_id: number; polling_interval: string; base_volume_path: string; ssl_certificate_id: number; @@ -258,6 +262,12 @@ export interface ProxyHealth { error?: string; } +/** An NPM access list for proxy authentication. */ +export interface NpmAccessList { + id: number; + name: string; +} + /** A proxy route managed by a deployed instance. */ export interface ProxyRoute { instance_id: string; diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte index 78e2f9e..d7206ac 100644 --- a/web/src/routes/projects/[id]/+page.svelte +++ b/web/src/routes/projects/[id]/+page.svelte @@ -35,6 +35,8 @@ let stageAutoDeploy = $state(true); let stageEnableProxy = $state(true); let stageMaxInstances = $state('1'); + let stageCpuLimit = $state(''); + let stageMemoryLimit = $state(''); let addingStage = $state(false); async function handleAddStage() { @@ -47,9 +49,11 @@ auto_deploy: stageAutoDeploy, enable_proxy: stageEnableProxy, max_instances: parseInt(stageMaxInstances) || 1, + cpu_limit: parseFloat(stageCpuLimit) || 0, + memory_limit: parseInt(stageMemoryLimit) || 0, }); toasts.success($t('projectDetail.stageCreated', { name: stageName })); - stageName = ''; stageTagPattern = '*'; stageAutoDeploy = true; stageEnableProxy = true; stageMaxInstances = '1'; + stageName = ''; stageTagPattern = '*'; stageAutoDeploy = true; stageEnableProxy = true; stageMaxInstances = '1'; stageCpuLimit = ''; stageMemoryLimit = ''; showAddStage = false; await loadProject(); } catch (e) { @@ -65,6 +69,7 @@ let editImage = $state(''); let editPort = $state(''); let editHealthcheck = $state(''); + let editAccessListId = $state(''); let saving = $state(false); function startEditing() { @@ -73,6 +78,7 @@ editImage = project.image; editPort = String(project.port || ''); editHealthcheck = project.healthcheck || ''; + editAccessListId = String(project.npm_access_list_id || '0'); editing = true; } @@ -85,6 +91,7 @@ image: editImage.trim(), port: parseInt(editPort) || 0, healthcheck: editHealthcheck.trim(), + npm_access_list_id: parseInt(editAccessListId) || 0, }); toasts.success($t('projectDetail.projectUpdated')); editing = false; @@ -281,6 +288,7 @@ +
+ + +
+
+
+

{$t('settingsNpm.accessList')}

+

{$t('settingsNpm.accessListHelp')}

+
+ + {#if accessListId > 0} + + {/if} +
+
+
+
{/if} @@ -266,3 +334,12 @@ onselect={handleCertSelect} onclose={() => { certPickerOpen = false; }} /> + + { accessListPickerOpen = false; }} +/>