feat: proxy routes page, OIDC login fix, NPM test connection, webhook URL fix, and UX improvements
- Add /proxies page showing deploy-managed proxy routes with project/stage links, search, and status - Add GET /api/proxies endpoint joining instances with project/stage names - Add POST /api/settings/npm/test endpoint for NPM connection validation - Add GET /api/auth/mode public endpoint for auth mode detection - Add NPM Test Connection button with validation on save - Fix OIDC SSO button only shown when auth_mode is oidc - Fix webhook URL showing empty when domain not set (fallback to request host) - Fix quick deploy double-tag (image:latest:latest) by splitting tag from image URL - Fix trim() errors on number inputs in deploy and settings forms - Fix NPM client auto-append /api to base URL - Sanitize NPM test error messages (no raw HTML) - Remove healthcheck field from Quick Deploy form - Fix env vars placeholder newline - Make domain field optional in settings - Set polling interval minimum to 60s - Add Proxies and Events to sidebar navigation - Fix SSL cert name flash on NPM settings page - Fix empty state icon on proxies page
This commit is contained in:
@@ -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
|
||||
|
||||
+11
-2
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
BIN
Binary file not shown.
+14
-2
@@ -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<ProxyRoute[]> {
|
||||
return get<ProxyRoute[]>('/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<NpmCertificate[]> {
|
||||
return get<NpmCertificate[]>('/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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Событий не найдено",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DockerHealth | null>(null);
|
||||
let proxyHealth = $state<ProxyHealth | null>(null);
|
||||
let healthChecked = $state(false);
|
||||
let healthInterval: ReturnType<typeof setInterval> | 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 @@
|
||||
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'deploy'}
|
||||
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'proxies'}
|
||||
<IconWifi size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'events'}
|
||||
<IconEvents size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'settings'}
|
||||
@@ -186,44 +195,63 @@
|
||||
<!-- Footer controls -->
|
||||
<div class="space-y-3 border-t border-[var(--border-primary)] px-4 py-3">
|
||||
{#if healthChecked}
|
||||
<div class="rounded-md {dockerConnected ? '' : 'bg-red-50 dark:bg-red-950/30'}">
|
||||
<div class="flex items-center gap-3 px-1 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 text-xs {dockerConnected ? 'text-emerald-600' : 'text-red-500 cursor-pointer'}"
|
||||
class="flex items-center gap-1.5 {dockerConnected ? 'text-emerald-600' : 'text-red-500'}"
|
||||
title={dockerConnected ? 'Docker connected' : dockerHealth?.error ?? 'Docker disconnected'}
|
||||
onclick={() => { if (!dockerConnected) hintsExpanded = !hintsExpanded; }}
|
||||
disabled={dockerConnected}
|
||||
>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="relative flex h-2 w-2 shrink-0">
|
||||
{#if dockerConnected}
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50"></span>
|
||||
{/if}
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full {dockerConnected ? 'bg-emerald-500' : 'bg-red-500'}"></span>
|
||||
</span>
|
||||
<span class="flex-1 text-left">Docker {dockerConnected ? $t('health.connected') : $t('health.disconnected')}</span>
|
||||
{#if !dockerConnected && dockerHealth?.error}
|
||||
<IconChevronDown size={12} class="transition-transform {hintsExpanded ? 'rotate-180' : ''}" />
|
||||
{/if}
|
||||
Docker
|
||||
</button>
|
||||
{#if !dockerConnected && hintsExpanded && dockerHealth?.error}
|
||||
<div class="px-2 pb-2">
|
||||
<code class="block text-[11px] text-red-600 dark:text-red-400 break-all leading-relaxed">{dockerHealth.error}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 w-full rounded border border-red-300 dark:border-red-700 px-2 py-1 text-[11px] font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
onclick={async () => {
|
||||
try {
|
||||
const h = await getHealth();
|
||||
dockerHealth = h.docker;
|
||||
} catch {
|
||||
dockerHealth = { connected: false };
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('health.retryNow')}
|
||||
</button>
|
||||
</div>
|
||||
{#if proxyHealth && proxyProviderName !== 'none'}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 {proxyConnected ? 'text-emerald-600' : 'text-red-500'}"
|
||||
title={proxyConnected ? (proxyProviderName === 'npm' ? 'NPM' : 'Traefik') + ' connected' : proxyHealth.error ?? 'Proxy disconnected'}
|
||||
onclick={() => { if (!proxyConnected) proxyHintsExpanded = !proxyHintsExpanded; }}
|
||||
>
|
||||
<span class="relative flex h-2 w-2 shrink-0">
|
||||
{#if proxyConnected}
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50"></span>
|
||||
{/if}
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full {proxyConnected ? 'bg-emerald-500' : 'bg-red-500'}"></span>
|
||||
</span>
|
||||
{proxyProviderName === 'npm' ? 'NPM' : 'Traefik'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !dockerConnected && hintsExpanded && dockerHealth?.error}
|
||||
<div class="rounded-md bg-red-50 dark:bg-red-950/30 px-2 py-2">
|
||||
<code class="block text-[10px] text-red-600 dark:text-red-400 break-all leading-relaxed">{dockerHealth.error}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1.5 w-full rounded border border-red-300 dark:border-red-700 px-2 py-0.5 text-[10px] font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
onclick={async () => {
|
||||
try {
|
||||
const h = await getHealth();
|
||||
dockerHealth = h.docker;
|
||||
proxyHealth = h.proxy ?? null;
|
||||
} catch {
|
||||
dockerHealth = { connected: false };
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('health.retryNow')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !proxyConnected && proxyHintsExpanded && proxyHealth?.error}
|
||||
<div class="rounded-md bg-red-50 dark:bg-red-950/30 px-2 py-2">
|
||||
<code class="block text-[10px] text-red-600 dark:text-red-400 break-all leading-relaxed">{proxyHealth.error}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex items-center justify-between">
|
||||
<ThemeToggle />
|
||||
|
||||
@@ -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 @@
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField label={$t('quickDeploy.projectName')} name="projectName" bind:value={projectName} placeholder="my-app" required error={errors.projectName ?? ''} helpText={$t('quickDeploy.lowercaseHint')} />
|
||||
<FormField label={$t('quickDeploy.port')} name="port" type="number" bind:value={port} placeholder="3000" required error={errors.port ?? ''} helpText={$t('quickDeploy.portHelp')} />
|
||||
<FormField label={$t('quickDeploy.healthCheckPath')} name="healthcheck" bind:value={healthcheck} placeholder="/api/health" helpText={$t('quickDeploy.healthCheckHelp')} />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="stage" class="text-sm font-medium text-[var(--text-primary)]">{$t('quickDeploy.stage')}</label>
|
||||
<select id="stage" bind:value={stage} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||
@@ -288,7 +286,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<FormField label={$t('quickDeploy.envVars')} name="envVars" type="textarea" bind:value={envVars} placeholder="KEY=value ANOTHER_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>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
let showPassword = $state(false);
|
||||
let authMode = $state('local');
|
||||
|
||||
// Apply theme on login page too.
|
||||
$effect(() => {
|
||||
@@ -19,6 +20,15 @@
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// Check if OIDC is enabled.
|
||||
try {
|
||||
const res = await fetch('/api/auth/mode');
|
||||
if (res.ok) {
|
||||
const envelope = await res.json();
|
||||
authMode = envelope.data?.auth_mode ?? 'local';
|
||||
}
|
||||
} catch { /* default to local */ }
|
||||
|
||||
const urlToken = $page.url.searchParams.get('token');
|
||||
if (urlToken) {
|
||||
// Validate the token against the backend before trusting it.
|
||||
@@ -140,6 +150,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if authMode === 'oidc'}
|
||||
<div class="mt-5">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
@@ -157,6 +168,7 @@
|
||||
{$t('login.ssoButton')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { listProxyRoutes } from '$lib/api';
|
||||
import type { ProxyRoute } from '$lib/types';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
|
||||
let routes = $state<ProxyRoute[]>([]);
|
||||
let loading = $state(true);
|
||||
let search = $state('');
|
||||
|
||||
const filtered = $derived(
|
||||
search.trim()
|
||||
? routes.filter((r) => {
|
||||
const q = search.toLowerCase();
|
||||
return r.domain?.toLowerCase().includes(q)
|
||||
|| r.project_name.toLowerCase().includes(q)
|
||||
|| r.stage_name.toLowerCase().includes(q)
|
||||
|| r.image_tag.toLowerCase().includes(q);
|
||||
})
|
||||
: routes
|
||||
);
|
||||
|
||||
async function loadRoutes() {
|
||||
loading = true;
|
||||
try {
|
||||
routes = await listProxyRoutes();
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('proxies.loadFailed'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { loadRoutes(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('proxies.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('proxies.title')}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('proxies.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<Skeleton height="4rem" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if routes.length === 0}
|
||||
<EmptyState title={$t('proxies.noRoutes')} description={$t('proxies.noRoutesDesc')} icon="instances" />
|
||||
{:else}
|
||||
<!-- Search -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder={$t('proxies.searchPlaceholder')}
|
||||
class="w-full max-w-md rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
|
||||
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.domain')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.project')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.stage')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.tag')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.port')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each filtered as route (route.instance_id)}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
{#if route.domain}
|
||||
<a href="https://{route.domain}" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] underline transition-colors">
|
||||
{route.domain}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-sm text-[var(--text-tertiary)]">{route.subdomain || '—'}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="/projects/{route.project_id}" class="text-sm font-medium text-[var(--text-primary)] hover:text-[var(--text-link)] transition-colors">
|
||||
{route.project_name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-[var(--text-secondary)]">{route.stage_name}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-mono text-[var(--text-secondary)]">{route.image_tag}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-mono text-[var(--text-secondary)]">{route.port}</td>
|
||||
<td class="px-4 py-3">
|
||||
<StatusBadge status={route.status} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0 && search}
|
||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('proxies.noMatch')}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-[var(--text-tertiary)]">
|
||||
{filtered.length} {filtered.length === 1 ? $t('proxies.route') : $t('proxies.routes')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
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<string, unknown> = {
|
||||
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 @@
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.globalConfig')}</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField label={$t('settingsGeneral.domain')} name="domain" bind:value={domain} placeholder="example.com" required error={errors.domain ?? ''} helpText={$t('settingsGeneral.domainHelp')} />
|
||||
<FormField label={$t('settingsGeneral.domain')} name="domain" bind:value={domain} placeholder="example.com" error={errors.domain ?? ''} helpText={$t('settingsGeneral.domainHelp')} />
|
||||
<FormField label={$t('settingsGeneral.serverIp')} name="serverIp" bind:value={serverIp} placeholder="93.84.96.191" error={errors.serverIp ?? ''} helpText={$t('settingsGeneral.serverIpHelp')} />
|
||||
<FormField label={$t('settingsGeneral.dockerNetwork')} name="network" bind:value={network} placeholder="staging-net" helpText={$t('settingsGeneral.dockerNetworkHelp')} />
|
||||
<div>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings, listNpmCertificates } from '$lib/api';
|
||||
import { getSettings, updateSettings, listNpmCertificates, testNpmConnection } from '$lib/api';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconCheck, IconEdit, IconShield, IconX } from '$lib/components/icons';
|
||||
import { IconLoader, IconCheck, IconEdit, IconShield, IconX, IconWifi } from '$lib/components/icons';
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let testing = $state(false);
|
||||
let npmUrl = $state('');
|
||||
let npmEmail = $state('');
|
||||
let npmPassword = $state('');
|
||||
@@ -41,12 +42,40 @@
|
||||
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
|
||||
npmPassword = '';
|
||||
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
||||
if (sslCertificateId > 0) sslCertName = `Certificate #${sslCertificateId}`;
|
||||
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; }
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
if (!validateNpmForm()) return;
|
||||
testing = true;
|
||||
try {
|
||||
await testNpmConnection({
|
||||
npm_url: npmUrl.trim(),
|
||||
npm_email: npmEmail.trim(),
|
||||
npm_password: npmPassword.trim() || undefined
|
||||
});
|
||||
toasts.success($t('settingsNpm.testSuccess'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsNpm.testFailed'));
|
||||
} finally { testing = false; }
|
||||
}
|
||||
|
||||
async function handleSaveNpm() {
|
||||
if (!validateNpmForm()) return;
|
||||
saving = true;
|
||||
try {
|
||||
// Validate connection before saving.
|
||||
await testNpmConnection({
|
||||
npm_url: npmUrl.trim(),
|
||||
npm_email: npmEmail.trim(),
|
||||
npm_password: npmPassword.trim() || undefined
|
||||
});
|
||||
} catch (err) {
|
||||
toasts.error($t('settingsNpm.saveFailedConnection') + ': ' + (err instanceof Error ? err.message : ''));
|
||||
saving = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload: Record<string, unknown> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() };
|
||||
if (npmPassword.trim()) payload.npm_password = npmPassword.trim();
|
||||
@@ -148,10 +177,14 @@
|
||||
<FormField label={$t('settingsCredentials.email')} name="npmEmail" type="email" bind:value={npmEmail} placeholder="admin@example.com" required error={errors.npmEmail ?? ''} helpText={$t('settingsCredentials.emailHelp')} />
|
||||
<FormField label={$t('settingsCredentials.password')} name="npmPassword" type="password" bind:value={npmPassword} placeholder={npmHasCredentials ? '(enter new password)' : 'npm-password'} required={editingNpm} error={errors.npmPassword ?? ''} helpText={npmHasCredentials ? $t('settingsCredentials.passwordHelpEdit') : $t('settingsCredentials.passwordHelpNew')} />
|
||||
<div class="flex gap-3">
|
||||
<button onclick={handleSaveNpm} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press">
|
||||
<button onclick={handleSaveNpm} disabled={saving || testing} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
{saving ? $t('settingsCredentials.saving') : $t('settingsCredentials.save')}
|
||||
</button>
|
||||
<button onclick={handleTestConnection} disabled={testing || saving} class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] disabled:opacity-50 transition-colors active:animate-press">
|
||||
{#if testing}<IconLoader size={16} />{:else}<IconWifi size={16} />{/if}
|
||||
{testing ? $t('settingsNpm.testing') : $t('settingsNpm.testConnection')}
|
||||
</button>
|
||||
{#if npmHasCredentials}
|
||||
<button onclick={() => { editingNpm = false; npmPassword = ''; errors = {}; }} class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
{$t('common.cancel')}
|
||||
|
||||
Reference in New Issue
Block a user