feat: unified THE FORGE // SECTION headers and merged proxy routes
Build / build (push) Successful in 10m37s

UI consistency
- ForgeHero now supports backHref, mono kicker, stats snippet, staggered
  entrance animation, and a registration-tick divider
- Every route now opens with the same "THE FORGE // SECTION" eyebrow: projects,
  sites, stacks, proxies, events, dns, deploy, settings, stale containers,
  site/project detail + env/volumes/browse, new site wizard
- Stacks list/detail/new moved to the shared hero and brand-anchor eyebrow
- Toolbars migrated from bespoke buttons to the shared .forge-btn utilities
- Sidebar footline adds a live UTC "forge clock" and a vim-style g-prefix
  quick-nav hint (g d/p/s/k/x/r/e/c jumps to each section)

Proxies page
- Server-side: merge static site proxy routes with instance routes and sort
  by domain (internal/api/proxies.go, internal/store/static_sites.go)
- ProxyRoute gains a Source field ("instance" | "static_site")
- Frontend adds source filter tabs and per-source labels/badges
This commit is contained in:
2026-04-22 16:27:55 +03:00
parent 0fd92fdfa3
commit ef0669d5dd
25 changed files with 702 additions and 277 deletions
+20 -3
View File
@@ -3,10 +3,12 @@ package api
import (
"log/slog"
"net/http"
"sort"
)
// listProxyRoutes handles GET /api/proxies.
// Returns all proxy-enabled instances with project and stage names.
// Returns proxy routes from both Docker instances and static sites,
// merged and sorted by domain.
func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
@@ -15,12 +17,27 @@ func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
return
}
routes, err := s.store.ListProxyRoutes(settings.Domain)
instanceRoutes, err := s.store.ListProxyRoutes(settings.Domain)
if err != nil {
slog.Error("failed to list proxy routes", "error", err)
slog.Error("failed to list instance proxy routes", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
siteRoutes, err := s.store.ListStaticSiteProxyRoutes(settings.Domain)
if err != nil {
slog.Error("failed to list static site proxy routes", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
routes := append(instanceRoutes, siteRoutes...)
sort.SliceStable(routes, func(i, j int) bool {
if routes[i].Domain == routes[j].Domain {
return routes[i].ProjectName < routes[j].ProjectName
}
return routes[i].Domain < routes[j].Domain
})
respondJSON(w, http.StatusOK, routes)
}
+4 -1
View File
@@ -119,8 +119,10 @@ func (s *Store) ListAllInstances() ([]Instance, error) {
return instances, rows.Err()
}
// ProxyRoute represents a proxy-enabled instance with project and stage names.
// ProxyRoute represents a proxy-enabled resource (Docker instance or static site)
// joined with the human-readable names needed to render the Proxies page.
type ProxyRoute struct {
Source string `json:"source"` // "instance" or "static_site"
InstanceID string `json:"instance_id"`
ProjectID string `json:"project_id"`
ProjectName string `json:"project_name"`
@@ -164,6 +166,7 @@ func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
); err != nil {
return nil, fmt.Errorf("scan proxy route: %w", err)
}
r.Source = "instance"
if domain != "" && r.Subdomain != "" {
r.Domain = r.Subdomain + "." + domain
}
+45
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
@@ -154,6 +155,50 @@ func (s *Store) UpdateStaticSiteContainer(id, containerID, proxyRouteID string)
return nil
}
// ListStaticSiteProxyRoutes returns proxy routes backed by static sites,
// shaped to match the unified ProxyRoute model used by the Proxies page.
// Sites without an active proxy route are skipped.
func (s *Store) ListStaticSiteProxyRoutes(domain string) ([]ProxyRoute, error) {
rows, err := s.db.Query(
`SELECT id, name, mode, provider, domain, container_id, proxy_route_id, status, created_at
FROM static_sites
WHERE proxy_route_id != ''
ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query static site proxy routes: %w", err)
}
defer rows.Close()
suffix := ""
if domain != "" {
suffix = "." + strings.ToLower(domain)
}
routes := []ProxyRoute{}
for rows.Next() {
var r ProxyRoute
var mode, provider, fullDomain string
if err := rows.Scan(
&r.InstanceID, &r.ProjectName, &mode, &provider, &fullDomain,
&r.ContainerID, &r.ProxyRouteID, &r.Status, &r.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan static site proxy route: %w", err)
}
r.Source = "static_site"
r.StageName = mode
r.ImageTag = provider
r.Domain = fullDomain
if suffix != "" && strings.HasSuffix(strings.ToLower(fullDomain), suffix) {
r.Subdomain = fullDomain[:len(fullDomain)-len(suffix)]
} else {
r.Subdomain = fullDomain
}
routes = append(routes, r)
}
return routes, rows.Err()
}
// DeleteStaticSite removes a static site by ID. Cascading deletes handle secrets.
func (s *Store) DeleteStaticSite(id string) error {
result, err := s.db.Exec(`DELETE FROM static_sites WHERE id = ?`, id)