refactor: remove standalone proxies, add Traefik provider with Docker labels
Standalone proxy removal: - Delete store, API handlers, proxy manager, health monitor, validator, hints - Delete frontend pages (proxies list, create, edit) and components (ProxyCard, ProxyForm, ProxyFilter, ProxyGroup, ValidationChecklist) - Remove proxy routes from router, nav items, dashboard references - Clean up SystemHealthCard to remove proxy section Traefik provider: - Add TraefikProvider implementing proxy.Provider via Docker labels - ContainerLabels() returns traefik.enable, router rule, entrypoints, service port, TLS cert resolver, docker network - ConfigureRoute() returns router name (labels handle routing at container creation) - DeleteRoute() is no-op (container removal auto-deregisters) - Ping() checks Traefik API health (optional) - Wire ContainerLabels into deployer (executeDeploy + blueGreenDeploy) - Add Traefik settings: entrypoint, cert_resolver, network, api_url - Add traefik option to proxy provider selector in settings UI - Show conditional Traefik config fields - Add i18n keys (EN + RU)
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
"Bash(go vet:*)",
|
"Bash(go vet:*)",
|
||||||
"Bash(git checkout:*)",
|
"Bash(git checkout:*)",
|
||||||
"Bash(git stash:*)",
|
"Bash(git stash:*)",
|
||||||
"Bash(echo \"EXIT: $?\")"
|
"Bash(echo \"EXIT: $?\")",
|
||||||
|
"Bash(./scripts/dev-server.sh)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"C:\\Users\\Alexei\\Documents\\docker-watcher\\internal",
|
"C:\\Users\\Alexei\\Documents\\docker-watcher\\internal",
|
||||||
|
|||||||
+8
-11
@@ -101,6 +101,14 @@ func main() {
|
|||||||
case "none":
|
case "none":
|
||||||
proxyProvider = proxy.NewNoneProvider()
|
proxyProvider = proxy.NewNoneProvider()
|
||||||
slog.Info("proxy provider: none")
|
slog.Info("proxy provider: none")
|
||||||
|
case "traefik":
|
||||||
|
proxyProvider = proxy.NewTraefikProvider(
|
||||||
|
settings.TraefikEntrypoint,
|
||||||
|
settings.TraefikCertResolver,
|
||||||
|
settings.TraefikNetwork,
|
||||||
|
settings.TraefikAPIURL,
|
||||||
|
)
|
||||||
|
slog.Info("proxy provider: traefik", "entrypoint", settings.TraefikEntrypoint)
|
||||||
default:
|
default:
|
||||||
// Default to NPM for backward compatibility (including "npm" and empty string).
|
// Default to NPM for backward compatibility (including "npm" and empty string).
|
||||||
npmPassword := ""
|
npmPassword := ""
|
||||||
@@ -164,13 +172,6 @@ func main() {
|
|||||||
slog.Warn("failed to start stale scanner", "error", err)
|
slog.Warn("failed to start stale scanner", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize proxy manager and health monitor.
|
|
||||||
proxyManager := proxy.NewManager(db, proxyProvider)
|
|
||||||
proxyHealth := proxy.NewHealthMonitor(db, eventBus)
|
|
||||||
if err := proxyHealth.Start("5m"); err != nil {
|
|
||||||
slog.Warn("failed to start proxy health monitor", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start daily event log pruning cron job.
|
// Start daily event log pruning cron job.
|
||||||
cronScheduler := cron.New()
|
cronScheduler := cron.New()
|
||||||
if _, err := cronScheduler.AddFunc("@daily", func() {
|
if _, err := cronScheduler.AddFunc("@daily", func() {
|
||||||
@@ -220,7 +221,6 @@ func main() {
|
|||||||
dnsProvider := initDNSProvider(settings, encKey)
|
dnsProvider := initDNSProvider(settings, encKey)
|
||||||
if dnsProvider != nil {
|
if dnsProvider != nil {
|
||||||
dep.SetDNSProvider(dnsProvider)
|
dep.SetDNSProvider(dnsProvider)
|
||||||
proxyManager.SetDNSProvider(dnsProvider)
|
|
||||||
slog.Info("DNS provider initialized", "provider", settings.DNSProvider)
|
slog.Info("DNS provider initialized", "provider", settings.DNSProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,14 +283,12 @@ func main() {
|
|||||||
// Build API server.
|
// Build API server.
|
||||||
apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, webhookHandler, eventBus, encKey)
|
apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, webhookHandler, eventBus, encKey)
|
||||||
apiServer.SetStaleScanner(staleScanner)
|
apiServer.SetStaleScanner(staleScanner)
|
||||||
apiServer.SetProxyManager(proxyManager)
|
|
||||||
apiServer.SetBackupEngine(backupEngine)
|
apiServer.SetBackupEngine(backupEngine)
|
||||||
apiServer.SetDBPath(dbPath)
|
apiServer.SetDBPath(dbPath)
|
||||||
apiServer.SetBackupSettingsChangedCallback(scheduleAutobackup)
|
apiServer.SetBackupSettingsChangedCallback(scheduleAutobackup)
|
||||||
apiServer.SetDNSProvider(dnsProvider)
|
apiServer.SetDNSProvider(dnsProvider)
|
||||||
apiServer.SetDNSProviderChangedCallback(func(provider dns.Provider) {
|
apiServer.SetDNSProviderChangedCallback(func(provider dns.Provider) {
|
||||||
dep.SetDNSProvider(provider)
|
dep.SetDNSProvider(provider)
|
||||||
proxyManager.SetDNSProvider(provider)
|
|
||||||
})
|
})
|
||||||
router := apiServer.Router()
|
router := apiServer.Router()
|
||||||
|
|
||||||
@@ -340,7 +338,6 @@ func main() {
|
|||||||
// Stop accepting new work.
|
// Stop accepting new work.
|
||||||
cronScheduler.Stop()
|
cronScheduler.Stop()
|
||||||
eventBus.Unsubscribe(notifySub)
|
eventBus.Unsubscribe(notifySub)
|
||||||
proxyHealth.Stop()
|
|
||||||
staleScanner.Stop()
|
staleScanner.Stop()
|
||||||
poller.Stop()
|
poller.Stop()
|
||||||
|
|
||||||
|
|||||||
@@ -202,12 +202,6 @@ func (s *Server) buildConsumerNameMap() map[string]string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standalone proxy consumers: "standalone:id" -> domain
|
|
||||||
proxies, _ := s.store.ListStandaloneProxies()
|
|
||||||
for _, p := range proxies {
|
|
||||||
names["standalone:"+p.ID] = p.Domain
|
|
||||||
}
|
|
||||||
|
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,13 +367,5 @@ func (s *Server) computeExpectedFQDNs(settings store.Settings) (map[string]strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standalone proxies.
|
|
||||||
proxies, _ := s.store.ListStandaloneProxies()
|
|
||||||
for _, p := range proxies {
|
|
||||||
if p.Domain != "" {
|
|
||||||
expected[p.Domain] = "standalone:" + p.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return expected, nil
|
return expected, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/docker-watcher/internal/proxy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// validateProxy runs the validation pipeline without creating a proxy.
|
|
||||||
// POST /api/proxies/validate
|
|
||||||
func (s *Server) validateProxy(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Host string `json:"host"`
|
|
||||||
Port int `json:"port"`
|
|
||||||
}
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Host == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "host is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Port < 1 || req.Port > 65535 {
|
|
||||||
respondError(w, http.StatusBadRequest, "port must be between 1 and 65535")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
result := proxy.ValidateDestination(ctx, req.Host, req.Port)
|
|
||||||
respondJSON(w, http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// createProxy creates a new standalone proxy.
|
|
||||||
// POST /api/proxies
|
|
||||||
func (s *Server) createProxy(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.proxyManager == nil {
|
|
||||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req proxy.CreateProxyRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Domain == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "domain is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.DestinationURL == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "destination_url is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.DestinationPort < 1 || req.DestinationPort > 65535 {
|
|
||||||
respondError(w, http.StatusBadRequest, "destination_port must be between 1 and 65535")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := s.proxyManager.CreateProxy(r.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to create proxy", "domain", req.Domain, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to create proxy")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusCreated, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// listProxies returns all standalone proxies.
|
|
||||||
// GET /api/proxies
|
|
||||||
func (s *Server) listProxies(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.proxyManager == nil {
|
|
||||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
proxies, err := s.proxyManager.ListProxies()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("proxy operation failed", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "proxy operation failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, proxies)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getProxy returns a single standalone proxy.
|
|
||||||
// GET /api/proxies/{id}
|
|
||||||
func (s *Server) getProxy(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.proxyManager == nil {
|
|
||||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
p, err := s.proxyManager.GetProxy(id)
|
|
||||||
if err != nil {
|
|
||||||
if proxy.IsNotFound(err) {
|
|
||||||
respondNotFound(w, "proxy")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("proxy operation failed", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "proxy operation failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateProxy updates an existing standalone proxy.
|
|
||||||
// PUT /api/proxies/{id}
|
|
||||||
func (s *Server) updateProxy(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.proxyManager == nil {
|
|
||||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
var req proxy.UpdateProxyRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Domain == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "domain is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.DestinationURL == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "destination_url is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.DestinationPort < 1 || req.DestinationPort > 65535 {
|
|
||||||
respondError(w, http.StatusBadRequest, "destination_port must be between 1 and 65535")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := s.proxyManager.UpdateProxy(r.Context(), id, req)
|
|
||||||
if err != nil {
|
|
||||||
if proxy.IsNotFound(err) {
|
|
||||||
respondNotFound(w, "proxy")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("proxy operation failed", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "proxy operation failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteProxy removes a standalone proxy.
|
|
||||||
// DELETE /api/proxies/{id}
|
|
||||||
func (s *Server) deleteProxy(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.proxyManager == nil {
|
|
||||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
if err := s.proxyManager.DeleteProxy(r.Context(), id); err != nil {
|
|
||||||
if proxy.IsNotFound(err) {
|
|
||||||
respondNotFound(w, "proxy")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("proxy operation failed", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "proxy operation failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
|
||||||
}
|
|
||||||
|
|
||||||
// listAllProxies returns a merged view of standalone and deploy-managed proxies.
|
|
||||||
// GET /api/proxies/all
|
|
||||||
func (s *Server) listAllProxies(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.proxyManager == nil {
|
|
||||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
views, err := s.proxyManager.ListAllProxies()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("proxy operation failed", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "proxy operation failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, views)
|
|
||||||
}
|
|
||||||
+1
-25
@@ -21,7 +21,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// DNSProviderChangedFunc is called when DNS settings change so the caller can
|
// DNSProviderChangedFunc is called when DNS settings change so the caller can
|
||||||
// update the provider on the deployer and proxy manager.
|
// update the provider on the deployer.
|
||||||
type DNSProviderChangedFunc func(provider dns.Provider)
|
type DNSProviderChangedFunc func(provider dns.Provider)
|
||||||
|
|
||||||
// Server holds all dependencies for the API layer.
|
// Server holds all dependencies for the API layer.
|
||||||
@@ -37,7 +37,6 @@ type Server struct {
|
|||||||
localAuth *auth.LocalAuth
|
localAuth *auth.LocalAuth
|
||||||
oidcProvider *auth.OIDCProvider
|
oidcProvider *auth.OIDCProvider
|
||||||
staleScanner *stale.Scanner
|
staleScanner *stale.Scanner
|
||||||
proxyManager *proxy.Manager
|
|
||||||
|
|
||||||
dnsProviderMu sync.RWMutex
|
dnsProviderMu sync.RWMutex
|
||||||
dnsProvider dns.Provider
|
dnsProvider dns.Provider
|
||||||
@@ -89,12 +88,6 @@ func (s *Server) SetStaleScanner(scanner *stale.Scanner) {
|
|||||||
s.staleScanner = scanner
|
s.staleScanner = scanner
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetProxyManager sets the proxy manager on the server.
|
|
||||||
// Called after both the API server and proxy manager are initialized.
|
|
||||||
func (s *Server) SetProxyManager(pm *proxy.Manager) {
|
|
||||||
s.proxyManager = pm
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetBackupEngine sets the backup engine on the server.
|
// SetBackupEngine sets the backup engine on the server.
|
||||||
func (s *Server) SetBackupEngine(engine *backup.Engine) {
|
func (s *Server) SetBackupEngine(engine *backup.Engine) {
|
||||||
s.backupEngine = engine
|
s.backupEngine = engine
|
||||||
@@ -261,19 +254,6 @@ func (s *Server) Router() chi.Router {
|
|||||||
// Stale container endpoints (read).
|
// Stale container endpoints (read).
|
||||||
r.Get("/containers/stale", s.listStaleContainers)
|
r.Get("/containers/stale", s.listStaleContainers)
|
||||||
|
|
||||||
// Proxy endpoints (read-only for any authenticated user).
|
|
||||||
r.Get("/proxies", s.listProxies)
|
|
||||||
r.Get("/proxies/all", s.listAllProxies)
|
|
||||||
r.Route("/proxies/{id}", func(r chi.Router) {
|
|
||||||
r.Get("/", s.getProxy)
|
|
||||||
// Admin-only proxy mutations.
|
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(auth.AdminOnly)
|
|
||||||
r.Put("/", s.updateProxy)
|
|
||||||
r.Delete("/", s.deleteProxy)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Admin-only routes: require admin role.
|
// Admin-only routes: require admin role.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(auth.AdminOnly)
|
r.Use(auth.AdminOnly)
|
||||||
@@ -300,10 +280,6 @@ func (s *Server) Router() chi.Router {
|
|||||||
// Registry creation.
|
// Registry creation.
|
||||||
r.Post("/registries", s.createRegistry)
|
r.Post("/registries", s.createRegistry)
|
||||||
|
|
||||||
// Proxy mutation endpoints.
|
|
||||||
r.Post("/proxies/validate", s.validateProxy)
|
|
||||||
r.Post("/proxies", s.createProxy)
|
|
||||||
|
|
||||||
// Stale container cleanup endpoints.
|
// Stale container cleanup endpoints.
|
||||||
// Bulk route must be registered before parameterized route.
|
// Bulk route must be registered before parameterized route.
|
||||||
r.Post("/containers/stale/cleanup", s.bulkCleanupStaleContainers)
|
r.Post("/containers/stale/cleanup", s.bulkCleanupStaleContainers)
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ type settingsRequest struct {
|
|||||||
CloudflareAPIToken string `json:"cloudflare_api_token"`
|
CloudflareAPIToken string `json:"cloudflare_api_token"`
|
||||||
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
|
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
|
||||||
ProxyProvider *string `json:"proxy_provider,omitempty"`
|
ProxyProvider *string `json:"proxy_provider,omitempty"`
|
||||||
|
TraefikEntrypoint *string `json:"traefik_entrypoint,omitempty"`
|
||||||
|
TraefikCertResolver *string `json:"traefik_cert_resolver,omitempty"`
|
||||||
|
TraefikNetwork *string `json:"traefik_network,omitempty"`
|
||||||
|
TraefikAPIURL *string `json:"traefik_api_url,omitempty"`
|
||||||
BackupEnabled *bool `json:"backup_enabled,omitempty"`
|
BackupEnabled *bool `json:"backup_enabled,omitempty"`
|
||||||
BackupIntervalHours *int `json:"backup_interval_hours,omitempty"`
|
BackupIntervalHours *int `json:"backup_interval_hours,omitempty"`
|
||||||
BackupRetentionCount *int `json:"backup_retention_count,omitempty"`
|
BackupRetentionCount *int `json:"backup_retention_count,omitempty"`
|
||||||
@@ -67,6 +71,10 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"has_cloudflare_api_token": settings.CloudflareAPIToken != "",
|
"has_cloudflare_api_token": settings.CloudflareAPIToken != "",
|
||||||
"cloudflare_zone_id": settings.CloudflareZoneID,
|
"cloudflare_zone_id": settings.CloudflareZoneID,
|
||||||
"proxy_provider": settings.ProxyProvider,
|
"proxy_provider": settings.ProxyProvider,
|
||||||
|
"traefik_entrypoint": settings.TraefikEntrypoint,
|
||||||
|
"traefik_cert_resolver": settings.TraefikCertResolver,
|
||||||
|
"traefik_network": settings.TraefikNetwork,
|
||||||
|
"traefik_api_url": settings.TraefikAPIURL,
|
||||||
"backup_enabled": settings.BackupEnabled,
|
"backup_enabled": settings.BackupEnabled,
|
||||||
"backup_interval_hours": settings.BackupIntervalHours,
|
"backup_interval_hours": settings.BackupIntervalHours,
|
||||||
"backup_retention_count": settings.BackupRetentionCount,
|
"backup_retention_count": settings.BackupRetentionCount,
|
||||||
@@ -171,13 +179,27 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Proxy provider setting.
|
// Proxy provider setting.
|
||||||
if req.ProxyProvider != nil {
|
if req.ProxyProvider != nil {
|
||||||
prov := *req.ProxyProvider
|
prov := *req.ProxyProvider
|
||||||
if prov != "" && prov != "none" && prov != "npm" {
|
if prov != "" && prov != "none" && prov != "npm" && prov != "traefik" {
|
||||||
respondError(w, http.StatusBadRequest, "proxy_provider must be 'none' or 'npm'")
|
respondError(w, http.StatusBadRequest, "proxy_provider must be 'none', 'npm', or 'traefik'")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updated.ProxyProvider = prov
|
updated.ProxyProvider = prov
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Traefik provider settings.
|
||||||
|
if req.TraefikEntrypoint != nil {
|
||||||
|
updated.TraefikEntrypoint = *req.TraefikEntrypoint
|
||||||
|
}
|
||||||
|
if req.TraefikCertResolver != nil {
|
||||||
|
updated.TraefikCertResolver = *req.TraefikCertResolver
|
||||||
|
}
|
||||||
|
if req.TraefikNetwork != nil {
|
||||||
|
updated.TraefikNetwork = *req.TraefikNetwork
|
||||||
|
}
|
||||||
|
if req.TraefikAPIURL != nil {
|
||||||
|
updated.TraefikAPIURL = *req.TraefikAPIURL
|
||||||
|
}
|
||||||
|
|
||||||
// Backup settings.
|
// Backup settings.
|
||||||
if req.BackupEnabled != nil {
|
if req.BackupEnabled != nil {
|
||||||
updated.BackupEnabled = *req.BackupEnabled
|
updated.BackupEnabled = *req.BackupEnabled
|
||||||
|
|||||||
@@ -89,6 +89,19 @@ func (d *Deployer) blueGreenDeploy(
|
|||||||
Mounts: mounts,
|
Mounts: mounts,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set proxy labels for providers that use Docker labels (e.g., Traefik).
|
||||||
|
if stage.EnableProxy {
|
||||||
|
fqdn := subdomain + "." + settings.Domain
|
||||||
|
if proxyLabels := d.proxy.ContainerLabels(fqdn, project.Port); proxyLabels != nil {
|
||||||
|
if containerCfg.Labels == nil {
|
||||||
|
containerCfg.Labels = make(map[string]string)
|
||||||
|
}
|
||||||
|
for k, v := range proxyLabels {
|
||||||
|
containerCfg.Labels[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info")
|
d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info")
|
||||||
containerID, err := d.docker.CreateContainer(ctx, containerCfg)
|
containerID, err := d.docker.CreateContainer(ctx, containerCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -315,6 +315,19 @@ func (d *Deployer) executeDeploy(
|
|||||||
Mounts: mounts,
|
Mounts: mounts,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set proxy labels for providers that use Docker labels (e.g., Traefik).
|
||||||
|
if stage.EnableProxy {
|
||||||
|
fqdn := subdomain + "." + settings.Domain
|
||||||
|
if proxyLabels := d.proxy.ContainerLabels(fqdn, project.Port); proxyLabels != nil {
|
||||||
|
if containerCfg.Labels == nil {
|
||||||
|
containerCfg.Labels = make(map[string]string)
|
||||||
|
}
|
||||||
|
for k, v := range proxyLabels {
|
||||||
|
containerCfg.Labels[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Creating container %s", containerName), "info")
|
d.logDeploy(deployID, fmt.Sprintf("Creating container %s", containerName), "info")
|
||||||
containerID, err = d.docker.CreateContainer(ctx, containerCfg)
|
containerID, err = d.docker.CreateContainer(ctx, containerCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/alexei/docker-watcher/internal/events"
|
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HealthMonitor periodically checks the health of all standalone proxies.
|
|
||||||
type HealthMonitor struct {
|
|
||||||
store *store.Store
|
|
||||||
eventBus *events.Bus
|
|
||||||
|
|
||||||
cron *cron.Cron
|
|
||||||
mu sync.Mutex
|
|
||||||
entryID cron.EntryID
|
|
||||||
running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHealthMonitor creates a new proxy health monitor.
|
|
||||||
func NewHealthMonitor(st *store.Store, eventBus *events.Bus) *HealthMonitor {
|
|
||||||
return &HealthMonitor{
|
|
||||||
store: st,
|
|
||||||
eventBus: eventBus,
|
|
||||||
cron: cron.New(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins periodic health checks with the given interval (e.g., "5m", "1m").
|
|
||||||
// If already running, it stops and restarts with the new interval.
|
|
||||||
func (h *HealthMonitor) Start(interval string) error {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
duration, err := time.ParseDuration(interval)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse health check interval %q: %w", interval, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.running {
|
|
||||||
h.cron.Remove(h.entryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
spec := fmt.Sprintf("@every %s", duration.String())
|
|
||||||
entryID, err := h.cron.AddFunc(spec, func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
if checkErr := h.CheckAll(ctx); checkErr != nil {
|
|
||||||
slog.Warn("proxy health monitor: check error", "error", checkErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("schedule proxy health monitor: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h.entryID = entryID
|
|
||||||
if !h.running {
|
|
||||||
h.cron.Start()
|
|
||||||
}
|
|
||||||
h.running = true
|
|
||||||
|
|
||||||
slog.Info("proxy health monitor started", "interval", duration.String())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop gracefully shuts down the health monitor.
|
|
||||||
func (h *HealthMonitor) Stop() {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
if h.running {
|
|
||||||
ctx := h.cron.Stop()
|
|
||||||
<-ctx.Done()
|
|
||||||
h.running = false
|
|
||||||
slog.Info("proxy health monitor stopped")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckAll performs a single health check cycle for all standalone proxies.
|
|
||||||
func (h *HealthMonitor) CheckAll(ctx context.Context) error {
|
|
||||||
proxies, err := h.store.ListStandaloneProxies()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("list standalone proxies: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, proxy := range proxies {
|
|
||||||
newStatus := checkProxyHealth(ctx, proxy.DestinationURL, proxy.DestinationPort)
|
|
||||||
oldStatus := proxy.HealthStatus
|
|
||||||
|
|
||||||
if err := h.store.UpdateProxyHealth(proxy.ID, newStatus); err != nil {
|
|
||||||
slog.Warn("proxy health monitor: failed to update health",
|
|
||||||
"proxy_id", proxy.ID, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit event on status change.
|
|
||||||
if oldStatus != newStatus && oldStatus != "unknown" {
|
|
||||||
h.emitHealthEvent(proxy, oldStatus, newStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkProxyHealth performs an HTTP GET to the destination and returns the health status.
|
|
||||||
func checkProxyHealth(ctx context.Context, host string, port int) string {
|
|
||||||
target := fmt.Sprintf("http://%s:%d/", host, port)
|
|
||||||
|
|
||||||
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "unhealthy"
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "unhealthy"
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 500 {
|
|
||||||
return "unhealthy"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "healthy"
|
|
||||||
}
|
|
||||||
|
|
||||||
// emitHealthEvent persists and publishes a health status change event.
|
|
||||||
func (h *HealthMonitor) emitHealthEvent(proxy store.StandaloneProxy, oldStatus, newStatus string) {
|
|
||||||
severity := "info"
|
|
||||||
if newStatus == "unhealthy" {
|
|
||||||
severity = "warn"
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := fmt.Sprintf("Proxy %s (%s) health changed: %s -> %s",
|
|
||||||
proxy.Domain, proxy.ID, oldStatus, newStatus)
|
|
||||||
|
|
||||||
metadata, _ := json.Marshal(map[string]any{
|
|
||||||
"proxy_id": proxy.ID,
|
|
||||||
"domain": proxy.Domain,
|
|
||||||
"old_status": oldStatus,
|
|
||||||
"new_status": newStatus,
|
|
||||||
})
|
|
||||||
|
|
||||||
evt, err := h.store.InsertEvent(store.EventLog{
|
|
||||||
Source: "proxy_health",
|
|
||||||
Severity: severity,
|
|
||||||
Message: msg,
|
|
||||||
Metadata: string(metadata),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("proxy health monitor: failed to persist event", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventLog,
|
|
||||||
Payload: events.EventLogPayload{
|
|
||||||
ID: evt.ID,
|
|
||||||
Source: "proxy_health",
|
|
||||||
Severity: severity,
|
|
||||||
Message: msg,
|
|
||||||
Metadata: string(metadata),
|
|
||||||
CreatedAt: evt.CreatedAt,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// diagnosticHint returns a user-friendly suggestion for a validation failure.
|
|
||||||
func diagnosticHint(step string, err error) string {
|
|
||||||
if err == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
switch step {
|
|
||||||
case StepDNS:
|
|
||||||
return "Domain cannot be resolved. Check DNS settings or use an IP address."
|
|
||||||
|
|
||||||
case StepTCP:
|
|
||||||
return tcpHintFromError(err)
|
|
||||||
|
|
||||||
case StepHTTP:
|
|
||||||
return httpHint(err.Error())
|
|
||||||
|
|
||||||
default:
|
|
||||||
return "Validation failed: " + err.Error()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tcpHintFromError returns a specific hint based on the TCP error type.
|
|
||||||
func tcpHintFromError(err error) string {
|
|
||||||
if err == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var opErr *net.OpError
|
|
||||||
if errors.As(err, &opErr) {
|
|
||||||
lower := strings.ToLower(opErr.Err.Error())
|
|
||||||
switch {
|
|
||||||
case strings.Contains(lower, "connection refused"):
|
|
||||||
return "Port is not accepting connections. Check if the service is running and the port is correct."
|
|
||||||
case strings.Contains(lower, "i/o timeout") || strings.Contains(lower, "timeout"):
|
|
||||||
return "Connection timed out. Possible firewall blocking. Check network/firewall rules."
|
|
||||||
case strings.Contains(lower, "no route to host") || strings.Contains(lower, "host is unreachable"):
|
|
||||||
return "Host is not reachable. Verify the IP address and network connectivity."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := err.Error()
|
|
||||||
lower := strings.ToLower(msg)
|
|
||||||
switch {
|
|
||||||
case strings.Contains(lower, "connection refused"):
|
|
||||||
return "Port is not accepting connections. Check if the service is running and the port is correct."
|
|
||||||
case strings.Contains(lower, "timeout"):
|
|
||||||
return "Connection timed out. Possible firewall blocking. Check network/firewall rules."
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("TCP connection failed: %s", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// httpHint returns a specific hint based on the HTTP probe result.
|
|
||||||
func httpHint(msg string) string {
|
|
||||||
lower := strings.ToLower(msg)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case strings.Contains(lower, "status"):
|
|
||||||
return msg // Already formatted by the caller with the status code.
|
|
||||||
case strings.Contains(lower, "timeout"):
|
|
||||||
return "HTTP health probe timed out. The service may be slow or unresponsive."
|
|
||||||
default:
|
|
||||||
return "HTTP health probe failed: " + msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/alexei/docker-watcher/internal/dns"
|
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Manager handles the lifecycle of standalone proxy hosts.
|
|
||||||
type Manager struct {
|
|
||||||
store *store.Store
|
|
||||||
provider Provider
|
|
||||||
dnsMu sync.RWMutex
|
|
||||||
dns dns.Provider // nil when wildcard DNS is active
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManager creates a new proxy manager.
|
|
||||||
func NewManager(st *store.Store, provider Provider) *Manager {
|
|
||||||
return &Manager{
|
|
||||||
store: st,
|
|
||||||
provider: provider,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDNSProvider sets the DNS provider for managing DNS records.
|
|
||||||
func (m *Manager) SetDNSProvider(provider dns.Provider) {
|
|
||||||
m.dnsMu.Lock()
|
|
||||||
defer m.dnsMu.Unlock()
|
|
||||||
m.dns = provider
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDNS returns the current DNS provider under read lock.
|
|
||||||
func (m *Manager) getDNS() dns.Provider {
|
|
||||||
m.dnsMu.RLock()
|
|
||||||
defer m.dnsMu.RUnlock()
|
|
||||||
return m.dns
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateProxyRequest is the input for creating a standalone proxy.
|
|
||||||
type CreateProxyRequest struct {
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
DestinationURL string `json:"destination_url"`
|
|
||||||
DestinationPort int `json:"destination_port"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProxyRequest is the input for updating a standalone proxy.
|
|
||||||
type UpdateProxyRequest struct {
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
DestinationURL string `json:"destination_url"`
|
|
||||||
DestinationPort int `json:"destination_port"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProxyView is a unified view of both standalone and deploy-managed proxies.
|
|
||||||
type ProxyView struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
Destination string `json:"destination"`
|
|
||||||
Type string `json:"type"` // "standalone" or "managed"
|
|
||||||
ProjectName string `json:"project_name,omitempty"`
|
|
||||||
StageName string `json:"stage_name,omitempty"`
|
|
||||||
HealthStatus string `json:"health_status"`
|
|
||||||
SSLEnabled bool `json:"ssl_enabled"`
|
|
||||||
NpmProxyID int `json:"npm_proxy_id"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateProxy validates the destination, creates a proxy route via the provider, and saves to the store.
|
|
||||||
func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (store.StandaloneProxy, error) {
|
|
||||||
// Validate destination.
|
|
||||||
result := ValidateDestination(ctx, req.DestinationURL, req.DestinationPort)
|
|
||||||
if !result.Valid {
|
|
||||||
return store.StandaloneProxy{}, fmt.Errorf("destination validation failed: %s", lastFailedStep(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load settings for SSL certificate and domain.
|
|
||||||
settings, err := m.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
return store.StandaloneProxy{}, fmt.Errorf("get settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create proxy route via provider.
|
|
||||||
routeID, err := m.provider.ConfigureRoute(ctx, req.Domain, req.DestinationURL, req.DestinationPort, RouteOptions{
|
|
||||||
SSLCertificateID: settings.SSLCertificateID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return store.StandaloneProxy{}, fmt.Errorf("create proxy route: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("created proxy route for standalone proxy",
|
|
||||||
"domain", req.Domain, "route_id", routeID, "provider", m.provider.Name())
|
|
||||||
|
|
||||||
// Save to store.
|
|
||||||
proxy, err := m.store.CreateStandaloneProxy(store.StandaloneProxy{
|
|
||||||
Domain: req.Domain,
|
|
||||||
DestinationURL: req.DestinationURL,
|
|
||||||
DestinationPort: req.DestinationPort,
|
|
||||||
SSLCertificateID: settings.SSLCertificateID,
|
|
||||||
HealthStatus: "unknown",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
// Best effort: clean up the proxy route if store insert fails.
|
|
||||||
if delErr := m.provider.DeleteRoute(ctx, routeID); delErr != nil {
|
|
||||||
slog.Error("failed to clean up proxy route after store error",
|
|
||||||
"route_id", routeID, "error", delErr)
|
|
||||||
}
|
|
||||||
return store.StandaloneProxy{}, fmt.Errorf("save standalone proxy: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create DNS record after successful store save.
|
|
||||||
m.ensureDNS(ctx, req.Domain, proxy.ID)
|
|
||||||
|
|
||||||
return proxy, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProxy re-validates the destination, updates the proxy route via the provider, and updates the store.
|
|
||||||
func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyRequest) (store.StandaloneProxy, error) {
|
|
||||||
existing, err := m.store.GetStandaloneProxy(id)
|
|
||||||
if err != nil {
|
|
||||||
return store.StandaloneProxy{}, fmt.Errorf("get proxy: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate new destination.
|
|
||||||
result := ValidateDestination(ctx, req.DestinationURL, req.DestinationPort)
|
|
||||||
if !result.Valid {
|
|
||||||
return store.StandaloneProxy{}, fmt.Errorf("destination validation failed: %s", lastFailedStep(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load settings for SSL certificate.
|
|
||||||
settings, err := m.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
return store.StandaloneProxy{}, fmt.Errorf("get settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update proxy route via provider (ConfigureRoute handles create-or-update).
|
|
||||||
if _, err := m.provider.ConfigureRoute(ctx, req.Domain, req.DestinationURL, req.DestinationPort, RouteOptions{
|
|
||||||
SSLCertificateID: settings.SSLCertificateID,
|
|
||||||
}); err != nil {
|
|
||||||
return store.StandaloneProxy{}, fmt.Errorf("update proxy route: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update store.
|
|
||||||
updated := existing
|
|
||||||
updated.Domain = req.Domain
|
|
||||||
updated.DestinationURL = req.DestinationURL
|
|
||||||
updated.DestinationPort = req.DestinationPort
|
|
||||||
updated.SSLCertificateID = settings.SSLCertificateID
|
|
||||||
|
|
||||||
if err := m.store.UpdateStandaloneProxy(updated); err != nil {
|
|
||||||
return store.StandaloneProxy{}, fmt.Errorf("update standalone proxy: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update DNS records if domain changed.
|
|
||||||
if existing.Domain != req.Domain {
|
|
||||||
m.removeDNS(ctx, existing.Domain)
|
|
||||||
m.ensureDNS(ctx, req.Domain, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-read from store to get updated timestamps.
|
|
||||||
return m.store.GetStandaloneProxy(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteProxy removes the proxy route via the provider and deletes from the store.
|
|
||||||
func (m *Manager) DeleteProxy(ctx context.Context, id string) error {
|
|
||||||
p, err := m.store.GetStandaloneProxy(id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get proxy: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete proxy route via provider using the NpmProxyID as a string route ID.
|
|
||||||
if p.NpmProxyID > 0 {
|
|
||||||
routeID := fmt.Sprintf("%d", p.NpmProxyID)
|
|
||||||
if err := m.provider.DeleteRoute(ctx, routeID); err != nil {
|
|
||||||
slog.Warn("failed to delete proxy route (continuing with store deletion)",
|
|
||||||
"route_id", routeID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove DNS record.
|
|
||||||
m.removeDNS(ctx, p.Domain)
|
|
||||||
|
|
||||||
if err := m.store.DeleteStandaloneProxy(id); err != nil {
|
|
||||||
return fmt.Errorf("delete standalone proxy: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProxy returns a single standalone proxy by ID.
|
|
||||||
func (m *Manager) GetProxy(id string) (store.StandaloneProxy, error) {
|
|
||||||
proxy, err := m.store.GetStandaloneProxy(id)
|
|
||||||
if err != nil {
|
|
||||||
return store.StandaloneProxy{}, fmt.Errorf("get proxy: %w", err)
|
|
||||||
}
|
|
||||||
return proxy, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListProxies returns all standalone proxies.
|
|
||||||
func (m *Manager) ListProxies() ([]store.StandaloneProxy, error) {
|
|
||||||
proxies, err := m.store.ListStandaloneProxies()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list proxies: %w", err)
|
|
||||||
}
|
|
||||||
return proxies, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAllProxies returns a merged view of standalone and deploy-managed proxies.
|
|
||||||
func (m *Manager) ListAllProxies() ([]ProxyView, error) {
|
|
||||||
views := []ProxyView{}
|
|
||||||
|
|
||||||
// Standalone proxies.
|
|
||||||
standalones, err := m.store.ListStandaloneProxies()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list standalone proxies: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range standalones {
|
|
||||||
views = append(views, ProxyView{
|
|
||||||
ID: p.ID,
|
|
||||||
Domain: p.Domain,
|
|
||||||
Destination: fmt.Sprintf("%s:%d", p.DestinationURL, p.DestinationPort),
|
|
||||||
Type: "standalone",
|
|
||||||
HealthStatus: p.HealthStatus,
|
|
||||||
SSLEnabled: p.SSLCertificateID > 0,
|
|
||||||
NpmProxyID: p.NpmProxyID,
|
|
||||||
CreatedAt: p.CreatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deploy-managed proxies: instances with a proxy route configured.
|
|
||||||
instances, err := m.store.ListAllInstances()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list instances: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-load project and stage names to avoid N+1 queries.
|
|
||||||
allProjects, _ := m.store.GetAllProjects()
|
|
||||||
projectNames := make(map[string]string, len(allProjects))
|
|
||||||
for _, p := range allProjects {
|
|
||||||
projectNames[p.ID] = p.Name
|
|
||||||
}
|
|
||||||
stageNames := make(map[string]string)
|
|
||||||
for _, p := range allProjects {
|
|
||||||
stages, _ := m.store.GetStagesByProjectID(p.ID)
|
|
||||||
for _, s := range stages {
|
|
||||||
stageNames[s.ID] = s.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, inst := range instances {
|
|
||||||
if inst.ProxyRouteID == "" && inst.NpmProxyID <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
projectName := projectNames[inst.ProjectID]
|
|
||||||
if projectName == "" {
|
|
||||||
projectName = inst.ProjectID
|
|
||||||
}
|
|
||||||
stageName := stageNames[inst.StageID]
|
|
||||||
if stageName == "" {
|
|
||||||
stageName = inst.StageID
|
|
||||||
}
|
|
||||||
|
|
||||||
cid := inst.ContainerID
|
|
||||||
if len(cid) > 12 {
|
|
||||||
cid = cid[:12]
|
|
||||||
}
|
|
||||||
destination := fmt.Sprintf("%s:%d", cid, inst.Port)
|
|
||||||
if inst.Subdomain != "" {
|
|
||||||
destination = fmt.Sprintf("%s:%d", inst.Subdomain, inst.Port)
|
|
||||||
}
|
|
||||||
|
|
||||||
healthStatus := "unknown"
|
|
||||||
if inst.Status == "running" {
|
|
||||||
healthStatus = "healthy"
|
|
||||||
} else if inst.Status == "stopped" || inst.Status == "failed" {
|
|
||||||
healthStatus = "unhealthy"
|
|
||||||
}
|
|
||||||
|
|
||||||
views = append(views, ProxyView{
|
|
||||||
ID: inst.ID,
|
|
||||||
Domain: inst.Subdomain,
|
|
||||||
Destination: destination,
|
|
||||||
Type: "managed",
|
|
||||||
ProjectName: projectName,
|
|
||||||
StageName: stageName,
|
|
||||||
HealthStatus: healthStatus,
|
|
||||||
SSLEnabled: true, // managed proxies always get SSL from settings
|
|
||||||
NpmProxyID: inst.NpmProxyID,
|
|
||||||
CreatedAt: inst.CreatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return views, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureDNS creates or updates a DNS record for a standalone proxy domain. Best-effort.
|
|
||||||
func (m *Manager) ensureDNS(ctx context.Context, domain, proxyID string) {
|
|
||||||
dnsProvider := m.getDNS()
|
|
||||||
if dnsProvider == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settings, err := m.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("dns: get settings for server IP", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if settings.ServerIP == "" {
|
|
||||||
slog.Warn("dns: server IP not configured, skipping DNS record creation", "domain", domain)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
recordID, err := dnsProvider.EnsureRecord(ctx, domain, settings.ServerIP)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("dns: failed to create/update record for standalone proxy", "domain", domain, "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := m.store.CreateDNSRecord(store.DNSRecord{
|
|
||||||
FQDN: domain,
|
|
||||||
ProviderRecordID: recordID,
|
|
||||||
ConsumerType: "standalone",
|
|
||||||
ConsumerID: proxyID,
|
|
||||||
}); err != nil {
|
|
||||||
// May already exist — try updating.
|
|
||||||
if updateErr := m.store.UpdateDNSRecordProviderID(domain, recordID); updateErr != nil {
|
|
||||||
slog.Warn("dns: failed to track record", "domain", domain, "error", updateErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slog.Info("dns: record ensured for standalone proxy", "domain", domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeDNS deletes a DNS record for a standalone proxy domain. Best-effort.
|
|
||||||
func (m *Manager) removeDNS(ctx context.Context, domain string) {
|
|
||||||
dnsProvider := m.getDNS()
|
|
||||||
if dnsProvider == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := dnsProvider.DeleteRecord(ctx, domain); err != nil {
|
|
||||||
slog.Warn("dns: failed to delete record for standalone proxy", "domain", domain, "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := m.store.DeleteDNSRecord(domain); err != nil {
|
|
||||||
slog.Warn("dns: failed to remove tracking record", "domain", domain, "error", err)
|
|
||||||
}
|
|
||||||
slog.Info("dns: record deleted for standalone proxy", "domain", domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// lastFailedStep returns the message of the last failed validation step.
|
|
||||||
func lastFailedStep(result ValidationResult) string {
|
|
||||||
for _, step := range result.Steps {
|
|
||||||
if !step.Passed {
|
|
||||||
msg := step.Message
|
|
||||||
if step.Hint != "" {
|
|
||||||
msg += " — " + step.Hint
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "unknown validation failure"
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsNotFound checks if an error wraps store.ErrNotFound.
|
|
||||||
func IsNotFound(err error) bool {
|
|
||||||
return errors.Is(err, store.ErrNotFound)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TraefikProvider manages proxy routes via Docker labels.
|
||||||
|
// Traefik auto-discovers containers with the appropriate labels.
|
||||||
|
type TraefikProvider struct {
|
||||||
|
entrypoint string
|
||||||
|
certResolver string
|
||||||
|
network string // Docker network for traefik.docker.network label
|
||||||
|
apiURL string // Traefik API URL for health checks (optional)
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTraefikProvider creates a Traefik-backed proxy provider.
|
||||||
|
func NewTraefikProvider(entrypoint, certResolver, network, apiURL string) *TraefikProvider {
|
||||||
|
if entrypoint == "" {
|
||||||
|
entrypoint = "websecure"
|
||||||
|
}
|
||||||
|
return &TraefikProvider{
|
||||||
|
entrypoint: entrypoint,
|
||||||
|
certResolver: certResolver,
|
||||||
|
network: network,
|
||||||
|
apiURL: strings.TrimRight(apiURL, "/"),
|
||||||
|
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TraefikProvider) Name() string { return "traefik" }
|
||||||
|
|
||||||
|
// ConfigureRoute for Traefik is a no-op for deploy-managed containers.
|
||||||
|
// Labels are set at container creation time via ContainerLabels().
|
||||||
|
// Returns a route ID for tracking.
|
||||||
|
func (t *TraefikProvider) ConfigureRoute(_ context.Context, domain, _ string, _ int, _ RouteOptions) (string, error) {
|
||||||
|
routerName := sanitizeDomain(domain)
|
||||||
|
return routerName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRoute for Traefik is a no-op — removing the container removes the labels,
|
||||||
|
// and Traefik automatically de-registers the route.
|
||||||
|
func (t *TraefikProvider) DeleteRoute(_ context.Context, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerLabels returns Docker labels for Traefik auto-discovery.
|
||||||
|
func (t *TraefikProvider) ContainerLabels(domain string, port int) map[string]string {
|
||||||
|
name := sanitizeDomain(domain)
|
||||||
|
labels := map[string]string{
|
||||||
|
"traefik.enable": "true",
|
||||||
|
fmt.Sprintf("traefik.http.routers.%s.rule", name): fmt.Sprintf("Host(`%s`)", domain),
|
||||||
|
fmt.Sprintf("traefik.http.routers.%s.entrypoints", name): t.entrypoint,
|
||||||
|
fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", name): fmt.Sprintf("%d", port),
|
||||||
|
}
|
||||||
|
if t.certResolver != "" {
|
||||||
|
labels[fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", name)] = t.certResolver
|
||||||
|
}
|
||||||
|
if t.network != "" {
|
||||||
|
labels["traefik.docker.network"] = t.network
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping checks Traefik API connectivity if a URL is configured.
|
||||||
|
func (t *TraefikProvider) Ping(ctx context.Context) error {
|
||||||
|
if t.apiURL == "" {
|
||||||
|
return nil // No API URL configured, skip health check.
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.apiURL+"/api/overview", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create traefik ping request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("traefik ping: %w", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("traefik api returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeDomain converts a domain to a safe Traefik router name.
|
||||||
|
func sanitizeDomain(domain string) string {
|
||||||
|
r := strings.NewReplacer(".", "-", ":", "-", "*", "wildcard")
|
||||||
|
return r.Replace(strings.ToLower(domain))
|
||||||
|
}
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Validation step names.
|
|
||||||
const (
|
|
||||||
StepSyntax = "syntax"
|
|
||||||
StepDNS = "dns"
|
|
||||||
StepTCP = "tcp"
|
|
||||||
StepHTTP = "http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ValidationStep holds the result of a single validation check.
|
|
||||||
type ValidationStep struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Passed bool `json:"passed"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
Hint string `json:"hint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidationResult holds the aggregate result of the validation pipeline.
|
|
||||||
type ValidationResult struct {
|
|
||||||
Valid bool `json:"valid"`
|
|
||||||
Steps []ValidationStep `json:"steps"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateDestination runs the multi-step validation pipeline against the given
|
|
||||||
// destination host and port. It checks syntax, DNS, TCP reachability, and HTTP health.
|
|
||||||
// The pipeline short-circuits on failure: later steps are skipped if an earlier one fails.
|
|
||||||
func ValidateDestination(ctx context.Context, host string, port int) ValidationResult {
|
|
||||||
result := ValidationResult{Valid: true}
|
|
||||||
|
|
||||||
// Step 1: Syntax validation.
|
|
||||||
if step, ok := validateSyntax(host, port); !ok {
|
|
||||||
result.Valid = false
|
|
||||||
result.Steps = append(result.Steps, step)
|
|
||||||
return result
|
|
||||||
} else {
|
|
||||||
result.Steps = append(result.Steps, step)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: DNS resolution (skip for IP addresses).
|
|
||||||
ip := net.ParseIP(host)
|
|
||||||
if ip == nil {
|
|
||||||
if step, ok := validateDNS(ctx, host); !ok {
|
|
||||||
result.Valid = false
|
|
||||||
result.Steps = append(result.Steps, step)
|
|
||||||
return result
|
|
||||||
} else {
|
|
||||||
result.Steps = append(result.Steps, step)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.Steps = append(result.Steps, ValidationStep{
|
|
||||||
Name: StepDNS,
|
|
||||||
Passed: true,
|
|
||||||
Message: "Skipped (IP address provided)",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: TCP port reachability.
|
|
||||||
if step, ok := validateTCP(ctx, host, port); !ok {
|
|
||||||
result.Valid = false
|
|
||||||
result.Steps = append(result.Steps, step)
|
|
||||||
return result
|
|
||||||
} else {
|
|
||||||
result.Steps = append(result.Steps, step)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: HTTP health probe.
|
|
||||||
step := validateHTTP(ctx, host, port)
|
|
||||||
result.Steps = append(result.Steps, step)
|
|
||||||
if !step.Passed {
|
|
||||||
result.Valid = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateSyntax checks that the host and port values are syntactically valid.
|
|
||||||
func validateSyntax(host string, port int) (ValidationStep, bool) {
|
|
||||||
if host == "" {
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepSyntax,
|
|
||||||
Passed: false,
|
|
||||||
Message: "Host is empty",
|
|
||||||
Hint: "Provide a valid hostname or IP address.",
|
|
||||||
}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if port < 1 || port > 65535 {
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepSyntax,
|
|
||||||
Passed: false,
|
|
||||||
Message: fmt.Sprintf("Port %d is out of range (1-65535)", port),
|
|
||||||
Hint: "Provide a valid port number between 1 and 65535.",
|
|
||||||
}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject obviously invalid hostnames (but allow IPs).
|
|
||||||
if net.ParseIP(host) == nil {
|
|
||||||
// Basic hostname validation: must not contain spaces or schemes.
|
|
||||||
if _, err := url.Parse("http://" + host); err != nil {
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepSyntax,
|
|
||||||
Passed: false,
|
|
||||||
Message: "Invalid hostname: " + err.Error(),
|
|
||||||
Hint: "Provide a valid hostname without scheme (e.g., 'example.com' not 'http://example.com').",
|
|
||||||
}, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepSyntax,
|
|
||||||
Passed: true,
|
|
||||||
Message: fmt.Sprintf("Host %q port %d syntax OK", host, port),
|
|
||||||
}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateDNS performs a DNS lookup on the given host.
|
|
||||||
func validateDNS(ctx context.Context, host string) (ValidationStep, bool) {
|
|
||||||
resolver := net.DefaultResolver
|
|
||||||
addrs, err := resolver.LookupHost(ctx, host)
|
|
||||||
if err != nil {
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepDNS,
|
|
||||||
Passed: false,
|
|
||||||
Message: fmt.Sprintf("DNS resolution failed for %q: %s", host, err.Error()),
|
|
||||||
Hint: diagnosticHint(StepDNS, err),
|
|
||||||
}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepDNS,
|
|
||||||
Passed: true,
|
|
||||||
Message: fmt.Sprintf("Resolved to %v", addrs),
|
|
||||||
}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateTCP attempts a TCP connection to host:port with a 5-second timeout.
|
|
||||||
func validateTCP(ctx context.Context, host string, port int) (ValidationStep, bool) {
|
|
||||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
|
||||||
|
|
||||||
dialCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var d net.Dialer
|
|
||||||
conn, err := d.DialContext(dialCtx, "tcp", addr)
|
|
||||||
if err != nil {
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepTCP,
|
|
||||||
Passed: false,
|
|
||||||
Message: fmt.Sprintf("TCP connect to %s failed: %s", addr, err.Error()),
|
|
||||||
Hint: diagnosticHint(StepTCP, err),
|
|
||||||
}, false
|
|
||||||
}
|
|
||||||
conn.Close()
|
|
||||||
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepTCP,
|
|
||||||
Passed: true,
|
|
||||||
Message: fmt.Sprintf("TCP connect to %s succeeded", addr),
|
|
||||||
}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateHTTP performs a GET request to the destination and checks for a response.
|
|
||||||
// Non-5xx responses are considered passing (the service is responding).
|
|
||||||
func validateHTTP(ctx context.Context, host string, port int) ValidationStep {
|
|
||||||
target := fmt.Sprintf("http://%s:%d/", host, port)
|
|
||||||
|
|
||||||
httpCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, target, nil)
|
|
||||||
if err != nil {
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepHTTP,
|
|
||||||
Passed: false,
|
|
||||||
Message: fmt.Sprintf("Failed to build HTTP request: %s", err.Error()),
|
|
||||||
Hint: diagnosticHint(StepHTTP, err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
// Do not follow redirects — we just want to see if the port responds to HTTP.
|
|
||||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepHTTP,
|
|
||||||
Passed: false,
|
|
||||||
Message: fmt.Sprintf("HTTP probe to %s failed: %s", target, err.Error()),
|
|
||||||
Hint: diagnosticHint(StepHTTP, err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 500 {
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepHTTP,
|
|
||||||
Passed: false,
|
|
||||||
Message: fmt.Sprintf("Service responded with HTTP %d. The service may not be healthy.", resp.StatusCode),
|
|
||||||
Hint: fmt.Sprintf("Service responded with HTTP %d. The service may not be healthy.", resp.StatusCode),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ValidationStep{
|
|
||||||
Name: StepHTTP,
|
|
||||||
Passed: true,
|
|
||||||
Message: fmt.Sprintf("HTTP probe returned %d", resp.StatusCode),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -63,8 +63,12 @@ type Settings struct {
|
|||||||
DNSProvider string `json:"dns_provider"`
|
DNSProvider string `json:"dns_provider"`
|
||||||
CloudflareAPIToken string `json:"cloudflare_api_token"`
|
CloudflareAPIToken string `json:"cloudflare_api_token"`
|
||||||
CloudflareZoneID string `json:"cloudflare_zone_id"`
|
CloudflareZoneID string `json:"cloudflare_zone_id"`
|
||||||
ProxyProvider string `json:"proxy_provider"`
|
ProxyProvider string `json:"proxy_provider"`
|
||||||
BackupEnabled bool `json:"backup_enabled"`
|
TraefikEntrypoint string `json:"traefik_entrypoint"`
|
||||||
|
TraefikCertResolver string `json:"traefik_cert_resolver"`
|
||||||
|
TraefikNetwork string `json:"traefik_network"`
|
||||||
|
TraefikAPIURL string `json:"traefik_api_url"`
|
||||||
|
BackupEnabled bool `json:"backup_enabled"`
|
||||||
BackupIntervalHours int `json:"backup_interval_hours"`
|
BackupIntervalHours int `json:"backup_interval_hours"`
|
||||||
BackupRetentionCount int `json:"backup_retention_count"`
|
BackupRetentionCount int `json:"backup_retention_count"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
@@ -194,16 +198,3 @@ type EventLog struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StandaloneProxy represents a standalone reverse proxy not tied to a project.
|
|
||||||
type StandaloneProxy struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
DestinationURL string `json:"destination_url"`
|
|
||||||
DestinationPort int `json:"destination_port"`
|
|
||||||
SSLCertificateID int `json:"ssl_certificate_id"`
|
|
||||||
NpmProxyID int `json:"npm_proxy_id"`
|
|
||||||
HealthStatus string `json:"health_status"` // unknown, healthy, unhealthy
|
|
||||||
HealthCheckedAt string `json:"health_checked_at"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
|||||||
allowed_volume_paths, wildcard_dns, dns_provider,
|
allowed_volume_paths, wildcard_dns, dns_provider,
|
||||||
cloudflare_api_token, cloudflare_zone_id,
|
cloudflare_api_token, cloudflare_zone_id,
|
||||||
proxy_provider,
|
proxy_provider,
|
||||||
|
traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url,
|
||||||
backup_enabled, backup_interval_hours, backup_retention_count,
|
backup_enabled, backup_interval_hours, backup_retention_count,
|
||||||
updated_at
|
updated_at
|
||||||
FROM settings WHERE id = 1`,
|
FROM settings WHERE id = 1`,
|
||||||
@@ -24,6 +25,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
|||||||
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
|
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
|
||||||
&st.CloudflareAPIToken, &st.CloudflareZoneID,
|
&st.CloudflareAPIToken, &st.CloudflareZoneID,
|
||||||
&st.ProxyProvider,
|
&st.ProxyProvider,
|
||||||
|
&st.TraefikEntrypoint, &st.TraefikCertResolver, &st.TraefikNetwork, &st.TraefikAPIURL,
|
||||||
&backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount,
|
&backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount,
|
||||||
&st.UpdatedAt)
|
&st.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,6 +55,7 @@ func (s *Store) UpdateSettings(st Settings) error {
|
|||||||
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
|
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
|
||||||
cloudflare_api_token=?, cloudflare_zone_id=?,
|
cloudflare_api_token=?, cloudflare_zone_id=?,
|
||||||
proxy_provider=?,
|
proxy_provider=?,
|
||||||
|
traefik_entrypoint=?, traefik_cert_resolver=?, traefik_network=?, traefik_api_url=?,
|
||||||
backup_enabled=?, backup_interval_hours=?, backup_retention_count=?,
|
backup_enabled=?, backup_interval_hours=?, backup_retention_count=?,
|
||||||
updated_at=?
|
updated_at=?
|
||||||
WHERE id = 1`,
|
WHERE id = 1`,
|
||||||
@@ -62,6 +65,7 @@ func (s *Store) UpdateSettings(st Settings) error {
|
|||||||
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
|
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
|
||||||
st.CloudflareAPIToken, st.CloudflareZoneID,
|
st.CloudflareAPIToken, st.CloudflareZoneID,
|
||||||
st.ProxyProvider,
|
st.ProxyProvider,
|
||||||
|
st.TraefikEntrypoint, st.TraefikCertResolver, st.TraefikNetwork, st.TraefikAPIURL,
|
||||||
backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount,
|
backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount,
|
||||||
st.UpdatedAt,
|
st.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateStandaloneProxy inserts a new standalone proxy record.
|
|
||||||
func (s *Store) CreateStandaloneProxy(p StandaloneProxy) (StandaloneProxy, error) {
|
|
||||||
p.ID = uuid.New().String()
|
|
||||||
p.CreatedAt = Now()
|
|
||||||
p.UpdatedAt = p.CreatedAt
|
|
||||||
|
|
||||||
if p.HealthStatus == "" {
|
|
||||||
p.HealthStatus = "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT INTO standalone_proxies (id, domain, destination_url, destination_port, ssl_certificate_id, npm_proxy_id, health_status, health_checked_at, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
p.ID, p.Domain, p.DestinationURL, p.DestinationPort, p.SSLCertificateID,
|
|
||||||
p.NpmProxyID, p.HealthStatus, p.HealthCheckedAt, p.CreatedAt, p.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return StandaloneProxy{}, fmt.Errorf("insert standalone proxy: %w", err)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStandaloneProxy returns a standalone proxy by ID.
|
|
||||||
func (s *Store) GetStandaloneProxy(id string) (StandaloneProxy, error) {
|
|
||||||
var p StandaloneProxy
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
`SELECT id, domain, destination_url, destination_port, ssl_certificate_id, npm_proxy_id, health_status, health_checked_at, created_at, updated_at
|
|
||||||
FROM standalone_proxies WHERE id = ?`, id,
|
|
||||||
).Scan(&p.ID, &p.Domain, &p.DestinationURL, &p.DestinationPort, &p.SSLCertificateID,
|
|
||||||
&p.NpmProxyID, &p.HealthStatus, &p.HealthCheckedAt, &p.CreatedAt, &p.UpdatedAt)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return StandaloneProxy{}, fmt.Errorf("standalone proxy %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return StandaloneProxy{}, fmt.Errorf("query standalone proxy: %w", err)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListStandaloneProxies returns all standalone proxy records ordered by creation time.
|
|
||||||
func (s *Store) ListStandaloneProxies() ([]StandaloneProxy, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT id, domain, destination_url, destination_port, ssl_certificate_id, npm_proxy_id, health_status, health_checked_at, created_at, updated_at
|
|
||||||
FROM standalone_proxies ORDER BY created_at DESC`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query standalone proxies: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
proxies := []StandaloneProxy{}
|
|
||||||
for rows.Next() {
|
|
||||||
var p StandaloneProxy
|
|
||||||
if err := rows.Scan(&p.ID, &p.Domain, &p.DestinationURL, &p.DestinationPort, &p.SSLCertificateID,
|
|
||||||
&p.NpmProxyID, &p.HealthStatus, &p.HealthCheckedAt, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan standalone proxy: %w", err)
|
|
||||||
}
|
|
||||||
proxies = append(proxies, p)
|
|
||||||
}
|
|
||||||
return proxies, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateStandaloneProxy updates an existing standalone proxy's mutable fields.
|
|
||||||
func (s *Store) UpdateStandaloneProxy(p StandaloneProxy) error {
|
|
||||||
p.UpdatedAt = Now()
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`UPDATE standalone_proxies SET domain=?, destination_url=?, destination_port=?, ssl_certificate_id=?, npm_proxy_id=?, health_status=?, health_checked_at=?, updated_at=?
|
|
||||||
WHERE id=?`,
|
|
||||||
p.Domain, p.DestinationURL, p.DestinationPort, p.SSLCertificateID,
|
|
||||||
p.NpmProxyID, p.HealthStatus, p.HealthCheckedAt, p.UpdatedAt, p.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update standalone proxy: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("standalone proxy %s: %w", p.ID, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteStandaloneProxy removes a standalone proxy by ID.
|
|
||||||
func (s *Store) DeleteStandaloneProxy(id string) error {
|
|
||||||
result, err := s.db.Exec(`DELETE FROM standalone_proxies WHERE id = ?`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete standalone proxy: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("standalone proxy %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProxyHealth updates the health status and check timestamp for a standalone proxy.
|
|
||||||
func (s *Store) UpdateProxyHealth(id string, status string) error {
|
|
||||||
ts := Now()
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`UPDATE standalone_proxies SET health_status=?, health_checked_at=?, updated_at=? WHERE id=?`,
|
|
||||||
status, ts, ts, id,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update proxy health: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("standalone proxy %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
+11
-2
@@ -104,6 +104,11 @@ func (s *Store) runMigrations() error {
|
|||||||
`ALTER TABLE instances ADD COLUMN proxy_route_id TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE instances ADD COLUMN proxy_route_id TEXT NOT NULL DEFAULT ''`,
|
||||||
// Add proxy_provider to settings (2026-04-04). Default to npm for backward compat.
|
// Add proxy_provider to settings (2026-04-04). Default to npm for backward compat.
|
||||||
`ALTER TABLE settings ADD COLUMN proxy_provider TEXT NOT NULL DEFAULT 'npm'`,
|
`ALTER TABLE settings ADD COLUMN proxy_provider TEXT NOT NULL DEFAULT 'npm'`,
|
||||||
|
// Add Traefik provider settings (2026-04-04).
|
||||||
|
`ALTER TABLE settings ADD COLUMN traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure'`,
|
||||||
|
`ALTER TABLE settings ADD COLUMN traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt'`,
|
||||||
|
`ALTER TABLE settings ADD COLUMN traefik_network TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
@@ -202,8 +207,12 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
webhook_secret TEXT NOT NULL DEFAULT '',
|
webhook_secret TEXT NOT NULL DEFAULT '',
|
||||||
polling_interval TEXT NOT NULL DEFAULT '5m',
|
polling_interval TEXT NOT NULL DEFAULT '5m',
|
||||||
base_volume_path TEXT NOT NULL DEFAULT '',
|
base_volume_path TEXT NOT NULL DEFAULT '',
|
||||||
ssl_certificate_id INTEGER NOT NULL DEFAULT 0,
|
ssl_certificate_id INTEGER NOT NULL DEFAULT 0,
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure',
|
||||||
|
traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt',
|
||||||
|
traefik_network TEXT NOT NULL DEFAULT '',
|
||||||
|
traefik_api_url TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS instances (
|
CREATE TABLE IF NOT EXISTS instances (
|
||||||
|
|||||||
@@ -11,15 +11,12 @@ import type {
|
|||||||
NpmCertificate,
|
NpmCertificate,
|
||||||
Project,
|
Project,
|
||||||
ProjectDetail,
|
ProjectDetail,
|
||||||
ProxyView,
|
|
||||||
Registry,
|
Registry,
|
||||||
RegistryImage,
|
RegistryImage,
|
||||||
Settings,
|
Settings,
|
||||||
StaleContainer,
|
StaleContainer,
|
||||||
Stage,
|
Stage,
|
||||||
StageEnv,
|
StageEnv,
|
||||||
StandaloneProxy,
|
|
||||||
ValidationResult,
|
|
||||||
Volume,
|
Volume,
|
||||||
VolumeScopeInfo,
|
VolumeScopeInfo,
|
||||||
BrowseResult,
|
BrowseResult,
|
||||||
@@ -531,43 +528,6 @@ export function fetchEventLogStats(): Promise<EventLogStats> {
|
|||||||
return get<EventLogStats>('/api/events/log/stats');
|
return get<EventLogStats>('/api/events/log/stats');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Proxies ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function validateProxy(host: string, port: number): Promise<ValidationResult> {
|
|
||||||
return post<ValidationResult>('/api/proxies/validate', { host, port });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProxy(data: {
|
|
||||||
domain: string;
|
|
||||||
destination_url: string;
|
|
||||||
destination_port: number;
|
|
||||||
}): Promise<StandaloneProxy> {
|
|
||||||
return post<StandaloneProxy>('/api/proxies', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listProxies(): Promise<StandaloneProxy[]> {
|
|
||||||
return get<StandaloneProxy[]>('/api/proxies');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProxy(id: string): Promise<StandaloneProxy> {
|
|
||||||
return get<StandaloneProxy>(`/api/proxies/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateProxy(
|
|
||||||
id: string,
|
|
||||||
data: { domain: string; destination_url: string; destination_port: number }
|
|
||||||
): Promise<StandaloneProxy> {
|
|
||||||
return put<StandaloneProxy>(`/api/proxies/${id}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteProxy(id: string): Promise<{ deleted: string }> {
|
|
||||||
return del<{ deleted: string }>(`/api/proxies/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listAllProxies(): Promise<ProxyView[]> {
|
|
||||||
return get<ProxyView[]>('/api/proxies/all');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stale Containers ────────────────────────────────────────────────
|
// ── Stale Containers ────────────────────────────────────────────────
|
||||||
|
|
||||||
export function fetchStaleContainers(): Promise<StaleContainer[]> {
|
export function fetchStaleContainers(): Promise<StaleContainer[]> {
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
<!--
|
|
||||||
Phase 4: Individual proxy display card showing domain, destination,
|
|
||||||
type badge, health indicator, SSL badge, and project/stage labels.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { ProxyView } from '$lib/types';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { IconEdit, IconExternalLink, IconLock } from '$lib/components/icons';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
proxy: ProxyView;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { proxy }: Props = $props();
|
|
||||||
|
|
||||||
const healthColors: Record<string, { dot: string; ring: string }> = {
|
|
||||||
healthy: { dot: 'bg-emerald-500', ring: 'bg-emerald-500' },
|
|
||||||
unhealthy: { dot: 'bg-red-500', ring: 'bg-red-500' },
|
|
||||||
unknown: { dot: 'bg-amber-400', ring: 'bg-amber-400' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const healthColor = $derived(healthColors[proxy.health_status] ?? healthColors.unknown);
|
|
||||||
const isHealthy = $derived(proxy.health_status === 'healthy');
|
|
||||||
|
|
||||||
const typeBadgeClass = $derived(
|
|
||||||
proxy.type === 'managed'
|
|
||||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'
|
|
||||||
: 'bg-purple-50 text-purple-700 dark:bg-purple-950 dark:text-purple-300'
|
|
||||||
);
|
|
||||||
|
|
||||||
const healthLabel = $derived($t(`proxies.health.${proxy.health_status}`));
|
|
||||||
|
|
||||||
function formatTimestamp(iso: string): string {
|
|
||||||
if (!iso) return '';
|
|
||||||
try {
|
|
||||||
const date = new Date(iso);
|
|
||||||
return date.toLocaleString();
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-all duration-200 hover:border-[var(--color-brand-300)] hover:shadow-[var(--shadow-md)]">
|
|
||||||
<!-- Top row: domain + health dot -->
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- Health indicator -->
|
|
||||||
<span class="relative flex h-2.5 w-2.5 shrink-0" title={healthLabel}>
|
|
||||||
{#if isHealthy}
|
|
||||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full {healthColor.ring} opacity-50"></span>
|
|
||||||
{/if}
|
|
||||||
<span class="relative inline-flex h-2.5 w-2.5 rounded-full {healthColor.dot}"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Domain link -->
|
|
||||||
<a
|
|
||||||
href="https://{proxy.domain}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="group inline-flex items-center gap-1 truncate text-sm font-semibold text-[var(--text-primary)] hover:text-[var(--color-brand-600)] transition-colors"
|
|
||||||
>
|
|
||||||
<span class="truncate">{proxy.domain}</span>
|
|
||||||
<IconExternalLink size={13} class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Destination -->
|
|
||||||
<p class="mt-1 truncate font-mono text-xs text-[var(--text-tertiary)]">
|
|
||||||
{proxy.destination}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Type badge -->
|
|
||||||
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {typeBadgeClass}">
|
|
||||||
{proxy.type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Badges row -->
|
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
||||||
<!-- SSL badge -->
|
|
||||||
{#if proxy.ssl_enabled}
|
|
||||||
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
|
||||||
<IconLock size={11} />
|
|
||||||
SSL
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Health status label -->
|
|
||||||
<span class="inline-flex items-center gap-1 rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-secondary)]">
|
|
||||||
{healthLabel}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Project / stage labels for managed proxies -->
|
|
||||||
{#if proxy.type === 'managed' && proxy.project_name}
|
|
||||||
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300">
|
|
||||||
{proxy.project_name}
|
|
||||||
</span>
|
|
||||||
{#if proxy.stage_name}
|
|
||||||
<span class="rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300">
|
|
||||||
{proxy.stage_name}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer row: edit link (standalone only) + timestamp -->
|
|
||||||
<div class="mt-3 flex items-center justify-between">
|
|
||||||
{#if proxy.type === 'standalone'}
|
|
||||||
<a
|
|
||||||
href="/proxies/{proxy.id}/edit"
|
|
||||||
class="inline-flex items-center gap-1 text-xs font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconEdit size={12} />
|
|
||||||
{$t('common.edit')}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<span></span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if proxy.created_at}
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">
|
|
||||||
{$t('proxies.lastChecked')}: {formatTimestamp(proxy.created_at)}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<!--
|
|
||||||
Phase 4: Filter bar for the unified proxy viewer.
|
|
||||||
Provides text search, health status dropdown, type dropdown, and clear filters.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { ProxyHealthStatus } from '$lib/types';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { IconSearch, IconX } from '$lib/components/icons';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
search: string;
|
|
||||||
healthFilter: ProxyHealthStatus | 'all';
|
|
||||||
typeFilter: 'all' | 'managed' | 'standalone';
|
|
||||||
onsearchchange: (value: string) => void;
|
|
||||||
onhealthchange: (value: ProxyHealthStatus | 'all') => void;
|
|
||||||
ontypechange: (value: 'all' | 'managed' | 'standalone') => void;
|
|
||||||
onclear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
search,
|
|
||||||
healthFilter,
|
|
||||||
typeFilter,
|
|
||||||
onsearchchange,
|
|
||||||
onhealthchange,
|
|
||||||
ontypechange,
|
|
||||||
onclear
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const hasFilters = $derived(
|
|
||||||
search.length > 0 || healthFilter !== 'all' || typeFilter !== 'all'
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
||||||
<!-- Text search -->
|
|
||||||
<div class="relative flex-1">
|
|
||||||
<IconSearch
|
|
||||||
size={16}
|
|
||||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
oninput={(e) => onsearchchange(e.currentTarget.value)}
|
|
||||||
placeholder={$t('proxies.filter.search')}
|
|
||||||
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2 pl-9 pr-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] transition-colors focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Health filter -->
|
|
||||||
<select
|
|
||||||
value={healthFilter}
|
|
||||||
onchange={(e) => onhealthchange(e.currentTarget.value as ProxyHealthStatus | 'all')}
|
|
||||||
class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] transition-colors focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
|
||||||
>
|
|
||||||
<option value="all">{$t('proxies.filter.health')}: {$t('proxies.filter.all')}</option>
|
|
||||||
<option value="healthy">{$t('proxies.health.healthy')}</option>
|
|
||||||
<option value="unhealthy">{$t('proxies.health.unhealthy')}</option>
|
|
||||||
<option value="unknown">{$t('proxies.health.unknown')}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- Type filter -->
|
|
||||||
<select
|
|
||||||
value={typeFilter}
|
|
||||||
onchange={(e) => ontypechange(e.currentTarget.value as 'all' | 'managed' | 'standalone')}
|
|
||||||
class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] transition-colors focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
|
||||||
>
|
|
||||||
<option value="all">{$t('proxies.filter.type')}: {$t('proxies.filter.all')}</option>
|
|
||||||
<option value="managed">{$t('proxies.managed')}</option>
|
|
||||||
<option value="standalone">{$t('proxies.standalone')}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- Clear filters -->
|
|
||||||
{#if hasFilters}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onclear}
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]"
|
|
||||||
>
|
|
||||||
<IconX size={14} />
|
|
||||||
{$t('proxies.filter.clear')}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
<!--
|
|
||||||
Phase 6: Create/edit form for standalone proxies.
|
|
||||||
Supports live destination validation with debounce.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { StandaloneProxy, ValidationResult } from '$lib/types';
|
|
||||||
import { validateProxy, createProxy, updateProxy, deleteProxy } from '$lib/api';
|
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
|
||||||
import ValidationChecklist from '$lib/components/ValidationChecklist.svelte';
|
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
|
||||||
import { IconLoader } from '$lib/components/icons';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mode: 'create' | 'edit';
|
|
||||||
proxy?: StandaloneProxy;
|
|
||||||
onsave?: (proxy: StandaloneProxy) => void;
|
|
||||||
ondelete?: (id: string) => void;
|
|
||||||
oncancel?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mode, proxy, onsave, ondelete, oncancel }: Props = $props();
|
|
||||||
|
|
||||||
// ── Form state ────────────────────────────────────────────────────
|
|
||||||
let destinationUrl = $state(proxy?.destination_url ?? '');
|
|
||||||
let port = $state(proxy?.destination_port?.toString() ?? '');
|
|
||||||
let domain = $state(proxy?.domain ?? '');
|
|
||||||
|
|
||||||
// ── Validation state ──────────────────────────────────────────────
|
|
||||||
let validationResult: ValidationResult | null = $state(null);
|
|
||||||
let validating = $state(false);
|
|
||||||
let validationTimer: ReturnType<typeof setTimeout> | null = $state(null);
|
|
||||||
|
|
||||||
// ── Submit state ──────────────────────────────────────────────────
|
|
||||||
let submitting = $state(false);
|
|
||||||
let submitError = $state('');
|
|
||||||
|
|
||||||
// ── Delete state ──────────────────────────────────────────────────
|
|
||||||
let deleteConfirmOpen = $state(false);
|
|
||||||
let deleting = $state(false);
|
|
||||||
|
|
||||||
// ── Derived ───────────────────────────────────────────────────────
|
|
||||||
const portNum = $derived(parseInt(port, 10));
|
|
||||||
const portValid = $derived(!isNaN(portNum) && portNum >= 1 && portNum <= 65535);
|
|
||||||
const canSubmit = $derived(
|
|
||||||
destinationUrl.trim().length > 0 &&
|
|
||||||
port.trim().length > 0 &&
|
|
||||||
portValid &&
|
|
||||||
domain.trim().length > 0 &&
|
|
||||||
!submitting
|
|
||||||
);
|
|
||||||
|
|
||||||
const title = $derived(
|
|
||||||
mode === 'create' ? $t('proxies.form.title') : $t('proxies.form.editTitle')
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitLabel = $derived(
|
|
||||||
submitting
|
|
||||||
? (mode === 'create' ? $t('proxies.form.create') : $t('proxies.form.save'))
|
|
||||||
: (mode === 'create' ? $t('proxies.form.create') : $t('proxies.form.save'))
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Domain auto-suggestion ────────────────────────────────────────
|
|
||||||
function suggestDomain(dest: string): string {
|
|
||||||
if (!dest) return '';
|
|
||||||
try {
|
|
||||||
// If it looks like a URL, parse the hostname
|
|
||||||
const withScheme = dest.includes('://') ? dest : `http://${dest}`;
|
|
||||||
const url = new URL(withScheme);
|
|
||||||
const host = url.hostname;
|
|
||||||
// Strip common prefixes and use as subdomain suggestion
|
|
||||||
const cleaned = host
|
|
||||||
.replace(/^(www|api|app)\./, '')
|
|
||||||
.replace(/\.\w+$/, '')
|
|
||||||
.replace(/[^a-z0-9.-]/gi, '-')
|
|
||||||
.toLowerCase();
|
|
||||||
return cleaned || '';
|
|
||||||
} catch {
|
|
||||||
// If it's a plain IP or hostname, use it directly
|
|
||||||
return dest.replace(/[^a-z0-9.-]/gi, '-').toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Live validation with debounce ─────────────────────────────────
|
|
||||||
function scheduleValidation(): void {
|
|
||||||
if (validationTimer !== null) {
|
|
||||||
clearTimeout(validationTimer);
|
|
||||||
}
|
|
||||||
validationResult = null;
|
|
||||||
|
|
||||||
if (!destinationUrl.trim() || !port.trim() || !portValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
validationTimer = setTimeout(() => {
|
|
||||||
runValidation();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runValidation(): Promise<void> {
|
|
||||||
if (!destinationUrl.trim() || !portValid) return;
|
|
||||||
|
|
||||||
validating = true;
|
|
||||||
try {
|
|
||||||
validationResult = await validateProxy(destinationUrl.trim(), portNum);
|
|
||||||
} catch {
|
|
||||||
// Validation is advisory -- don't block the UI
|
|
||||||
validationResult = null;
|
|
||||||
} finally {
|
|
||||||
validating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDestinationInput(): void {
|
|
||||||
// Auto-suggest domain only when creating and domain is empty or was auto-generated
|
|
||||||
if (mode === 'create') {
|
|
||||||
const suggested = suggestDomain(destinationUrl);
|
|
||||||
if (!domain || domain === suggestDomain(destinationUrl.slice(0, -1))) {
|
|
||||||
domain = suggested;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scheduleValidation();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePortInput(): void {
|
|
||||||
scheduleValidation();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleValidateClick(): void {
|
|
||||||
if (validationTimer !== null) {
|
|
||||||
clearTimeout(validationTimer);
|
|
||||||
}
|
|
||||||
runValidation();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Submit ────────────────────────────────────────────────────────
|
|
||||||
async function handleSubmit(): Promise<void> {
|
|
||||||
if (!canSubmit) return;
|
|
||||||
|
|
||||||
submitting = true;
|
|
||||||
submitError = '';
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
domain: domain.trim(),
|
|
||||||
destination_url: destinationUrl.trim(),
|
|
||||||
destination_port: portNum
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const saved = mode === 'create'
|
|
||||||
? await createProxy(data)
|
|
||||||
: await updateProxy(proxy!.id, data);
|
|
||||||
onsave?.(saved);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
submitError = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
} finally {
|
|
||||||
submitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete ────────────────────────────────────────────────────────
|
|
||||||
async function handleDeleteConfirm(): Promise<void> {
|
|
||||||
if (!proxy) return;
|
|
||||||
deleting = true;
|
|
||||||
try {
|
|
||||||
await deleteProxy(proxy.id);
|
|
||||||
deleteConfirmOpen = false;
|
|
||||||
ondelete?.(proxy.id);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
submitError = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
} finally {
|
|
||||||
deleting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Header -->
|
|
||||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
|
||||||
|
|
||||||
<!-- Form fields -->
|
|
||||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
|
||||||
<FormField
|
|
||||||
label={$t('proxies.form.destination')}
|
|
||||||
name="destination_url"
|
|
||||||
bind:value={destinationUrl}
|
|
||||||
placeholder="192.168.1.100 or http://my-service"
|
|
||||||
required
|
|
||||||
oninput={handleDestinationInput}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label={$t('proxies.form.port')}
|
|
||||||
name="destination_port"
|
|
||||||
type="number"
|
|
||||||
bind:value={port}
|
|
||||||
placeholder="8080"
|
|
||||||
required
|
|
||||||
error={port && !portValid ? $t('validation.invalidPort') : ''}
|
|
||||||
oninput={handlePortInput}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label={$t('proxies.form.domain')}
|
|
||||||
name="domain"
|
|
||||||
bind:value={domain}
|
|
||||||
placeholder="my-service.example.com"
|
|
||||||
required
|
|
||||||
helpText={$t('proxies.form.domainHelp')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Validation checklist -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<ValidationChecklist result={validationResult} loading={validating} />
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
disabled={!destinationUrl.trim() || !portValid || validating}
|
|
||||||
onclick={handleValidateClick}
|
|
||||||
>
|
|
||||||
{#if validating}
|
|
||||||
<IconLoader size={14} />
|
|
||||||
{$t('proxies.form.validating')}
|
|
||||||
{:else}
|
|
||||||
{$t('proxies.form.validate')}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Validation warning (non-blocking) -->
|
|
||||||
{#if validationResult && !validationResult.valid}
|
|
||||||
<p class="text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
Validation reported issues but you can still create the proxy.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Submit error -->
|
|
||||||
{#if submitError}
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{submitError}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<div class="flex items-center justify-between pt-2">
|
|
||||||
<div>
|
|
||||||
{#if mode === 'edit'}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg px-3 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-950 transition-colors"
|
|
||||||
onclick={() => { deleteConfirmOpen = true; }}
|
|
||||||
>
|
|
||||||
{$t('proxies.form.delete')}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
onclick={() => oncancel?.()}
|
|
||||||
>
|
|
||||||
{$t('proxies.form.cancel')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-brand-600)] disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
disabled={!canSubmit}
|
|
||||||
>
|
|
||||||
{#if submitting}
|
|
||||||
<IconLoader size={14} />
|
|
||||||
{/if}
|
|
||||||
{submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete confirmation dialog -->
|
|
||||||
{#if mode === 'edit'}
|
|
||||||
<ConfirmDialog
|
|
||||||
open={deleteConfirmOpen}
|
|
||||||
title={$t('proxies.form.delete')}
|
|
||||||
message={$t('proxies.form.deleteConfirm')}
|
|
||||||
confirmLabel={deleting ? $t('common.loading') : $t('proxies.form.delete')}
|
|
||||||
confirmVariant="danger"
|
|
||||||
onconfirm={handleDeleteConfirm}
|
|
||||||
oncancel={() => { deleteConfirmOpen = false; }}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<!--
|
|
||||||
Phase 4: Collapsible group for proxies by project/stage.
|
|
||||||
Shows a header with project name, proxy count, and expandable body.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import { IconChevronRight } from '$lib/components/icons';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
count: number;
|
|
||||||
defaultExpanded?: boolean;
|
|
||||||
children: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { title, count, defaultExpanded = true, children }: Props = $props();
|
|
||||||
|
|
||||||
let expanded = $state(defaultExpanded);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] overflow-hidden">
|
|
||||||
<!-- Header -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => { expanded = !expanded; }}
|
|
||||||
class="flex w-full items-center gap-3 px-5 py-3.5 text-left transition-colors hover:bg-[var(--surface-card-hover)]"
|
|
||||||
>
|
|
||||||
<IconChevronRight
|
|
||||||
size={16}
|
|
||||||
class="shrink-0 text-[var(--text-tertiary)] transition-transform duration-200 {expanded ? 'rotate-90' : ''}"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-semibold text-[var(--text-primary)]">{title}</span>
|
|
||||||
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-secondary)]">
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Expandable body -->
|
|
||||||
{#if expanded}
|
|
||||||
<div class="border-t border-[var(--border-primary)] p-4 animate-fade-in">
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
<!--
|
<!--
|
||||||
Dashboard summary card: container counts, proxy health, recent errors.
|
Dashboard summary card: container counts and recent errors.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Instance, ProxyView, EventLogStats } from '$lib/types';
|
import type { Instance, EventLogStats } from '$lib/types';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import { IconServer, IconProxies, IconAlert } from '$lib/components/icons';
|
import { IconServer, IconAlert } from '$lib/components/icons';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
let runningCount = $state(0);
|
let runningCount = $state(0);
|
||||||
let stoppedCount = $state(0);
|
let stoppedCount = $state(0);
|
||||||
let healthyProxies = $state(0);
|
|
||||||
let unhealthyProxies = $state(0);
|
|
||||||
let recentErrors = $state(0);
|
let recentErrors = $state(0);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
@@ -19,9 +17,8 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [projects, proxies, eventStats] = await Promise.all([
|
const [projects, eventStats] = await Promise.all([
|
||||||
api.listProjects(),
|
api.listProjects(),
|
||||||
api.listAllProxies().catch(() => [] as ProxyView[]),
|
|
||||||
api.fetchEventLogStats().catch(() => ({ info: 0, warn: 0, error: 0, total: 0 }) as EventLogStats)
|
api.fetchEventLogStats().catch(() => ({ info: 0, warn: 0, error: 0, total: 0 }) as EventLogStats)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -42,8 +39,6 @@
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
runningCount = allInstances.filter((i) => i.status === 'running').length;
|
runningCount = allInstances.filter((i) => i.status === 'running').length;
|
||||||
stoppedCount = allInstances.filter((i) => i.status !== 'running').length;
|
stoppedCount = allInstances.filter((i) => i.status !== 'running').length;
|
||||||
healthyProxies = proxies.filter((p) => p.health_status === 'healthy').length;
|
|
||||||
unhealthyProxies = proxies.filter((p) => p.health_status === 'unhealthy').length;
|
|
||||||
recentErrors = eventStats.error;
|
recentErrors = eventStats.error;
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -65,10 +60,10 @@
|
|||||||
{#if !loading}
|
{#if !loading}
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||||
<h3 class="mb-4 text-sm font-semibold text-[var(--text-primary)]">{$t('systemHealth.title')}</h3>
|
<h3 class="mb-4 text-sm font-semibold text-[var(--text-primary)]">{$t('systemHealth.title')}</h3>
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<!-- Containers -->
|
<!-- Containers -->
|
||||||
<a href="/projects" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
<a href="/projects" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600">
|
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 dark:bg-emerald-950/30 text-emerald-600">
|
||||||
<IconServer size={18} />
|
<IconServer size={18} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -81,26 +76,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Proxies -->
|
|
||||||
<a href="/proxies" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
|
||||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg {unhealthyProxies > 0 ? 'bg-red-50 text-red-600' : 'bg-blue-50 text-blue-600'}">
|
|
||||||
<IconProxies size={18} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-[var(--text-secondary)]">{$t('systemHealth.proxies')}</p>
|
|
||||||
<p class="text-sm font-semibold text-[var(--text-primary)]">
|
|
||||||
<span class="text-emerald-600">{healthyProxies}</span>
|
|
||||||
{#if unhealthyProxies > 0}
|
|
||||||
<span class="text-[var(--text-tertiary)]"> / </span>
|
|
||||||
<span class="text-red-600">{unhealthyProxies}</span>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Recent errors -->
|
<!-- Recent errors -->
|
||||||
<a href="/events" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
<a href="/events" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg {recentErrors > 0 ? 'bg-red-50 text-red-600' : 'bg-gray-50 text-gray-400'}">
|
<div class="flex h-9 w-9 items-center justify-center rounded-lg {recentErrors > 0 ? 'bg-red-50 dark:bg-red-950/30 text-red-600' : 'bg-gray-50 dark:bg-gray-900/30 text-gray-400'}">
|
||||||
<IconAlert size={18} />
|
<IconAlert size={18} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
<!--
|
|
||||||
Phase 6: Validation checklist for proxy destination validation.
|
|
||||||
Shows each validation step with pass/fail/pending status indicators.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { ValidationResult } from '$lib/types';
|
|
||||||
import { IconCheck, IconX, IconLoader } from '$lib/components/icons';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
result: ValidationResult | null;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { result, loading = false }: Props = $props();
|
|
||||||
|
|
||||||
/** Map step names to i18n keys. */
|
|
||||||
const stepLabelKeys: Record<string, string> = {
|
|
||||||
syntax: 'proxies.validation.syntax',
|
|
||||||
dns: 'proxies.validation.dns',
|
|
||||||
tcp: 'proxies.validation.tcp',
|
|
||||||
http: 'proxies.validation.http'
|
|
||||||
};
|
|
||||||
|
|
||||||
function getStepLabel(name: string): string {
|
|
||||||
const key = stepLabelKeys[name];
|
|
||||||
return key ? $t(key) : name;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading || result}
|
|
||||||
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-4">
|
|
||||||
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">
|
|
||||||
{$t('proxies.validation.title')}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{#if loading && !result}
|
|
||||||
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
|
||||||
<IconLoader size={16} />
|
|
||||||
<span>{$t('proxies.validation.checking')}</span>
|
|
||||||
</div>
|
|
||||||
{:else if result}
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{#each result.steps as step}
|
|
||||||
<li>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{#if step.passed}
|
|
||||||
<span class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950">
|
|
||||||
<IconCheck size={14} class="text-emerald-600 dark:text-emerald-400" />
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-[var(--text-primary)]">{getStepLabel(step.name)}</span>
|
|
||||||
{#if step.message}
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">— {step.message}</span>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-950">
|
|
||||||
<IconX size={14} class="text-red-600 dark:text-red-400" />
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-[var(--text-primary)]">{getStepLabel(step.name)}</span>
|
|
||||||
{#if step.message}
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">— {step.message}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if !step.passed && step.hint}
|
|
||||||
<p class="ml-7 mt-1 text-xs text-amber-600 dark:text-amber-400">{step.hint}</p>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -45,7 +45,6 @@ export { default as IconContainer } from './IconContainer.svelte';
|
|||||||
export { default as IconHardDrive } from './IconHardDrive.svelte';
|
export { default as IconHardDrive } from './IconHardDrive.svelte';
|
||||||
export { default as IconWifi } from './IconWifi.svelte';
|
export { default as IconWifi } from './IconWifi.svelte';
|
||||||
export { default as IconRefresh } from './IconRefresh.svelte';
|
export { default as IconRefresh } from './IconRefresh.svelte';
|
||||||
export { default as IconProxies } from './IconProxies.svelte';
|
|
||||||
export { default as IconEvents } from './IconEvents.svelte';
|
export { default as IconEvents } from './IconEvents.svelte';
|
||||||
export { default as IconLogout } from './IconLogout.svelte';
|
export { default as IconLogout } from './IconLogout.svelte';
|
||||||
export { default as IconArrowLeft } from './IconArrowLeft.svelte';
|
export { default as IconArrowLeft } from './IconArrowLeft.svelte';
|
||||||
|
|||||||
@@ -248,7 +248,17 @@
|
|||||||
"proxyNoneDesc": "No proxy — containers are accessed directly by port",
|
"proxyNoneDesc": "No proxy — containers are accessed directly by port",
|
||||||
"proxyNpm": "Nginx Proxy Manager",
|
"proxyNpm": "Nginx Proxy Manager",
|
||||||
"proxyNpmDesc": "Routes managed via NPM API (configure credentials below)",
|
"proxyNpmDesc": "Routes managed via NPM API (configure credentials below)",
|
||||||
"proxyNoneWarning": "Switching to None does not remove existing proxy routes from NPM. You can delete them manually from your NPM dashboard."
|
"proxyTraefik": "Traefik",
|
||||||
|
"proxyTraefikDesc": "Auto-discovery via Docker labels — no API calls needed",
|
||||||
|
"proxyNoneWarning": "Switching to None does not remove existing proxy routes. You may need to clean them up manually.",
|
||||||
|
"traefikEntrypoint": "Entrypoint",
|
||||||
|
"traefikEntrypointHelp": "Traefik entrypoint name for HTTPS routes",
|
||||||
|
"traefikCertResolver": "Cert Resolver",
|
||||||
|
"traefikCertResolverHelp": "TLS certificate resolver name (e.g., letsencrypt)",
|
||||||
|
"traefikNetwork": "Docker Network",
|
||||||
|
"traefikNetworkHelp": "Network Traefik listens on (leave empty to use global network)",
|
||||||
|
"traefikApiUrl": "Traefik API URL",
|
||||||
|
"traefikApiUrlHelp": "Optional — for health checks (e.g., http://traefik:8080)"
|
||||||
},
|
},
|
||||||
"settingsGeneral": {
|
"settingsGeneral": {
|
||||||
"title": "General Settings",
|
"title": "General Settings",
|
||||||
|
|||||||
@@ -248,7 +248,17 @@
|
|||||||
"proxyNoneDesc": "Без прокси — контейнеры доступны напрямую по порту",
|
"proxyNoneDesc": "Без прокси — контейнеры доступны напрямую по порту",
|
||||||
"proxyNpm": "Nginx Proxy Manager",
|
"proxyNpm": "Nginx Proxy Manager",
|
||||||
"proxyNpmDesc": "Маршруты через NPM API (настройте учётные данные ниже)",
|
"proxyNpmDesc": "Маршруты через NPM API (настройте учётные данные ниже)",
|
||||||
"proxyNoneWarning": "Переключение на «Нет» не удаляет существующие прокси-маршруты из NPM. Вы можете удалить их вручную в панели NPM."
|
"proxyTraefik": "Traefik",
|
||||||
|
"proxyTraefikDesc": "Автообнаружение через Docker-метки — без API-вызовов",
|
||||||
|
"proxyNoneWarning": "Переключение на «Нет» не удаляет существующие прокси-маршруты. Возможно, потребуется очистить их вручную.",
|
||||||
|
"traefikEntrypoint": "Точка входа",
|
||||||
|
"traefikEntrypointHelp": "Имя точки входа Traefik для HTTPS-маршрутов",
|
||||||
|
"traefikCertResolver": "Резолвер сертификатов",
|
||||||
|
"traefikCertResolverHelp": "Имя резолвера TLS-сертификатов (напр., letsencrypt)",
|
||||||
|
"traefikNetwork": "Docker-сеть",
|
||||||
|
"traefikNetworkHelp": "Сеть, которую слушает Traefik (оставьте пустым для глобальной сети)",
|
||||||
|
"traefikApiUrl": "URL API Traefik",
|
||||||
|
"traefikApiUrlHelp": "Необязательно — для проверки состояния (напр., http://traefik:8080)"
|
||||||
},
|
},
|
||||||
"settingsGeneral": {
|
"settingsGeneral": {
|
||||||
"title": "Общие настройки",
|
"title": "Общие настройки",
|
||||||
|
|||||||
+4
-44
@@ -116,6 +116,10 @@ export interface Settings {
|
|||||||
has_cloudflare_api_token: boolean;
|
has_cloudflare_api_token: boolean;
|
||||||
cloudflare_zone_id: string;
|
cloudflare_zone_id: string;
|
||||||
proxy_provider: string;
|
proxy_provider: string;
|
||||||
|
traefik_entrypoint: string;
|
||||||
|
traefik_cert_resolver: string;
|
||||||
|
traefik_network: string;
|
||||||
|
traefik_api_url: string;
|
||||||
backup_enabled: boolean;
|
backup_enabled: boolean;
|
||||||
backup_interval_hours: number;
|
backup_interval_hours: number;
|
||||||
backup_retention_count: number;
|
backup_retention_count: number;
|
||||||
@@ -265,23 +269,6 @@ export interface EventLogStats {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A standalone reverse proxy not tied to a project. */
|
|
||||||
export interface StandaloneProxy {
|
|
||||||
id: string;
|
|
||||||
domain: string;
|
|
||||||
destination_url: string;
|
|
||||||
destination_port: number;
|
|
||||||
ssl_certificate_id: number;
|
|
||||||
npm_proxy_id: number;
|
|
||||||
health_status: ProxyHealthStatus;
|
|
||||||
health_checked_at: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Health status for a proxy. */
|
|
||||||
export type ProxyHealthStatus = 'unknown' | 'healthy' | 'unhealthy';
|
|
||||||
|
|
||||||
/** A container detected as stale by the backend poller. */
|
/** A container detected as stale by the backend poller. */
|
||||||
export interface StaleContainer {
|
export interface StaleContainer {
|
||||||
instance: {
|
instance: {
|
||||||
@@ -303,20 +290,6 @@ export interface StaleContainer {
|
|||||||
days_stale: number;
|
days_stale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single step in the validation pipeline. */
|
|
||||||
export interface ValidationStep {
|
|
||||||
name: string;
|
|
||||||
passed: boolean;
|
|
||||||
message?: string;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of the proxy destination validation pipeline. */
|
|
||||||
export interface ValidationResult {
|
|
||||||
valid: boolean;
|
|
||||||
steps: ValidationStep[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Container CPU and memory stats from the Docker stats API. */
|
/** Container CPU and memory stats from the Docker stats API. */
|
||||||
export interface ContainerStats {
|
export interface ContainerStats {
|
||||||
cpu_percent: number;
|
cpu_percent: number;
|
||||||
@@ -325,16 +298,3 @@ export interface ContainerStats {
|
|||||||
memory_percent: number;
|
memory_percent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unified view of standalone + deploy-managed proxies (from /api/proxies/all). */
|
|
||||||
export interface ProxyView {
|
|
||||||
id: string;
|
|
||||||
domain: string;
|
|
||||||
destination: string;
|
|
||||||
type: 'standalone' | 'managed';
|
|
||||||
project_name?: string;
|
|
||||||
stage_name?: string;
|
|
||||||
health_status: ProxyHealthStatus;
|
|
||||||
ssl_enabled: boolean;
|
|
||||||
npm_proxy_id: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
<!--
|
|
||||||
Phase 4: Unified Proxy Viewer — shows all proxies (managed + standalone)
|
|
||||||
with grouping, filtering, and real-time health indicators.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { ProxyView, ProxyHealthStatus } from '$lib/types';
|
|
||||||
import { listAllProxies } from '$lib/api';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import ProxyCard from '$lib/components/ProxyCard.svelte';
|
|
||||||
import ProxyGroup from '$lib/components/ProxyGroup.svelte';
|
|
||||||
import ProxyFilter from '$lib/components/ProxyFilter.svelte';
|
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
||||||
import { IconGlobe, IconLoader, IconPlus } from '$lib/components/icons';
|
|
||||||
|
|
||||||
let proxies = $state<ProxyView[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
// Filter state
|
|
||||||
let search = $state('');
|
|
||||||
let healthFilter = $state<ProxyHealthStatus | 'all'>('all');
|
|
||||||
let typeFilter = $state<'all' | 'managed' | 'standalone'>('all');
|
|
||||||
|
|
||||||
// Filtered proxies
|
|
||||||
const filtered = $derived(() => {
|
|
||||||
let result = proxies;
|
|
||||||
|
|
||||||
// Text search
|
|
||||||
if (search.length > 0) {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
result = result.filter(
|
|
||||||
(p) =>
|
|
||||||
p.domain.toLowerCase().includes(q) ||
|
|
||||||
p.destination.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Health filter
|
|
||||||
if (healthFilter !== 'all') {
|
|
||||||
result = result.filter((p) => p.health_status === healthFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type filter
|
|
||||||
if (typeFilter !== 'all') {
|
|
||||||
result = result.filter((p) => p.type === typeFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Split into standalone and managed
|
|
||||||
const standaloneProxies = $derived(filtered().filter((p) => p.type === 'standalone'));
|
|
||||||
const managedProxies = $derived(filtered().filter((p) => p.type === 'managed'));
|
|
||||||
|
|
||||||
// Group managed proxies by project, then stage within each project
|
|
||||||
interface StageGroup {
|
|
||||||
stageName: string;
|
|
||||||
proxies: ProxyView[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectGroup {
|
|
||||||
projectName: string;
|
|
||||||
stages: StageGroup[];
|
|
||||||
totalCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const managedGroups = $derived<ProjectGroup[]>(() => {
|
|
||||||
const projectMap = new Map<string, Map<string, ProxyView[]>>();
|
|
||||||
|
|
||||||
for (const proxy of managedProxies) {
|
|
||||||
const projName = proxy.project_name ?? 'Unknown';
|
|
||||||
const stageName = proxy.stage_name ?? 'default';
|
|
||||||
|
|
||||||
if (!projectMap.has(projName)) {
|
|
||||||
projectMap.set(projName, new Map());
|
|
||||||
}
|
|
||||||
const stageMap = projectMap.get(projName)!;
|
|
||||||
|
|
||||||
if (!stageMap.has(stageName)) {
|
|
||||||
stageMap.set(stageName, []);
|
|
||||||
}
|
|
||||||
stageMap.get(stageName)!.push(proxy);
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups: ProjectGroup[] = [];
|
|
||||||
for (const [projectName, stageMap] of projectMap) {
|
|
||||||
const stages: StageGroup[] = [];
|
|
||||||
let totalCount = 0;
|
|
||||||
for (const [stageName, stageProxies] of stageMap) {
|
|
||||||
stages.push({ stageName, proxies: stageProxies });
|
|
||||||
totalCount += stageProxies.length;
|
|
||||||
}
|
|
||||||
groups.push({ projectName, stages, totalCount });
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups.sort((a, b) => a.projectName.localeCompare(b.projectName));
|
|
||||||
});
|
|
||||||
|
|
||||||
function clearFilters(): void {
|
|
||||||
search = '';
|
|
||||||
healthFilter = 'all';
|
|
||||||
typeFilter = 'all';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProxies(): Promise<void> {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
proxies = await listAllProxies();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
error = err instanceof Error ? err.message : 'Failed to load proxies';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
loadProxies();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('proxies.title')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
|
||||||
<IconGlobe size={22} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('proxies.title')}</h1>
|
|
||||||
{#if !loading && proxies.length > 0}
|
|
||||||
<p class="text-sm text-[var(--text-tertiary)]">
|
|
||||||
{proxies.length} {proxies.length === 1 ? 'proxy' : 'proxies'}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/proxies/create"
|
|
||||||
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 transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
|
|
||||||
>
|
|
||||||
<IconPlus size={16} />
|
|
||||||
{$t('proxies.create')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading state -->
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center justify-center py-20">
|
|
||||||
<IconLoader size={24} class="animate-spin text-[var(--color-brand-500)]" />
|
|
||||||
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
|
||||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadProxies}>
|
|
||||||
{$t('common.retry')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else if proxies.length === 0}
|
|
||||||
<!-- Empty state -->
|
|
||||||
<EmptyState
|
|
||||||
title={$t('proxies.noProxies')}
|
|
||||||
description={$t('proxies.noProxiesDesc')}
|
|
||||||
actionLabel={$t('proxies.create')}
|
|
||||||
actionHref="/proxies/create"
|
|
||||||
icon="projects"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<!-- Filter bar -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<ProxyFilter
|
|
||||||
{search}
|
|
||||||
{healthFilter}
|
|
||||||
{typeFilter}
|
|
||||||
onsearchchange={(v) => { search = v; }}
|
|
||||||
onhealthchange={(v) => { healthFilter = v; }}
|
|
||||||
ontypechange={(v) => { typeFilter = v; }}
|
|
||||||
onclear={clearFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No filter results -->
|
|
||||||
{#if filtered().length === 0}
|
|
||||||
<div class="rounded-xl border-2 border-dashed border-[var(--border-primary)] px-6 py-16 text-center">
|
|
||||||
<p class="text-sm text-[var(--text-secondary)]">{$t('proxies.noProxies')}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={clearFilters}
|
|
||||||
class="mt-3 text-sm font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)] transition-colors"
|
|
||||||
>
|
|
||||||
{$t('proxies.filter.clear')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Standalone proxies section -->
|
|
||||||
{#if standaloneProxies.length > 0}
|
|
||||||
<ProxyGroup title={$t('proxies.standalone')} count={standaloneProxies.length}>
|
|
||||||
{#each standaloneProxies as proxy (proxy.id)}
|
|
||||||
<ProxyCard {proxy} />
|
|
||||||
{/each}
|
|
||||||
</ProxyGroup>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Managed proxies grouped by project -->
|
|
||||||
{#if managedGroups().length > 0}
|
|
||||||
{#each managedGroups() as group (group.projectName)}
|
|
||||||
<ProxyGroup title={group.projectName} count={group.totalCount}>
|
|
||||||
{#each group.stages as stage (stage.stageName)}
|
|
||||||
{#if group.stages.length > 1}
|
|
||||||
<div class="col-span-full">
|
|
||||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-tertiary)]">
|
|
||||||
{stage.stageName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#each stage.proxies as proxy (proxy.id)}
|
|
||||||
<ProxyCard {proxy} />
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
</ProxyGroup>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
// Client-side loading — data is fetched in the component via $effect.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<!--
|
|
||||||
Phase 6: Edit Proxy page — loads a standalone proxy and wraps ProxyForm in edit mode.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { StandaloneProxy } from '$lib/types';
|
|
||||||
import { getProxy } from '$lib/api';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import ProxyForm from '$lib/components/ProxyForm.svelte';
|
|
||||||
import { IconGlobe, IconLoader, IconArrowLeft } from '$lib/components/icons';
|
|
||||||
|
|
||||||
let proxy: StandaloneProxy | null = $state(null);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
const proxyId = $derived($page.params.id);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
proxy = await getProxy(proxyId);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
error = err instanceof Error ? err.message : 'Failed to load proxy';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleSave(_proxy: StandaloneProxy): void {
|
|
||||||
goto('/proxies');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete(_id: string): void {
|
|
||||||
goto('/proxies');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel(): void {
|
|
||||||
goto('/proxies');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('proxies.form.editTitle')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<!-- Back link -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<a
|
|
||||||
href="/proxies"
|
|
||||||
class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconArrowLeft size={16} />
|
|
||||||
{$t('common.back')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-6 flex items-center gap-3">
|
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
|
||||||
<IconGlobe size={22} />
|
|
||||||
</div>
|
|
||||||
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('proxies.form.editTitle')}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center justify-center py-20">
|
|
||||||
<IconLoader size={24} class="text-[var(--color-brand-500)]" />
|
|
||||||
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
|
||||||
<a href="/proxies" class="mt-2 inline-block text-sm font-medium text-[var(--color-danger)] underline hover:no-underline">
|
|
||||||
{$t('common.back')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{:else if proxy}
|
|
||||||
<!-- Form card -->
|
|
||||||
<div class="mx-auto max-w-2xl rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
|
||||||
<ProxyForm
|
|
||||||
mode="edit"
|
|
||||||
{proxy}
|
|
||||||
onsave={handleSave}
|
|
||||||
ondelete={handleDelete}
|
|
||||||
oncancel={handleCancel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
// Client-side loading — proxy data is fetched in the component.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<!--
|
|
||||||
Phase 6: Create Proxy page — wraps ProxyForm in create mode.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import type { StandaloneProxy } from '$lib/types';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import ProxyForm from '$lib/components/ProxyForm.svelte';
|
|
||||||
import { IconGlobe, IconArrowLeft } from '$lib/components/icons';
|
|
||||||
|
|
||||||
function handleSave(_proxy: StandaloneProxy): void {
|
|
||||||
goto('/proxies');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel(): void {
|
|
||||||
goto('/proxies');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('proxies.form.title')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<!-- Back link -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<a
|
|
||||||
href="/proxies"
|
|
||||||
class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconArrowLeft size={16} />
|
|
||||||
{$t('common.back')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-6 flex items-center gap-3">
|
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
|
||||||
<IconGlobe size={22} />
|
|
||||||
</div>
|
|
||||||
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('proxies.form.title')}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form card -->
|
|
||||||
<div class="mx-auto max-w-2xl rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
|
||||||
<ProxyForm
|
|
||||||
mode="create"
|
|
||||||
onsave={handleSave}
|
|
||||||
oncancel={handleCancel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
// Client-side loading — ProxyForm handles data fetching.
|
|
||||||
@@ -26,6 +26,12 @@
|
|||||||
// Proxy provider state.
|
// Proxy provider state.
|
||||||
let proxyProvider = $state('npm');
|
let proxyProvider = $state('npm');
|
||||||
|
|
||||||
|
// Traefik settings state.
|
||||||
|
let traefikEntrypoint = $state('websecure');
|
||||||
|
let traefikCertResolver = $state('letsencrypt');
|
||||||
|
let traefikNetwork = $state('');
|
||||||
|
let traefikApiUrl = $state('');
|
||||||
|
|
||||||
// DNS settings state.
|
// DNS settings state.
|
||||||
let wildcardDns = $state(true);
|
let wildcardDns = $state(true);
|
||||||
let dnsProvider = $state('');
|
let dnsProvider = $state('');
|
||||||
@@ -91,6 +97,10 @@
|
|||||||
notificationUrl = settings.notification_url ?? '';
|
notificationUrl = settings.notification_url ?? '';
|
||||||
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
|
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
|
||||||
proxyProvider = settings.proxy_provider ?? 'npm';
|
proxyProvider = settings.proxy_provider ?? 'npm';
|
||||||
|
traefikEntrypoint = settings.traefik_entrypoint ?? 'websecure';
|
||||||
|
traefikCertResolver = settings.traefik_cert_resolver ?? 'letsencrypt';
|
||||||
|
traefikNetwork = settings.traefik_network ?? '';
|
||||||
|
traefikApiUrl = settings.traefik_api_url ?? '';
|
||||||
wildcardDns = settings.wildcard_dns ?? true;
|
wildcardDns = settings.wildcard_dns ?? true;
|
||||||
dnsProvider = settings.dns_provider ?? '';
|
dnsProvider = settings.dns_provider ?? '';
|
||||||
hasCloudflareApiToken = settings.has_cloudflare_api_token ?? false;
|
hasCloudflareApiToken = settings.has_cloudflare_api_token ?? false;
|
||||||
@@ -118,6 +128,10 @@
|
|||||||
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
|
subdomain_pattern: subdomainPattern.trim(), polling_interval: 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,
|
||||||
|
traefik_entrypoint: traefikEntrypoint.trim() || 'websecure',
|
||||||
|
traefik_cert_resolver: traefikCertResolver.trim(),
|
||||||
|
traefik_network: traefikNetwork.trim(),
|
||||||
|
traefik_api_url: traefikApiUrl.trim(),
|
||||||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
|
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
|
||||||
wildcard_dns: wildcardDns,
|
wildcard_dns: wildcardDns,
|
||||||
dns_provider: wildcardDns ? '' : dnsProvider,
|
dns_provider: wildcardDns ? '' : dnsProvider,
|
||||||
@@ -290,12 +304,27 @@
|
|||||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settings.proxyNpmDesc')}</p>
|
<p class="text-xs text-[var(--text-tertiary)]">{$t('settings.proxyNpmDesc')}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-1 cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors {proxyProvider === 'traefik' ? 'border-[var(--color-brand-500)] bg-[var(--surface-card-hover)]' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}">
|
||||||
|
<input type="radio" bind:group={proxyProvider} value="traefik" class="mt-0.5 h-4 w-4 text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settings.proxyTraefik')}</span>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">{$t('settings.proxyTraefikDesc')}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{#if proxyProvider === 'none'}
|
{#if proxyProvider === 'none'}
|
||||||
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30 p-3">
|
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30 p-3">
|
||||||
<p class="text-sm text-amber-800 dark:text-amber-300">{$t('settings.proxyNoneWarning')}</p>
|
<p class="text-sm text-amber-800 dark:text-amber-300">{$t('settings.proxyNoneWarning')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if proxyProvider === 'traefik'}
|
||||||
|
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-4">
|
||||||
|
<FormField label={$t('settings.traefikEntrypoint')} name="traefikEntrypoint" bind:value={traefikEntrypoint} placeholder="websecure" helpText={$t('settings.traefikEntrypointHelp')} />
|
||||||
|
<FormField label={$t('settings.traefikCertResolver')} name="traefikCertResolver" bind:value={traefikCertResolver} placeholder="letsencrypt" helpText={$t('settings.traefikCertResolverHelp')} />
|
||||||
|
<FormField label={$t('settings.traefikNetwork')} name="traefikNetwork" bind:value={traefikNetwork} placeholder="" helpText={$t('settings.traefikNetworkHelp')} />
|
||||||
|
<FormField label={$t('settings.traefikApiUrl')} name="traefikApiUrl" bind:value={traefikApiUrl} placeholder="http://traefik:8080" helpText={$t('settings.traefikApiUrlHelp')} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SSL Certificate moved to Credentials page -->
|
<!-- SSL Certificate moved to Credentials page -->
|
||||||
|
|||||||
Reference in New Issue
Block a user