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.
|
// login handles POST /api/auth/login.
|
||||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||||
var req auth.LoginRequest
|
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")
|
respondError(w, http.StatusBadRequest, "image is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split tag from image if the image URL contains one (e.g., "registry/app:v1").
|
||||||
if req.Tag == "" {
|
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 == "" {
|
if req.Name == "" {
|
||||||
// Derive name from image.
|
// Derive name from image (without tag).
|
||||||
parts := strings.Split(req.Image, "/")
|
parts := strings.Split(req.Image, "/")
|
||||||
req.Name = parts[len(parts)-1]
|
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)
|
r.Use(limitBody)
|
||||||
|
|
||||||
// Public auth endpoints (no auth required).
|
// Public auth endpoints (no auth required).
|
||||||
|
r.Get("/auth/mode", s.authMode)
|
||||||
r.Post("/auth/login", s.rateLimitedLogin(loginLimiter))
|
r.Post("/auth/login", s.rateLimitedLogin(loginLimiter))
|
||||||
r.Get("/auth/oidc/login", s.oidcLogin)
|
r.Get("/auth/oidc/login", s.oidcLogin)
|
||||||
r.Get("/auth/oidc/callback", s.oidcCallback)
|
r.Get("/auth/oidc/callback", s.oidcCallback)
|
||||||
@@ -185,6 +186,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Get("/health", s.getHealth)
|
r.Get("/health", s.getHealth)
|
||||||
r.Get("/auth/me", s.currentUser)
|
r.Get("/auth/me", s.currentUser)
|
||||||
r.Post("/auth/logout", s.logout)
|
r.Post("/auth/logout", s.logout)
|
||||||
|
r.Get("/proxies", s.listProxyRoutes)
|
||||||
r.Get("/projects", s.listProjects)
|
r.Get("/projects", s.listProjects)
|
||||||
r.Route("/projects/{id}", func(r chi.Router) {
|
r.Route("/projects/{id}", func(r chi.Router) {
|
||||||
r.Get("/", s.getProject)
|
r.Get("/", s.getProject)
|
||||||
@@ -290,6 +292,9 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Get("/settings/webhook-url", s.getWebhookURL)
|
r.Get("/settings/webhook-url", s.getWebhookURL)
|
||||||
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
|
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
|
||||||
|
|
||||||
|
// NPM connection test.
|
||||||
|
r.Post("/settings/npm/test", s.testNpmConnection)
|
||||||
|
|
||||||
// DNS management endpoints.
|
// DNS management endpoints.
|
||||||
r.Post("/settings/dns/test", s.testDNSConnection)
|
r.Post("/settings/dns/test", s.testDNSConnection)
|
||||||
r.Post("/settings/dns/zones", s.listDNSZones)
|
r.Post("/settings/dns/zones", s.listDNSZones)
|
||||||
|
|||||||
@@ -258,8 +258,15 @@ func (s *Server) getWebhookURL(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
webhookURL := ""
|
webhookURL := ""
|
||||||
if settings.WebhookSecret != "" && settings.Domain != "" {
|
if settings.WebhookSecret != "" {
|
||||||
webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, 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{
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
@@ -281,10 +288,13 @@ func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookURL := ""
|
host := settings.Domain
|
||||||
if settings.Domain != "" {
|
scheme := "https"
|
||||||
webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, secret)
|
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{
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
"webhook_url": webhookURL,
|
"webhook_url": webhookURL,
|
||||||
@@ -504,6 +514,75 @@ type dnsTestRequest struct {
|
|||||||
ZoneID string `json:"zone_id"`
|
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.
|
// testDNSConnection handles POST /api/settings/dns/test.
|
||||||
func (s *Server) testDNSConnection(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) testDNSConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
var req dnsTestRequest
|
var req dnsTestRequest
|
||||||
|
|||||||
@@ -26,11 +26,15 @@ type Client struct {
|
|||||||
password string
|
password string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an NPM client targeting the given base URL (e.g. "http://npm:81/api").
|
// New creates an NPM client targeting the given base URL (e.g. "http://npm:81").
|
||||||
// The returned client is not yet authenticated — call Authenticate before other methods.
|
// Automatically appends "/api" if not already present.
|
||||||
func New(baseURL string) *Client {
|
func New(baseURL string) *Client {
|
||||||
|
u := strings.TrimRight(baseURL, "/")
|
||||||
|
if u != "" && !strings.HasSuffix(u, "/api") {
|
||||||
|
u += "/api"
|
||||||
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: u,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -119,6 +119,59 @@ func (s *Store) ListAllInstances() ([]Instance, error) {
|
|||||||
return instances, rows.Err()
|
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.
|
// UpdateInstance updates an existing instance's mutable fields.
|
||||||
func (s *Store) UpdateInstance(inst Instance) error {
|
func (s *Store) UpdateInstance(inst Instance) error {
|
||||||
inst.UpdatedAt = Now()
|
inst.UpdatedAt = Now()
|
||||||
|
|||||||
BIN
Binary file not shown.
+14
-2
@@ -4,11 +4,13 @@ import type {
|
|||||||
Deploy,
|
Deploy,
|
||||||
DeployLog,
|
DeployLog,
|
||||||
DockerHealth,
|
DockerHealth,
|
||||||
|
ProxyHealth,
|
||||||
EventLogEntry,
|
EventLogEntry,
|
||||||
EventLogStats,
|
EventLogStats,
|
||||||
InspectResult,
|
InspectResult,
|
||||||
Instance,
|
Instance,
|
||||||
NpmCertificate,
|
NpmCertificate,
|
||||||
|
ProxyRoute,
|
||||||
Project,
|
Project,
|
||||||
ProjectDetail,
|
ProjectDetail,
|
||||||
Registry,
|
Registry,
|
||||||
@@ -265,6 +267,16 @@ export function regenerateWebhookUrl(): Promise<{ webhook_url: string }> {
|
|||||||
return post<{ webhook_url: string }>('/api/settings/webhook-url/regenerate');
|
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[]> {
|
export function listNpmCertificates(): Promise<NpmCertificate[]> {
|
||||||
return get<NpmCertificate[]>('/api/settings/npm-certificates');
|
return get<NpmCertificate[]>('/api/settings/npm-certificates');
|
||||||
}
|
}
|
||||||
@@ -315,8 +327,8 @@ export function backupDownloadUrl(id: string): string {
|
|||||||
|
|
||||||
// ── Health ──────────────────────────────────────────────────────────
|
// ── Health ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getHealth(): Promise<{ docker: DockerHealth }> {
|
export function getHealth(): Promise<{ docker: DockerHealth; proxy?: ProxyHealth }> {
|
||||||
return get<{ docker: DockerHealth }>('/api/health');
|
return get<{ docker: DockerHealth; proxy?: ProxyHealth }>('/api/health');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -281,7 +281,7 @@
|
|||||||
"varTag": "Image tag",
|
"varTag": "Image tag",
|
||||||
"varPort": "Container port",
|
"varPort": "Container port",
|
||||||
"pollingInterval": "Polling Interval (seconds)",
|
"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",
|
"notificationUrl": "Notification URL",
|
||||||
"notificationUrlHelp": "Webhook URL for deploy notifications",
|
"notificationUrlHelp": "Webhook URL for deploy notifications",
|
||||||
"saveSettings": "Save Settings",
|
"saveSettings": "Save Settings",
|
||||||
@@ -367,6 +367,13 @@
|
|||||||
"healthConnected": "Connected",
|
"healthConnected": "Connected",
|
||||||
"healthUnreachable": "Unreachable"
|
"healthUnreachable": "Unreachable"
|
||||||
},
|
},
|
||||||
|
"settingsNpm": {
|
||||||
|
"testConnection": "Test Connection",
|
||||||
|
"testing": "Testing...",
|
||||||
|
"testSuccess": "NPM connection successful",
|
||||||
|
"testFailed": "NPM connection failed",
|
||||||
|
"saveFailedConnection": "Cannot save \u2014 connection test failed"
|
||||||
|
},
|
||||||
"settingsCredentials": {
|
"settingsCredentials": {
|
||||||
"title": "Credentials",
|
"title": "Credentials",
|
||||||
"description": "Manage credentials for Nginx Proxy Manager and registry tokens. All values are encrypted at rest.",
|
"description": "Manage credentials for Nginx Proxy Manager and registry tokens. All values are encrypted at rest.",
|
||||||
@@ -544,7 +551,7 @@
|
|||||||
"invalidIp": "Invalid IP format",
|
"invalidIp": "Invalid IP format",
|
||||||
"invalidEmail": "Invalid email format",
|
"invalidEmail": "Invalid email format",
|
||||||
"invalidPort": "Port must be between 1 and 65535",
|
"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",
|
"invalidProjectName": "Only lowercase letters, numbers, and hyphens allowed",
|
||||||
"requiredWhenUpdating": "{field} is required when updating credentials",
|
"requiredWhenUpdating": "{field} is required when updating credentials",
|
||||||
"requiredForNew": "{field} is required for new registries"
|
"requiredForNew": "{field} is required for new registries"
|
||||||
@@ -628,6 +635,23 @@
|
|||||||
"skipped": "Skipped"
|
"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": {
|
"events": {
|
||||||
"title": "Event Log",
|
"title": "Event Log",
|
||||||
"noEvents": "No events found",
|
"noEvents": "No events found",
|
||||||
|
|||||||
@@ -281,7 +281,7 @@
|
|||||||
"varTag": "Тег образа",
|
"varTag": "Тег образа",
|
||||||
"varPort": "Порт контейнера",
|
"varPort": "Порт контейнера",
|
||||||
"pollingInterval": "Интервал опроса (секунды)",
|
"pollingInterval": "Интервал опроса (секунды)",
|
||||||
"pollingIntervalHelp": "Как часто проверять реестры на новые теги (10-86400)",
|
"pollingIntervalHelp": "Как часто проверять реестры на новые теги (60-86400)",
|
||||||
"notificationUrl": "URL уведомлений",
|
"notificationUrl": "URL уведомлений",
|
||||||
"notificationUrlHelp": "URL вебхука для уведомлений о деплоях",
|
"notificationUrlHelp": "URL вебхука для уведомлений о деплоях",
|
||||||
"saveSettings": "Сохранить настройки",
|
"saveSettings": "Сохранить настройки",
|
||||||
@@ -367,6 +367,13 @@
|
|||||||
"healthConnected": "Подключено",
|
"healthConnected": "Подключено",
|
||||||
"healthUnreachable": "Недоступно"
|
"healthUnreachable": "Недоступно"
|
||||||
},
|
},
|
||||||
|
"settingsNpm": {
|
||||||
|
"testConnection": "Проверить соединение",
|
||||||
|
"testing": "Проверка...",
|
||||||
|
"testSuccess": "Подключение к NPM успешно",
|
||||||
|
"testFailed": "Не удалось подключиться к NPM",
|
||||||
|
"saveFailedConnection": "Невозможно сохранить — проверка соединения не пройдена"
|
||||||
|
},
|
||||||
"settingsCredentials": {
|
"settingsCredentials": {
|
||||||
"title": "Учётные данные",
|
"title": "Учётные данные",
|
||||||
"description": "Управление учётными данными для Nginx Proxy Manager и токенами реестров. Все значения зашифрованы.",
|
"description": "Управление учётными данными для Nginx Proxy Manager и токенами реестров. Все значения зашифрованы.",
|
||||||
@@ -544,7 +551,7 @@
|
|||||||
"invalidIp": "Неверный формат IP",
|
"invalidIp": "Неверный формат IP",
|
||||||
"invalidEmail": "Неверный формат email",
|
"invalidEmail": "Неверный формат email",
|
||||||
"invalidPort": "Порт должен быть от 1 до 65535",
|
"invalidPort": "Порт должен быть от 1 до 65535",
|
||||||
"invalidPollingInterval": "Интервал опроса должен быть от 10 до 86400 секунд",
|
"invalidPollingInterval": "Интервал опроса должен быть от 60 до 86400 секунд",
|
||||||
"invalidProjectName": "Допускаются только строчные буквы, цифры и дефисы",
|
"invalidProjectName": "Допускаются только строчные буквы, цифры и дефисы",
|
||||||
"requiredWhenUpdating": "Поле {field} обязательно при обновлении учётных данных",
|
"requiredWhenUpdating": "Поле {field} обязательно при обновлении учётных данных",
|
||||||
"requiredForNew": "Поле {field} обязательно для новых реестров"
|
"requiredForNew": "Поле {field} обязательно для новых реестров"
|
||||||
@@ -628,6 +635,23 @@
|
|||||||
"skipped": "Пропущено"
|
"skipped": "Пропущено"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"proxies": {
|
||||||
|
"title": "Прокси-маршруты",
|
||||||
|
"description": "Активные прокси-маршруты, созданные при развёртывании.",
|
||||||
|
"domain": "Домен",
|
||||||
|
"project": "Проект",
|
||||||
|
"stage": "Этап",
|
||||||
|
"tag": "Тег",
|
||||||
|
"port": "Порт",
|
||||||
|
"status": "Статус",
|
||||||
|
"noRoutes": "Нет прокси-маршрутов",
|
||||||
|
"noRoutesDesc": "Прокси-маршруты создаются автоматически при развёртывании контейнера с включённым прокси.",
|
||||||
|
"searchPlaceholder": "Поиск по домену, проекту или тегу...",
|
||||||
|
"noMatch": "Нет маршрутов, соответствующих поиску.",
|
||||||
|
"loadFailed": "Не удалось загрузить прокси-маршруты",
|
||||||
|
"route": "маршрут",
|
||||||
|
"routes": "маршрутов"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Журнал событий",
|
"title": "Журнал событий",
|
||||||
"noEvents": "Событий не найдено",
|
"noEvents": "Событий не найдено",
|
||||||
|
|||||||
@@ -251,6 +251,30 @@ export interface DockerHealth {
|
|||||||
checked_at?: string;
|
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. */
|
/** A persistent event log entry. */
|
||||||
export interface EventLogEntry {
|
export interface EventLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
import Toast from '$lib/components/Toast.svelte';
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
import LocaleSwitcher from '$lib/components/LocaleSwitcher.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 { goto } from '$app/navigation';
|
||||||
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
|
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
|
||||||
import { instanceStatusStore } from '$lib/stores/instance-status';
|
import { instanceStatusStore } from '$lib/stores/instance-status';
|
||||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||||
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
||||||
import { logout as apiLogout, getHealth } from '$lib/api';
|
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';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
|
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
|
||||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
||||||
|
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
|
||||||
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
|
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
|
||||||
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
|
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
|
||||||
] as const;
|
] as const;
|
||||||
@@ -38,11 +39,15 @@
|
|||||||
let sseConnection: SSEConnection | null = null;
|
let sseConnection: SSEConnection | null = null;
|
||||||
let sidebarOpen = $state(false);
|
let sidebarOpen = $state(false);
|
||||||
let dockerHealth = $state<DockerHealth | null>(null);
|
let dockerHealth = $state<DockerHealth | null>(null);
|
||||||
|
let proxyHealth = $state<ProxyHealth | null>(null);
|
||||||
let healthChecked = $state(false);
|
let healthChecked = $state(false);
|
||||||
let healthInterval: ReturnType<typeof setInterval> | null = null;
|
let healthInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let hintsExpanded = $state(false);
|
let hintsExpanded = $state(false);
|
||||||
|
let proxyHintsExpanded = $state(false);
|
||||||
|
|
||||||
const dockerConnected = $derived(dockerHealth?.connected ?? 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.
|
// Hide sidebar and chrome on the login page.
|
||||||
const isLoginPage = $derived($page.url.pathname === '/login');
|
const isLoginPage = $derived($page.url.pathname === '/login');
|
||||||
@@ -99,8 +104,10 @@
|
|||||||
try {
|
try {
|
||||||
const h = await getHealth();
|
const h = await getHealth();
|
||||||
dockerHealth = h.docker;
|
dockerHealth = h.docker;
|
||||||
|
proxyHealth = h.proxy ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
dockerHealth = { connected: false };
|
dockerHealth = { connected: false };
|
||||||
|
proxyHealth = null;
|
||||||
}
|
}
|
||||||
healthChecked = true;
|
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" />
|
<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'}
|
{: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" />
|
<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'}
|
{: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" />
|
<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'}
|
{:else if item.icon === 'settings'}
|
||||||
@@ -186,44 +195,63 @@
|
|||||||
<!-- Footer controls -->
|
<!-- Footer controls -->
|
||||||
<div class="space-y-3 border-t border-[var(--border-primary)] px-4 py-3">
|
<div class="space-y-3 border-t border-[var(--border-primary)] px-4 py-3">
|
||||||
{#if healthChecked}
|
{#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
|
<button
|
||||||
type="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; }}
|
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}
|
{#if dockerConnected}
|
||||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50"></span>
|
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="relative inline-flex h-2 w-2 rounded-full {dockerConnected ? 'bg-emerald-500' : 'bg-red-500'}"></span>
|
<span class="relative inline-flex h-2 w-2 rounded-full {dockerConnected ? 'bg-emerald-500' : 'bg-red-500'}"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex-1 text-left">Docker {dockerConnected ? $t('health.connected') : $t('health.disconnected')}</span>
|
Docker
|
||||||
{#if !dockerConnected && dockerHealth?.error}
|
|
||||||
<IconChevronDown size={12} class="transition-transform {hintsExpanded ? 'rotate-180' : ''}" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
{#if !dockerConnected && hintsExpanded && dockerHealth?.error}
|
{#if proxyHealth && proxyProviderName !== 'none'}
|
||||||
<div class="px-2 pb-2">
|
<button
|
||||||
<code class="block text-[11px] text-red-600 dark:text-red-400 break-all leading-relaxed">{dockerHealth.error}</code>
|
type="button"
|
||||||
<button
|
class="flex items-center gap-1.5 {proxyConnected ? 'text-emerald-600' : 'text-red-500'}"
|
||||||
type="button"
|
title={proxyConnected ? (proxyProviderName === 'npm' ? 'NPM' : 'Traefik') + ' connected' : proxyHealth.error ?? 'Proxy disconnected'}
|
||||||
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={() => { if (!proxyConnected) proxyHintsExpanded = !proxyHintsExpanded; }}
|
||||||
onclick={async () => {
|
>
|
||||||
try {
|
<span class="relative flex h-2 w-2 shrink-0">
|
||||||
const h = await getHealth();
|
{#if proxyConnected}
|
||||||
dockerHealth = h.docker;
|
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50"></span>
|
||||||
} catch {
|
{/if}
|
||||||
dockerHealth = { connected: false };
|
<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>
|
||||||
{$t('health.retryNow')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
|
|
||||||
let projectName = $state('');
|
let projectName = $state('');
|
||||||
let port = $state('');
|
let port = $state('');
|
||||||
let healthcheck = $state('');
|
|
||||||
let stage = $state('dev');
|
let stage = $state('dev');
|
||||||
let subdomain = $state('');
|
let subdomain = $state('');
|
||||||
let envVars = $state('');
|
let envVars = $state('');
|
||||||
@@ -78,9 +77,10 @@
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePort(value: string): string {
|
function validatePort(value: string | number): string {
|
||||||
if (!value.trim()) return $t('validation.required', { field: 'Port' });
|
const s = String(value ?? '');
|
||||||
const num = parseInt(value, 10);
|
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');
|
if (isNaN(num) || num < 1 || num > 65535) return $t('validation.invalidPort');
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
inspectResult = result;
|
inspectResult = result;
|
||||||
projectName = deriveProjectName(result.image);
|
projectName = deriveProjectName(result.image);
|
||||||
port = result.port?.toString() ?? '';
|
port = result.port?.toString() ?? '';
|
||||||
healthcheck = result.healthcheck ?? '';
|
// Healthcheck auto-detected but not shown — user can configure later on project page.
|
||||||
stage = 'dev';
|
stage = 'dev';
|
||||||
subdomain = '';
|
subdomain = '';
|
||||||
envVars = '';
|
envVars = '';
|
||||||
@@ -151,8 +151,7 @@
|
|||||||
inspectResult = null;
|
inspectResult = null;
|
||||||
projectName = '';
|
projectName = '';
|
||||||
port = '';
|
port = '';
|
||||||
healthcheck = '';
|
stage = 'dev';
|
||||||
stage = 'dev';
|
|
||||||
subdomain = '';
|
subdomain = '';
|
||||||
envVars = '';
|
envVars = '';
|
||||||
}
|
}
|
||||||
@@ -274,7 +273,6 @@
|
|||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<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.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.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">
|
<div class="flex flex-col gap-1.5">
|
||||||
<label for="stage" class="text-sm font-medium text-[var(--text-primary)]">{$t('quickDeploy.stage')}</label>
|
<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">
|
<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>
|
||||||
|
|
||||||
<div class="mt-4">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let showPassword = $state(false);
|
let showPassword = $state(false);
|
||||||
|
let authMode = $state('local');
|
||||||
|
|
||||||
// Apply theme on login page too.
|
// Apply theme on login page too.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -19,6 +20,15 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
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');
|
const urlToken = $page.url.searchParams.get('token');
|
||||||
if (urlToken) {
|
if (urlToken) {
|
||||||
// Validate the token against the backend before trusting it.
|
// Validate the token against the backend before trusting it.
|
||||||
@@ -140,6 +150,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{#if authMode === 'oidc'}
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-0 flex items-center">
|
<div class="absolute inset-0 flex items-center">
|
||||||
@@ -157,6 +168,7 @@
|
|||||||
{$t('login.ssoButton')}
|
{$t('login.ssoButton')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>>({});
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
function validateDomain(value: 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');
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) return $t('validation.invalidDomain');
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -52,10 +52,11 @@
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePollingInterval(value: string): string {
|
function validatePollingInterval(value: string | number): string {
|
||||||
if (!value.trim()) return '';
|
const s = String(value ?? '');
|
||||||
const num = parseInt(value, 10);
|
if (!s.trim()) return '';
|
||||||
if (isNaN(num) || num < 10 || num > 86400) return $t('validation.invalidPollingInterval');
|
const num = parseInt(s, 10);
|
||||||
|
if (isNaN(num) || num < 60 || num > 86400) return $t('validation.invalidPollingInterval');
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@
|
|||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
|
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(),
|
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
|
||||||
proxy_provider: proxyProvider,
|
proxy_provider: proxyProvider,
|
||||||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
|
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)]">
|
<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>
|
<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">
|
<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.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')} />
|
<FormField label={$t('settingsGeneral.dockerNetwork')} name="network" bind:value={network} placeholder="staging-net" helpText={$t('settingsGeneral.dockerNetworkHelp')} />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script lang="ts">
|
<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 type { EntityPickerItem } from '$lib/types';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
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 loading = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let testing = $state(false);
|
||||||
let npmUrl = $state('');
|
let npmUrl = $state('');
|
||||||
let npmEmail = $state('');
|
let npmEmail = $state('');
|
||||||
let npmPassword = $state('');
|
let npmPassword = $state('');
|
||||||
@@ -41,12 +42,40 @@
|
|||||||
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
|
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
|
||||||
npmPassword = '';
|
npmPassword = '';
|
||||||
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
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; }
|
} 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() {
|
async function handleSaveNpm() {
|
||||||
if (!validateNpmForm()) return;
|
if (!validateNpmForm()) return;
|
||||||
saving = true;
|
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 {
|
try {
|
||||||
const payload: Record<string, unknown> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() };
|
const payload: Record<string, unknown> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() };
|
||||||
if (npmPassword.trim()) payload.npm_password = npmPassword.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.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')} />
|
<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">
|
<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}
|
{#if saving}<IconLoader size={16} />{/if}
|
||||||
{saving ? $t('settingsCredentials.saving') : $t('settingsCredentials.save')}
|
{saving ? $t('settingsCredentials.saving') : $t('settingsCredentials.save')}
|
||||||
</button>
|
</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}
|
{#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">
|
<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')}
|
{$t('common.cancel')}
|
||||||
|
|||||||
Reference in New Issue
Block a user