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:
2026-04-05 01:27:54 +03:00
parent 1aa9c3f0e9
commit 187e302f4a
18 changed files with 525 additions and 63 deletions
+10
View File
@@ -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
View File
@@ -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]
}
+26
View File
@@ -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)
}
+5
View File
@@ -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)
+84 -5
View File
@@ -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
+7 -3
View File
@@ -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,
},
+53
View File
@@ -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
View File
Binary file not shown.
+14 -2
View File
@@ -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 ─────────────────────────────────────────────────────────────
+26 -2
View File
@@ -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",
+26 -2
View File
@@ -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": "Событий не найдено",
+24
View File
@@ -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;
+56 -28
View File
@@ -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 />
+7 -9
View File
@@ -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&#10;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
View File
@@ -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>
+120
View File
@@ -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>
+8 -7
View File
@@ -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>
+36 -3
View File
@@ -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')}