diff --git a/internal/api/auth.go b/internal/api/auth.go index 725a367..cc363fc 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -30,6 +30,16 @@ func (s *Server) rateLimitedLogin(rl *rateLimiter) http.HandlerFunc { } } +// authMode handles GET /api/auth/mode — public endpoint returning the auth mode. +func (s *Server) authMode(w http.ResponseWriter, r *http.Request) { + as, err := s.store.GetAuthSettings() + if err != nil { + respondJSON(w, http.StatusOK, map[string]string{"auth_mode": "local"}) + return + } + respondJSON(w, http.StatusOK, map[string]string{"auth_mode": as.AuthMode}) +} + // login handles POST /api/auth/login. func (s *Server) login(w http.ResponseWriter, r *http.Request) { var req auth.LoginRequest diff --git a/internal/api/deploys.go b/internal/api/deploys.go index 86cbd9d..1eda118 100644 --- a/internal/api/deploys.go +++ b/internal/api/deploys.go @@ -116,11 +116,20 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusBadRequest, "image is required") return } + + // Split tag from image if the image URL contains one (e.g., "registry/app:v1"). if req.Tag == "" { - req.Tag = "latest" + imageRef, tag := splitImageTag(req.Image) + if tag != "" { + req.Image = imageRef + req.Tag = tag + } else { + req.Tag = "latest" + } } + if req.Name == "" { - // Derive name from image. + // Derive name from image (without tag). parts := strings.Split(req.Image, "/") req.Name = parts[len(parts)-1] } diff --git a/internal/api/proxies.go b/internal/api/proxies.go new file mode 100644 index 0000000..6ac9515 --- /dev/null +++ b/internal/api/proxies.go @@ -0,0 +1,26 @@ +package api + +import ( + "log/slog" + "net/http" +) + +// listProxyRoutes handles GET /api/proxies. +// Returns all proxy-enabled instances with project and stage names. +func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) { + settings, err := s.store.GetSettings() + if err != nil { + slog.Error("failed to get settings for proxy routes", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + + routes, err := s.store.ListProxyRoutes(settings.Domain) + if err != nil { + slog.Error("failed to list proxy routes", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + + respondJSON(w, http.StatusOK, routes) +} diff --git a/internal/api/router.go b/internal/api/router.go index ae1671b..710fa7e 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -169,6 +169,7 @@ func (s *Server) Router() chi.Router { r.Use(limitBody) // Public auth endpoints (no auth required). + r.Get("/auth/mode", s.authMode) r.Post("/auth/login", s.rateLimitedLogin(loginLimiter)) r.Get("/auth/oidc/login", s.oidcLogin) r.Get("/auth/oidc/callback", s.oidcCallback) @@ -185,6 +186,7 @@ func (s *Server) Router() chi.Router { r.Get("/health", s.getHealth) r.Get("/auth/me", s.currentUser) r.Post("/auth/logout", s.logout) + r.Get("/proxies", s.listProxyRoutes) r.Get("/projects", s.listProjects) r.Route("/projects/{id}", func(r chi.Router) { r.Get("/", s.getProject) @@ -290,6 +292,9 @@ func (s *Server) Router() chi.Router { r.Get("/settings/webhook-url", s.getWebhookURL) r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret) + // NPM connection test. + r.Post("/settings/npm/test", s.testNpmConnection) + // DNS management endpoints. r.Post("/settings/dns/test", s.testDNSConnection) r.Post("/settings/dns/zones", s.listDNSZones) diff --git a/internal/api/settings.go b/internal/api/settings.go index 8804746..344089d 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -258,8 +258,15 @@ func (s *Server) getWebhookURL(w http.ResponseWriter, r *http.Request) { } webhookURL := "" - if settings.WebhookSecret != "" && settings.Domain != "" { - webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, settings.WebhookSecret) + if settings.WebhookSecret != "" { + host := settings.Domain + scheme := "https" + if host == "" { + // Fall back to request host for dev/local setups. + host = r.Host + scheme = "http" + } + webhookURL = fmt.Sprintf("%s://%s/api/webhook/%s", scheme, host, settings.WebhookSecret) } respondJSON(w, http.StatusOK, map[string]string{ @@ -281,10 +288,13 @@ func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request) return } - webhookURL := "" - if settings.Domain != "" { - webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, secret) + host := settings.Domain + scheme := "https" + if host == "" { + host = r.Host + scheme = "http" } + webhookURL := fmt.Sprintf("%s://%s/api/webhook/%s", scheme, host, secret) respondJSON(w, http.StatusOK, map[string]string{ "webhook_url": webhookURL, @@ -504,6 +514,75 @@ type dnsTestRequest struct { ZoneID string `json:"zone_id"` } +// testNpmConnection handles POST /api/settings/npm/test. +// Tests connectivity and authentication to the NPM API. +func (s *Server) testNpmConnection(w http.ResponseWriter, r *http.Request) { + var req struct { + URL string `json:"npm_url"` + Email string `json:"npm_email"` + Password string `json:"npm_password"` + } + if !decodeJSON(w, r, &req) { + return + } + + // Use provided values, fall back to stored settings. + settings, err := s.store.GetSettings() + if err != nil { + slog.Error("failed to get settings", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + + npmURL := req.URL + if npmURL == "" { + npmURL = settings.NpmURL + } + if npmURL == "" { + respondError(w, http.StatusBadRequest, "NPM URL is required") + return + } + + email := req.Email + if email == "" { + email = settings.NpmEmail + } + + password := req.Password + if password == "" && settings.NpmPassword != "" { + decrypted, err := crypto.Decrypt(s.encKey, settings.NpmPassword) + if err != nil { + respondError(w, http.StatusBadRequest, "failed to decrypt stored NPM password") + return + } + password = decrypted + } + + if email == "" || password == "" { + respondError(w, http.StatusBadRequest, "NPM email and password are required") + return + } + + // Test connectivity. + client := npm.New(npmURL) + ctx := r.Context() + + if err := client.Ping(ctx); err != nil { + slog.Warn("npm test: ping failed", "url", npmURL, "error", err) + respondError(w, http.StatusBadGateway, "Cannot reach NPM at "+npmURL) + return + } + + // Test authentication. + if err := client.Authenticate(ctx, email, password); err != nil { + slog.Warn("npm test: auth failed", "url", npmURL, "error", err) + respondError(w, http.StatusBadGateway, "NPM authentication failed — check email and password") + return + } + + respondJSON(w, http.StatusOK, map[string]string{"status": "connected"}) +} + // testDNSConnection handles POST /api/settings/dns/test. func (s *Server) testDNSConnection(w http.ResponseWriter, r *http.Request) { var req dnsTestRequest diff --git a/internal/npm/client.go b/internal/npm/client.go index 60ef7f3..18cb03a 100644 --- a/internal/npm/client.go +++ b/internal/npm/client.go @@ -26,11 +26,15 @@ type Client struct { password string } -// New creates an NPM client targeting the given base URL (e.g. "http://npm:81/api"). -// The returned client is not yet authenticated — call Authenticate before other methods. +// New creates an NPM client targeting the given base URL (e.g. "http://npm:81"). +// Automatically appends "/api" if not already present. func New(baseURL string) *Client { + u := strings.TrimRight(baseURL, "/") + if u != "" && !strings.HasSuffix(u, "/api") { + u += "/api" + } return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), + baseURL: u, httpClient: &http.Client{ Timeout: 30 * time.Second, }, diff --git a/internal/store/instances.go b/internal/store/instances.go index ebaf84b..9025288 100644 --- a/internal/store/instances.go +++ b/internal/store/instances.go @@ -119,6 +119,59 @@ func (s *Store) ListAllInstances() ([]Instance, error) { return instances, rows.Err() } +// ProxyRoute represents a proxy-enabled instance with project and stage names. +type ProxyRoute struct { + InstanceID string `json:"instance_id"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + StageID string `json:"stage_id"` + StageName string `json:"stage_name"` + ImageTag string `json:"image_tag"` + Subdomain string `json:"subdomain"` + Domain string `json:"domain"` + ContainerID string `json:"container_id"` + Port int `json:"port"` + ProxyRouteID string `json:"proxy_route_id"` + NpmProxyID int `json:"npm_proxy_id"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} + +// ListProxyRoutes returns all instances that have a proxy configured, joined with project/stage names. +func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) { + rows, err := s.db.Query(` + SELECT i.id, i.project_id, p.name, i.stage_id, s.name, + i.image_tag, i.subdomain, i.container_id, i.port, + i.proxy_route_id, i.npm_proxy_id, i.status, i.created_at + FROM instances i + JOIN projects p ON p.id = i.project_id + JOIN stages s ON s.id = i.stage_id + WHERE i.subdomain != '' AND (i.proxy_route_id != '' OR i.npm_proxy_id > 0) + ORDER BY p.name, s.name, i.created_at DESC`, + ) + if err != nil { + return nil, fmt.Errorf("query proxy routes: %w", err) + } + defer rows.Close() + + routes := []ProxyRoute{} + for rows.Next() { + var r ProxyRoute + if err := rows.Scan( + &r.InstanceID, &r.ProjectID, &r.ProjectName, &r.StageID, &r.StageName, + &r.ImageTag, &r.Subdomain, &r.ContainerID, &r.Port, + &r.ProxyRouteID, &r.NpmProxyID, &r.Status, &r.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scan proxy route: %w", err) + } + if domain != "" && r.Subdomain != "" { + r.Domain = r.Subdomain + "." + domain + } + routes = append(routes, r) + } + return routes, rows.Err() +} + // UpdateInstance updates an existing instance's mutable fields. func (s *Store) UpdateInstance(inst Instance) error { inst.UpdatedAt = Now() diff --git a/server.exe~ b/server.exe~ new file mode 100644 index 0000000..0e47f96 Binary files /dev/null and b/server.exe~ differ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2b86f76..1fc1955 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -4,11 +4,13 @@ import type { Deploy, DeployLog, DockerHealth, + ProxyHealth, EventLogEntry, EventLogStats, InspectResult, Instance, NpmCertificate, + ProxyRoute, Project, ProjectDetail, Registry, @@ -265,6 +267,16 @@ export function regenerateWebhookUrl(): Promise<{ webhook_url: string }> { return post<{ webhook_url: string }>('/api/settings/webhook-url/regenerate'); } +// ── Proxy Routes ─────────────────────────────────────────────────── + +export function listProxyRoutes(): Promise { + return get('/api/proxies'); +} + +export function testNpmConnection(data: { npm_url?: string; npm_email?: string; npm_password?: string }): Promise<{ status: string }> { + return post<{ status: string }>('/api/settings/npm/test', data); +} + export function listNpmCertificates(): Promise { return get('/api/settings/npm-certificates'); } @@ -315,8 +327,8 @@ export function backupDownloadUrl(id: string): string { // ── Health ────────────────────────────────────────────────────────── -export function getHealth(): Promise<{ docker: DockerHealth }> { - return get<{ docker: DockerHealth }>('/api/health'); +export function getHealth(): Promise<{ docker: DockerHealth; proxy?: ProxyHealth }> { + return get<{ docker: DockerHealth; proxy?: ProxyHealth }>('/api/health'); } // ── Auth ───────────────────────────────────────────────────────────── diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index dd1d5d3..4f17689 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -281,7 +281,7 @@ "varTag": "Image tag", "varPort": "Container port", "pollingInterval": "Polling Interval (seconds)", - "pollingIntervalHelp": "How often to check registries for new tags (10-86400)", + "pollingIntervalHelp": "How often to check registries for new tags (60-86400)", "notificationUrl": "Notification URL", "notificationUrlHelp": "Webhook URL for deploy notifications", "saveSettings": "Save Settings", @@ -367,6 +367,13 @@ "healthConnected": "Connected", "healthUnreachable": "Unreachable" }, + "settingsNpm": { + "testConnection": "Test Connection", + "testing": "Testing...", + "testSuccess": "NPM connection successful", + "testFailed": "NPM connection failed", + "saveFailedConnection": "Cannot save \u2014 connection test failed" + }, "settingsCredentials": { "title": "Credentials", "description": "Manage credentials for Nginx Proxy Manager and registry tokens. All values are encrypted at rest.", @@ -544,7 +551,7 @@ "invalidIp": "Invalid IP format", "invalidEmail": "Invalid email format", "invalidPort": "Port must be between 1 and 65535", - "invalidPollingInterval": "Polling interval must be between 10 and 86400 seconds", + "invalidPollingInterval": "Polling interval must be between 60 and 86400 seconds", "invalidProjectName": "Only lowercase letters, numbers, and hyphens allowed", "requiredWhenUpdating": "{field} is required when updating credentials", "requiredForNew": "{field} is required for new registries" @@ -628,6 +635,23 @@ "skipped": "Skipped" } }, + "proxies": { + "title": "Proxy Routes", + "description": "Active proxy routes created by deployments.", + "domain": "Domain", + "project": "Project", + "stage": "Stage", + "tag": "Tag", + "port": "Port", + "status": "Status", + "noRoutes": "No proxy routes", + "noRoutesDesc": "Proxy routes are created automatically when you deploy a container with proxy enabled.", + "searchPlaceholder": "Search by domain, project, or tag...", + "noMatch": "No routes match your search.", + "loadFailed": "Failed to load proxy routes", + "route": "route", + "routes": "routes" + }, "events": { "title": "Event Log", "noEvents": "No events found", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 3676921..cb15fb5 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -281,7 +281,7 @@ "varTag": "Тег образа", "varPort": "Порт контейнера", "pollingInterval": "Интервал опроса (секунды)", - "pollingIntervalHelp": "Как часто проверять реестры на новые теги (10-86400)", + "pollingIntervalHelp": "Как часто проверять реестры на новые теги (60-86400)", "notificationUrl": "URL уведомлений", "notificationUrlHelp": "URL вебхука для уведомлений о деплоях", "saveSettings": "Сохранить настройки", @@ -367,6 +367,13 @@ "healthConnected": "Подключено", "healthUnreachable": "Недоступно" }, + "settingsNpm": { + "testConnection": "Проверить соединение", + "testing": "Проверка...", + "testSuccess": "Подключение к NPM успешно", + "testFailed": "Не удалось подключиться к NPM", + "saveFailedConnection": "Невозможно сохранить — проверка соединения не пройдена" + }, "settingsCredentials": { "title": "Учётные данные", "description": "Управление учётными данными для Nginx Proxy Manager и токенами реестров. Все значения зашифрованы.", @@ -544,7 +551,7 @@ "invalidIp": "Неверный формат IP", "invalidEmail": "Неверный формат email", "invalidPort": "Порт должен быть от 1 до 65535", - "invalidPollingInterval": "Интервал опроса должен быть от 10 до 86400 секунд", + "invalidPollingInterval": "Интервал опроса должен быть от 60 до 86400 секунд", "invalidProjectName": "Допускаются только строчные буквы, цифры и дефисы", "requiredWhenUpdating": "Поле {field} обязательно при обновлении учётных данных", "requiredForNew": "Поле {field} обязательно для новых реестров" @@ -628,6 +635,23 @@ "skipped": "Пропущено" } }, + "proxies": { + "title": "Прокси-маршруты", + "description": "Активные прокси-маршруты, созданные при развёртывании.", + "domain": "Домен", + "project": "Проект", + "stage": "Этап", + "tag": "Тег", + "port": "Порт", + "status": "Статус", + "noRoutes": "Нет прокси-маршрутов", + "noRoutesDesc": "Прокси-маршруты создаются автоматически при развёртывании контейнера с включённым прокси.", + "searchPlaceholder": "Поиск по домену, проекту или тегу...", + "noMatch": "Нет маршрутов, соответствующих поиску.", + "loadFailed": "Не удалось загрузить прокси-маршруты", + "route": "маршрут", + "routes": "маршрутов" + }, "events": { "title": "Журнал событий", "noEvents": "Событий не найдено", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 626ab00..13fefc7 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -251,6 +251,30 @@ export interface DockerHealth { checked_at?: string; } +export interface ProxyHealth { + connected: boolean; + provider: string; + error?: string; +} + +/** A proxy route managed by a deployed instance. */ +export interface ProxyRoute { + instance_id: string; + project_id: string; + project_name: string; + stage_id: string; + stage_name: string; + image_tag: string; + subdomain: string; + domain: string; + container_id: string; + port: number; + proxy_route_id: string; + npm_proxy_id: number; + status: string; + created_at: string; +} + /** A persistent event log entry. */ export interface EventLogEntry { id: number; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 8480805..853016c 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -6,14 +6,14 @@ import Toast from '$lib/components/Toast.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte'; - import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconSettings, IconMenu, IconX, IconLogout, IconChevronDown } from '$lib/components/icons'; + import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons'; import { goto } from '$app/navigation'; import { connectGlobalEvents, type SSEConnection } from '$lib/sse'; import { instanceStatusStore } from '$lib/stores/instance-status'; import { resolvedTheme, applyTheme } from '$lib/stores/theme'; import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth'; import { logout as apiLogout, getHealth } from '$lib/api'; - import type { DockerHealth } from '$lib/types'; + import type { DockerHealth, ProxyHealth } from '$lib/types'; import { t } from '$lib/i18n'; interface Props { @@ -26,6 +26,7 @@ { href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' }, { href: '/projects', labelKey: 'nav.projects', icon: 'projects' }, { href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' }, + { href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' }, { href: '/events', labelKey: 'nav.events', icon: 'events' }, { href: '/settings', labelKey: 'nav.settings', icon: 'settings' } ] as const; @@ -38,11 +39,15 @@ let sseConnection: SSEConnection | null = null; let sidebarOpen = $state(false); let dockerHealth = $state(null); + let proxyHealth = $state(null); let healthChecked = $state(false); let healthInterval: ReturnType | null = null; let hintsExpanded = $state(false); + let proxyHintsExpanded = $state(false); const dockerConnected = $derived(dockerHealth?.connected ?? false); + const proxyConnected = $derived(proxyHealth?.connected ?? true); + const proxyProviderName = $derived(proxyHealth?.provider ?? ''); // Hide sidebar and chrome on the login page. const isLoginPage = $derived($page.url.pathname === '/login'); @@ -99,8 +104,10 @@ try { const h = await getHealth(); dockerHealth = h.docker; + proxyHealth = h.proxy ?? null; } catch { dockerHealth = { connected: false }; + proxyHealth = null; } healthChecked = true; } @@ -170,6 +177,8 @@ {:else if item.icon === 'deploy'} + {:else if item.icon === 'proxies'} + {:else if item.icon === 'events'} {:else if item.icon === 'settings'} @@ -186,44 +195,63 @@
{#if healthChecked} -
+
- {#if !dockerConnected && hintsExpanded && dockerHealth?.error} -
- {dockerHealth.error} - -
+ {#if proxyHealth && proxyProviderName !== 'none'} + {/if}
+ {#if !dockerConnected && hintsExpanded && dockerHealth?.error} +
+ {dockerHealth.error} + +
+ {/if} + {#if !proxyConnected && proxyHintsExpanded && proxyHealth?.error} +
+ {proxyHealth.error} +
+ {/if} {/if}
diff --git a/web/src/routes/deploy/+page.svelte b/web/src/routes/deploy/+page.svelte index 69946ac..4760c50 100644 --- a/web/src/routes/deploy/+page.svelte +++ b/web/src/routes/deploy/+page.svelte @@ -17,7 +17,6 @@ let projectName = $state(''); let port = $state(''); - let healthcheck = $state(''); let stage = $state('dev'); let subdomain = $state(''); let envVars = $state(''); @@ -78,9 +77,10 @@ return ''; } - function validatePort(value: string): string { - if (!value.trim()) return $t('validation.required', { field: 'Port' }); - const num = parseInt(value, 10); + function validatePort(value: string | number): string { + const s = String(value ?? ''); + if (!s.trim()) return $t('validation.required', { field: 'Port' }); + const num = parseInt(s, 10); if (isNaN(num) || num < 1 || num > 65535) return $t('validation.invalidPort'); return ''; } @@ -122,7 +122,7 @@ inspectResult = result; projectName = deriveProjectName(result.image); port = result.port?.toString() ?? ''; - healthcheck = result.healthcheck ?? ''; + // Healthcheck auto-detected but not shown — user can configure later on project page. stage = 'dev'; subdomain = ''; envVars = ''; @@ -151,8 +151,7 @@ inspectResult = null; projectName = ''; port = ''; - healthcheck = ''; - stage = 'dev'; + stage = 'dev'; subdomain = ''; envVars = ''; } @@ -274,7 +273,6 @@
-
+ +
+ + + + + + + + + + + + + {#each filtered as route (route.instance_id)} + + + + + + + + + {/each} + +
{$t('proxies.domain')}{$t('proxies.project')}{$t('proxies.stage')}{$t('proxies.tag')}{$t('proxies.port')}{$t('proxies.status')}
+ {#if route.domain} + + {route.domain} + + {:else} + {route.subdomain || '—'} + {/if} + + + {route.project_name} + + {route.stage_name} + {route.image_tag} + {route.port} + +
+
+ + {#if filtered.length === 0 && search} +

{$t('proxies.noMatch')}

+ {/if} + +

+ {filtered.length} {filtered.length === 1 ? $t('proxies.route') : $t('proxies.routes')} +

+ {/if} +
diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 81b988c..9cbc0b4 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -41,7 +41,7 @@ let errors = $state>({}); function validateDomain(value: string): string { - if (!value.trim()) return $t('validation.required', { field: 'Domain' }); + if (!value.trim()) return ''; if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) return $t('validation.invalidDomain'); return ''; } @@ -52,10 +52,11 @@ return ''; } - function validatePollingInterval(value: string): string { - if (!value.trim()) return ''; - const num = parseInt(value, 10); - if (isNaN(num) || num < 10 || num > 86400) return $t('validation.invalidPollingInterval'); + function validatePollingInterval(value: string | number): string { + const s = String(value ?? ''); + if (!s.trim()) return ''; + const num = parseInt(s, 10); + if (isNaN(num) || num < 60 || num > 86400) return $t('validation.invalidPollingInterval'); return ''; } @@ -115,7 +116,7 @@ try { const payload: Record = { domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(), - subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(), + subdomain_pattern: subdomainPattern.trim(), polling_interval: String(pollingInterval ?? '').trim(), base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(), proxy_provider: proxyProvider, stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7), @@ -236,7 +237,7 @@

{$t('settingsGeneral.globalConfig')}

- +
diff --git a/web/src/routes/settings/npm/+page.svelte b/web/src/routes/settings/npm/+page.svelte index c8e46f4..79113d0 100644 --- a/web/src/routes/settings/npm/+page.svelte +++ b/web/src/routes/settings/npm/+page.svelte @@ -1,15 +1,16 @@