feat: static sites feature with Gitea/GitHub/GitLab support and Deno backend

Deploy static content from Git repository folders with optional server-side
API endpoints. Supports Gitea/Forgejo/Gogs, GitHub, and GitLab with provider
autodetection.

- New Sites entity with CRUD, encrypted secrets, and manual/push/tag sync triggers
- Pluggable GitProvider interface with three implementations
- Deno container mode: auto-generates router from API_{method}_{name} exports
- Static container mode: nginx serving files with optional markdown rendering
- Wizard UI with provider selector, repo picker, branch/folder tree pickers
- Deploy pipeline builds fresh image, starts container, configures NPM proxy
- Stop/Start buttons, force redeploy on manual trigger
- Periodic health checker detects crashed containers
- Proxy route existence check during auto-sync
This commit is contained in:
2026-04-11 03:35:57 +03:00
parent b0816502bf
commit 8d2c5a063b
31 changed files with 4967 additions and 5 deletions
+10
View File
@@ -32,6 +32,7 @@ import (
"github.com/alexei/docker-watcher/internal/proxy"
"github.com/alexei/docker-watcher/internal/registry"
"github.com/alexei/docker-watcher/internal/stale"
"github.com/alexei/docker-watcher/internal/staticsite"
"github.com/alexei/docker-watcher/internal/store"
"github.com/alexei/docker-watcher/internal/webhook"
)
@@ -280,8 +281,16 @@ func main() {
}
scheduleAutobackup(settings.BackupEnabled, settings.BackupIntervalHours)
// Initialize static site manager and health checker.
staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, encKey)
staticSiteHealth := staticsite.NewHealthChecker(db, dockerClient, staticSiteMgr)
if err := staticSiteHealth.Start("2m"); err != nil {
slog.Warn("failed to start static site health checker", "error", err)
}
// Build API server.
apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, webhookHandler, eventBus, encKey)
apiServer.SetStaticSiteManager(staticSiteMgr)
apiServer.SetStaleScanner(staleScanner)
apiServer.SetBackupEngine(backupEngine)
apiServer.SetDBPath(dbPath)
@@ -341,6 +350,7 @@ func main() {
// Stop accepting new work.
cronScheduler.Stop()
eventBus.Unsubscribe(notifySub)
staticSiteHealth.Stop()
staleScanner.Stop()
poller.Stop()
+1
View File
@@ -36,6 +36,7 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/yuin/goldmark v1.8.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
+2
View File
@@ -63,6 +63,8 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
+35
View File
@@ -16,6 +16,7 @@ import (
"github.com/alexei/docker-watcher/internal/npm"
"github.com/alexei/docker-watcher/internal/proxy"
"github.com/alexei/docker-watcher/internal/stale"
"github.com/alexei/docker-watcher/internal/staticsite"
"github.com/alexei/docker-watcher/internal/store"
"github.com/alexei/docker-watcher/internal/webhook"
)
@@ -42,6 +43,7 @@ type Server struct {
dnsProvider dns.Provider
onDNSProviderChanged DNSProviderChangedFunc
staticSiteManager *staticsite.Manager
backupEngine *backup.Engine
dbPath string
shutdownFunc func() // called after restore to trigger graceful shutdown
@@ -83,6 +85,11 @@ func NewServer(
return s
}
// SetStaticSiteManager sets the static site manager on the server.
func (s *Server) SetStaticSiteManager(mgr *staticsite.Manager) {
s.staticSiteManager = mgr
}
// SetStaleScanner sets the stale scanner on the server.
// Called after both the API server and scanner are initialized.
func (s *Server) SetStaleScanner(scanner *stale.Scanner) {
@@ -243,6 +250,26 @@ func (s *Server) Router() chi.Router {
r.Post("/volumes/{volId}/upload", s.uploadToVolume)
})
})
// Static sites.
r.Get("/sites", s.listStaticSites)
r.Route("/sites/{id}", func(r chi.Router) {
r.Get("/", s.getStaticSite)
r.Get("/secrets", s.listStaticSiteSecrets)
// Admin-only mutations.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
r.Put("/", s.updateStaticSite)
r.Delete("/", s.deleteStaticSite)
r.Post("/deploy", s.deployStaticSite)
r.Post("/stop", s.stopStaticSite)
r.Post("/start", s.startStaticSite)
r.Post("/secrets", s.createStaticSiteSecret)
r.Put("/secrets/{sid}", s.updateStaticSiteSecret)
r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret)
})
})
r.Get("/deploys", s.listDeploys)
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
r.Get("/events", s.streamEvents)
@@ -294,6 +321,14 @@ func (s *Server) Router() chi.Router {
// Project creation.
r.Post("/projects", s.createProject)
// Static site creation and tools.
r.Post("/sites", s.createStaticSite)
r.Post("/sites/test-connection", s.testStaticSiteConnection)
r.Post("/sites/branches", s.listStaticSiteBranches)
r.Post("/sites/tree", s.listStaticSiteTree)
r.Post("/sites/detect-provider", s.detectStaticSiteProvider)
r.Post("/sites/repos", s.listStaticSiteRepos)
// Quick deploy endpoints.
r.Post("/deploy/inspect", s.inspectImage)
r.Post("/deploy/quick", s.quickDeploy)
+613
View File
@@ -0,0 +1,613 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/store"
)
// ── List / Get ─────────────────────────────────────────────────────────
func (s *Server) listStaticSites(w http.ResponseWriter, r *http.Request) {
sites, err := s.store.GetAllStaticSites()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list static sites")
return
}
// Mask access tokens in response.
for i := range sites {
sites[i].AccessToken = maskToken(sites[i].AccessToken)
}
respondJSON(w, http.StatusOK, sites)
}
func (s *Server) getStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
site.AccessToken = maskToken(site.AccessToken)
respondJSON(w, http.StatusOK, site)
}
// ── Create ──────────────────────────────────────────────────────────
type createStaticSiteRequest struct {
Name string `json:"name"`
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
FolderPath string `json:"folder_path"`
AccessToken string `json:"access_token"`
Domain string `json:"domain"`
Mode string `json:"mode"`
RenderMarkdown bool `json:"render_markdown"`
SyncTrigger string `json:"sync_trigger"`
TagPattern string `json:"tag_pattern"`
}
func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
var req createStaticSiteRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" || req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
respondError(w, http.StatusBadRequest, "name, gitea_url, repo_owner, and repo_name are required")
return
}
if req.Branch == "" {
req.Branch = "main"
}
if req.Mode == "" {
req.Mode = "static"
}
if req.SyncTrigger == "" {
req.SyncTrigger = "manual"
}
// Encrypt access token if provided.
encryptedToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
return
}
encryptedToken = encrypted
}
site := store.StaticSite{
Name: req.Name,
Provider: req.Provider,
GiteaURL: strings.TrimRight(req.GiteaURL, "/"),
RepoOwner: req.RepoOwner,
RepoName: req.RepoName,
Branch: req.Branch,
FolderPath: req.FolderPath,
AccessToken: encryptedToken,
Domain: req.Domain,
Mode: req.Mode,
RenderMarkdown: req.RenderMarkdown,
SyncTrigger: req.SyncTrigger,
TagPattern: req.TagPattern,
Status: "idle",
}
created, err := s.store.CreateStaticSite(site)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create static site: "+err.Error())
return
}
created.AccessToken = maskToken(created.AccessToken)
respondJSON(w, http.StatusCreated, created)
}
// ── Update ──────────────────────────────────────────────────────────
func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to get static site")
return
}
var req createStaticSiteRequest
if !decodeJSON(w, r, &req) {
return
}
// Update fields.
if req.Name != "" {
existing.Name = req.Name
}
if req.Provider != "" {
existing.Provider = req.Provider
}
if req.GiteaURL != "" {
existing.GiteaURL = strings.TrimRight(req.GiteaURL, "/")
}
if req.RepoOwner != "" {
existing.RepoOwner = req.RepoOwner
}
if req.RepoName != "" {
existing.RepoName = req.RepoName
}
if req.Branch != "" {
existing.Branch = req.Branch
}
if req.FolderPath != "" {
existing.FolderPath = req.FolderPath
}
if req.Domain != "" {
existing.Domain = req.Domain
}
if req.Mode != "" {
existing.Mode = req.Mode
}
if req.SyncTrigger != "" {
existing.SyncTrigger = req.SyncTrigger
}
existing.RenderMarkdown = req.RenderMarkdown
existing.TagPattern = req.TagPattern
// Update access token only if a new one is provided.
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
return
}
existing.AccessToken = encrypted
}
if err := s.store.UpdateStaticSite(existing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update static site: "+err.Error())
return
}
existing.AccessToken = maskToken(existing.AccessToken)
respondJSON(w, http.StatusOK, existing)
}
// ── Delete ──────────────────────────────────────────────────────────
func (s *Server) deleteStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Remove container and proxy route first.
if s.staticSiteManager != nil {
if err := s.staticSiteManager.Remove(r.Context(), id); err != nil {
// Log but don't fail — still delete the DB record.
respondError(w, http.StatusInternalServerError, "failed to remove site resources: "+err.Error())
return
}
}
if err := s.store.DeleteStaticSite(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete static site")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// ── Deploy ──────────────────────────────────────────────────────────
func (s *Server) deployStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
// Trigger deploy asynchronously with a detached context
// (the HTTP request context is canceled when the response is sent).
// Manual deploys always force a full rebuild + proxy regeneration.
go func() {
ctx := context.Background()
if err := s.staticSiteManager.Deploy(ctx, id, true); err != nil {
// Error is already stored in the site status.
return
}
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "deploying"})
}
// ── Stop / Start ────────────────────────────────────────────────────
func (s *Server) stopStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
go func() {
ctx := context.Background()
_ = s.staticSiteManager.Stop(ctx, id)
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "stopping"})
}
func (s *Server) startStaticSite(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
go func() {
ctx := context.Background()
_ = s.staticSiteManager.Start(ctx, id)
}()
respondJSON(w, http.StatusAccepted, map[string]string{"status": "starting"})
}
// ── Test Connection ─────────────────────────────────────────────────
type testConnectionRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
}
func (s *Server) testStaticSiteConnection(w http.ResponseWriter, r *http.Request) {
var req testConnectionRequest
if !decodeJSON(w, r, &req) {
return
}
if req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
respondError(w, http.StatusBadRequest, "gitea_url, repo_owner, and repo_name are required")
return
}
// Encrypt token for the manager to decrypt (consistent handling).
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
if err := s.staticSiteManager.TestConnection(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName); err != nil {
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "connected"})
}
// ── Branches ────────────────────────────────────────────────────────
type listBranchesRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
}
func (s *Server) listStaticSiteBranches(w http.ResponseWriter, r *http.Request) {
var req listBranchesRequest
if !decodeJSON(w, r, &req) {
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
branches, err := s.staticSiteManager.ListBranches(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list branches: "+err.Error())
return
}
respondJSON(w, http.StatusOK, branches)
}
// ── Tree ────────────────────────────────────────────────────────────
type listTreeRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
}
func (s *Server) listStaticSiteTree(w http.ResponseWriter, r *http.Request) {
var req listTreeRequest
if !decodeJSON(w, r, &req) {
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
entries, err := s.staticSiteManager.ListTree(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName, req.Branch)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list tree: "+err.Error())
return
}
respondJSON(w, http.StatusOK, entries)
}
// ── Secrets ─────────────────────────────────────────────────────────
func (s *Server) listStaticSiteSecrets(w http.ResponseWriter, r *http.Request) {
siteID := chi.URLParam(r, "id")
secrets, err := s.store.GetStaticSiteSecretsBySiteID(siteID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list secrets")
return
}
// Mask encrypted values.
for i := range secrets {
if secrets[i].Encrypted {
secrets[i].Value = "••••••••"
}
}
respondJSON(w, http.StatusOK, secrets)
}
type createSecretRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
}
func (s *Server) createStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
siteID := chi.URLParam(r, "id")
var req createSecretRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Key == "" {
respondError(w, http.StatusBadRequest, "key is required")
return
}
value := req.Value
if req.Encrypted && value != "" {
encrypted, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
return
}
value = encrypted
}
secret := store.StaticSiteSecret{
SiteID: siteID,
Key: req.Key,
Value: value,
Encrypted: req.Encrypted,
}
created, err := s.store.CreateStaticSiteSecret(secret)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create secret: "+err.Error())
return
}
if created.Encrypted {
created.Value = "••••••••"
}
respondJSON(w, http.StatusCreated, created)
}
func (s *Server) updateStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
secretID := chi.URLParam(r, "sid")
existing, err := s.store.GetStaticSiteSecretByID(secretID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "secret")
return
}
respondError(w, http.StatusInternalServerError, "failed to get secret")
return
}
var req createSecretRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Key != "" {
existing.Key = req.Key
}
existing.Encrypted = req.Encrypted
if req.Value != "" {
value := req.Value
if req.Encrypted {
encrypted, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
return
}
value = encrypted
}
existing.Value = value
}
if err := s.store.UpdateStaticSiteSecret(existing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update secret: "+err.Error())
return
}
if existing.Encrypted {
existing.Value = "••••••••"
}
respondJSON(w, http.StatusOK, existing)
}
func (s *Server) deleteStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
secretID := chi.URLParam(r, "sid")
if err := s.store.DeleteStaticSiteSecret(secretID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "secret")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete secret")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": secretID})
}
// ── List Repos ──────────────────────────────────────────────────────
type listReposRequest struct {
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
AccessToken string `json:"access_token"`
Query string `json:"query"`
}
func (s *Server) listStaticSiteRepos(w http.ResponseWriter, r *http.Request) {
var req listReposRequest
if !decodeJSON(w, r, &req) {
return
}
if req.GiteaURL == "" {
respondError(w, http.StatusBadRequest, "gitea_url is required")
return
}
encToken := ""
if req.AccessToken != "" {
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to process token")
return
}
encToken = encrypted
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
repos, err := s.staticSiteManager.ListRepos(r.Context(), req.Provider, req.GiteaURL, encToken, req.Query)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to list repos: "+err.Error())
return
}
respondJSON(w, http.StatusOK, repos)
}
// ── Detect Provider ─────────────────────────────────────────────────
type detectProviderRequest struct {
URL string `json:"url"`
}
func (s *Server) detectStaticSiteProvider(w http.ResponseWriter, r *http.Request) {
var req detectProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if req.URL == "" {
respondError(w, http.StatusBadRequest, "url is required")
return
}
if s.staticSiteManager == nil {
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
return
}
provider := s.staticSiteManager.DetectProvider(r.Context(), req.URL)
respondJSON(w, http.StatusOK, map[string]string{"provider": provider})
}
// maskToken returns a masked version of a token string for API responses.
func maskToken(token string) string {
if token == "" {
return ""
}
return "••••••••"
}
+95
View File
@@ -0,0 +1,95 @@
package docker
import (
"archive/tar"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/moby/moby/client"
)
// BuildImage builds a Docker image from a directory containing a Dockerfile.
// The directory is packaged as a tar archive and sent to the Docker daemon.
// The tag parameter is the image name:tag to apply (e.g., "dw-site-myapp:latest").
func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error {
// Create tar archive of the build context.
pr, pw := io.Pipe()
go func() {
tw := tar.NewWriter(pw)
err := filepath.Walk(contextDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(contextDir, path)
if err != nil {
return fmt.Errorf("rel path: %w", err)
}
relPath = filepath.ToSlash(relPath)
if relPath == "." {
return nil
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return fmt.Errorf("tar header for %s: %w", relPath, err)
}
header.Name = relPath
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("write tar header for %s: %w", relPath, err)
}
if info.IsDir() {
return nil
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
defer file.Close()
if _, err := io.Copy(tw, file); err != nil {
return fmt.Errorf("copy %s to tar: %w", relPath, err)
}
return nil
})
if closeErr := tw.Close(); closeErr != nil && err == nil {
err = closeErr
}
pw.CloseWithError(err)
}()
resp, err := c.api.ImageBuild(ctx, pr, client.ImageBuildOptions{
Dockerfile: "Dockerfile",
Tags: []string{tag},
Remove: true,
ForceRemove: true,
})
if err != nil {
return fmt.Errorf("build image %s: %w", tag, err)
}
defer resp.Body.Close()
// Read the build output to completion (required for the build to finish).
output, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read build output for %s: %w", tag, err)
}
// Check for error in build output.
if strings.Contains(string(output), `"error"`) {
return fmt.Errorf("build image %s: build errors in output", tag)
}
return nil
}
+10
View File
@@ -21,6 +21,9 @@ const (
// EventLog is emitted for audit trail and operational log entries.
EventLog EventType = "event_log"
// EventStaticSiteStatus is emitted when a static site status changes.
EventStaticSiteStatus EventType = "static_site_status"
)
// Event is a single event published on the bus.
@@ -64,6 +67,13 @@ type EventLogPayload struct {
CreatedAt string `json:"created_at"`
}
// StaticSiteStatusPayload is the payload for EventStaticSiteStatus events.
type StaticSiteStatusPayload struct {
SiteID string `json:"site_id"`
Name string `json:"name"`
Status string `json:"status"`
}
// Subscriber is a channel that receives events.
type Subscriber chan Event
+4
View File
@@ -17,6 +17,10 @@ func (n *NoneProvider) DeleteRoute(_ context.Context, _ string) error {
return nil
}
func (n *NoneProvider) RouteExists(_ context.Context, _ string) (bool, error) {
return true, nil
}
func (n *NoneProvider) ContainerLabels(_ string, _ int) map[string]string {
return nil
}
+11
View File
@@ -101,6 +101,17 @@ func (p *NpmProvider) DeleteRoute(ctx context.Context, routeID string) error {
return p.client.DeleteProxyHost(ctx, id)
}
func (p *NpmProvider) RouteExists(ctx context.Context, domain string) (bool, error) {
if err := p.auth(ctx); err != nil {
return false, err
}
_, found, err := p.client.FindProxyHostByDomain(ctx, domain)
if err != nil {
return false, fmt.Errorf("find proxy host: %w", err)
}
return found, nil
}
func (p *NpmProvider) ContainerLabels(_ string, _ int) map[string]string {
// NPM configures routing via its API, not Docker labels.
return nil
+4
View File
@@ -27,6 +27,10 @@ type Provider interface {
// Does nothing if routeID is empty.
DeleteRoute(ctx context.Context, routeID string) error
// RouteExists returns true if a proxy route exists for the given domain.
// Used for health checks during auto-sync to detect externally removed routes.
RouteExists(ctx context.Context, domain string) (bool, error)
// ContainerLabels returns Docker labels to set on containers at creation time.
// Traefik uses labels for auto-discovery; NPM and None return nil.
ContainerLabels(domain string, port int) map[string]string
+6
View File
@@ -48,6 +48,12 @@ func (t *TraefikProvider) DeleteRoute(_ context.Context, _ string) error {
return nil
}
// RouteExists for Traefik is a no-op (always returns true) since routes are
// managed via container labels and don't need separate tracking.
func (t *TraefikProvider) RouteExists(_ context.Context, _ string) (bool, error) {
return true, nil
}
// ContainerLabels returns Docker labels for Traefik auto-discovery.
func (t *TraefikProvider) ContainerLabels(domain string, port int) map[string]string {
name := sanitizeDomain(domain)
+253
View File
@@ -0,0 +1,253 @@
package deno
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"text/template"
)
// RouteEntry represents a parsed API endpoint from a TypeScript file.
type RouteEntry struct {
Method string // GET, POST, PUT, DELETE, PATCH
Path string // e.g., "/api/weather/current"
ImportPath string // relative import path, e.g., "./api/weather.ts"
FunctionName string // original export name, e.g., "API_get_current"
}
// validMethods lists the HTTP methods recognized by the convention.
var validMethods = map[string]bool{
"get": true, "post": true, "put": true, "delete": true, "patch": true,
}
// apiExportPattern matches "export async function API_..." or "export function API_..."
var apiExportPattern = regexp.MustCompile(`^export\s+(?:async\s+)?function\s+(API_\w+)`)
// ScanRoutes scans all .ts files in the api/ subdirectory for API_ prefixed exports
// and returns a list of RouteEntry for each discovered endpoint.
func ScanRoutes(apiDir string) ([]RouteEntry, error) {
var routes []RouteEntry
err := filepath.Walk(apiDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".ts" && ext != ".js" {
return nil
}
// Derive the base route from file path relative to apiDir.
relPath, err := filepath.Rel(apiDir, path)
if err != nil {
return fmt.Errorf("rel path: %w", err)
}
// Convert "weather.ts" → "weather", "sub/weather.ts" → "sub/weather"
baseName := strings.TrimSuffix(relPath, filepath.Ext(relPath))
baseName = filepath.ToSlash(baseName)
baseRoute := "/api/" + baseName
// Import path relative to the generated router.
importPath := "./api/" + filepath.ToSlash(relPath)
// Scan file for API_ exports.
fileRoutes, err := scanFileExports(path, baseRoute, importPath)
if err != nil {
return fmt.Errorf("scan %s: %w", relPath, err)
}
routes = append(routes, fileRoutes...)
return nil
})
return routes, err
}
// scanFileExports reads a TypeScript file and extracts API_ prefixed exports.
func scanFileExports(filePath, baseRoute, importPath string) ([]RouteEntry, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
defer file.Close()
var routes []RouteEntry
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
matches := apiExportPattern.FindStringSubmatch(line)
if len(matches) < 2 {
continue
}
funcName := matches[1] // e.g., "API_get_current"
entry, ok := parseAPIFunctionName(funcName, baseRoute, importPath)
if ok {
routes = append(routes, entry)
}
}
return routes, scanner.Err()
}
// parseAPIFunctionName parses "API_get_current" into a RouteEntry.
// Convention: API_{method} → handles base route; API_{method}_{path} → handles sub-route.
func parseAPIFunctionName(funcName, baseRoute, importPath string) (RouteEntry, bool) {
// Strip "API_" prefix.
rest := strings.TrimPrefix(funcName, "API_")
if rest == "" {
return RouteEntry{}, false
}
// Split on first "_" to extract method.
parts := strings.SplitN(rest, "_", 2)
method := strings.ToLower(parts[0])
if !validMethods[method] {
return RouteEntry{}, false
}
path := baseRoute
if len(parts) == 2 && parts[1] != "" {
path = baseRoute + "/" + parts[1]
}
return RouteEntry{
Method: strings.ToUpper(method),
Path: path,
ImportPath: importPath,
FunctionName: funcName,
}, true
}
// routerTemplate is the Deno router entrypoint template.
var routerTemplate = template.Must(template.New("router").Parse(`// Auto-generated by Docker Watcher — do not edit manually.
import { serveDir } from "https://deno.land/std/http/file_server.ts";
{{- range .Imports}}
import { {{.FunctionName}} as {{.Alias}} } from "{{.Path}}";
{{- end}}
const routes: Array<{ method: string; path: string; handler: (req: Request) => Promise<Response> | Response }> = [
{{- range .Routes}}
{ method: "{{.Method}}", path: "{{.Path}}", handler: {{.Alias}} },
{{- end}}
];
Deno.serve({ port: 8000 }, async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const method = req.method.toUpperCase();
// Match API routes.
for (const route of routes) {
if (route.method === method && url.pathname === route.path) {
try {
return await route.handler(req);
} catch (e) {
console.error("Handler error:", e);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
}
// Serve static files from /public.
return serveDir(req, { fsRoot: "/app/public", quiet: true });
});
`))
// ImportEntry represents a single aliased import.
type ImportEntry struct {
FunctionName string // original name, e.g., "API_get"
Alias string // unique alias, e.g., "time_API_get"
Path string // import path, e.g., "./api/time.ts"
}
// routerData holds the data for the router template.
type routerData struct {
Imports []ImportEntry
Routes []routeWithAlias
}
// routeWithAlias is a route entry using the aliased handler name.
type routeWithAlias struct {
Method string
Path string
Alias string
}
// GenerateRouter generates the Deno router TypeScript source from route entries.
func GenerateRouter(routes []RouteEntry) (string, error) {
var imports []ImportEntry
var aliasedRoutes []routeWithAlias
for _, r := range routes {
// Derive a unique alias from the file base name + function name.
// e.g., "./api/echo.ts" + "API_get" → "echo_API_get"
baseName := filepath.Base(r.ImportPath)
baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName))
alias := baseName + "_" + r.FunctionName
imports = append(imports, ImportEntry{
FunctionName: r.FunctionName,
Alias: alias,
Path: r.ImportPath,
})
aliasedRoutes = append(aliasedRoutes, routeWithAlias{
Method: r.Method,
Path: r.Path,
Alias: alias,
})
}
data := routerData{Imports: imports, Routes: aliasedRoutes}
var buf strings.Builder
if err := routerTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("execute router template: %w", err)
}
return buf.String(), nil
}
// GenerateDockerfile generates the Dockerfile for the Deno container.
func GenerateDockerfile() string {
return `FROM denoland/deno:latest
WORKDIR /app
# Copy static files.
COPY public/ /app/public/
# Copy API source files and generated router.
COPY api/ /app/api/
COPY router.ts /app/router.ts
# Cache dependencies.
RUN deno install --entrypoint router.ts
EXPOSE 8000
CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-env", "router.ts"]
`
}
// GenerateStaticDockerfile generates a minimal nginx Dockerfile for pure static sites.
func GenerateStaticDockerfile() string {
return `FROM nginx:alpine
COPY . /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
`
}
+360
View File
@@ -0,0 +1,360 @@
package staticsite
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// giteaTreeEntry represents a single entry in a Gitea git tree response.
type giteaTreeEntry struct {
Path string `json:"path"`
Type string `json:"type"` // "blob" or "tree"
SHA string `json:"sha"`
Size int64 `json:"size"`
}
// giteaTreeResponse represents the Gitea git tree API response.
type giteaTreeResponse struct {
SHA string `json:"sha"`
Entries []giteaTreeEntry `json:"tree"`
Truncated bool `json:"truncated"`
}
// giteaBranch represents a branch from the Gitea API.
type giteaBranch struct {
Name string `json:"name"`
Commit struct {
ID string `json:"id"`
} `json:"commit"`
}
// giteaRef represents a git reference from the Gitea API.
type giteaRef struct {
Ref string `json:"ref"`
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
// GiteaContentFetcher downloads folder contents from a Gitea repository.
type GiteaContentFetcher struct {
baseURL string
token string
httpClient *http.Client
}
// NewGiteaContentFetcher creates a new content fetcher.
// token may be empty for public repositories.
func NewGiteaContentFetcher(baseURL, token string) *GiteaContentFetcher {
return &GiteaContentFetcher{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
// Name returns the provider identifier.
func (f *GiteaContentFetcher) Name() string { return "gitea" }
// ListRepos returns repositories accessible with the current token.
func (f *GiteaContentFetcher) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) {
var allRepos []RepoInfo
page := 1
limit := 50
for {
url := fmt.Sprintf("%s/api/v1/repos/search?limit=%d&page=%d", f.baseURL, limit, page)
if query != "" {
url += "&q=" + query
}
if f.token != "" {
// When authenticated, include private repos.
url += "&private=true"
}
body, err := f.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("list repos: %w", err)
}
var result struct {
Data []struct {
Owner struct {
Login string `json:"login"`
} `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Private bool `json:"private"`
HTMLURL string `json:"html_url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
// Gitea search wraps in {"data": [...]}, but some versions return a flat array.
var flat []struct {
Owner struct {
Login string `json:"login"`
} `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Private bool `json:"private"`
HTMLURL string `json:"html_url"`
}
if err2 := json.Unmarshal(body, &flat); err2 != nil {
return nil, fmt.Errorf("decode repos: %w", err)
}
for _, r := range flat {
allRepos = append(allRepos, RepoInfo{
Owner: r.Owner.Login,
Name: r.Name,
FullName: r.FullName,
Description: r.Description,
Private: r.Private,
HTMLURL: r.HTMLURL,
})
}
if len(flat) < limit {
break
}
page++
continue
}
for _, r := range result.Data {
allRepos = append(allRepos, RepoInfo{
Owner: r.Owner.Login,
Name: r.Name,
FullName: r.FullName,
Description: r.Description,
Private: r.Private,
HTMLURL: r.HTMLURL,
})
}
if len(result.Data) < limit {
break
}
page++
}
return allRepos, nil
}
// ListBranches returns all branches for a repository.
func (f *GiteaContentFetcher) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
var allBranches []string
page := 1
limit := 50
for {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?page=%d&limit=%d",
f.baseURL, owner, repo, page, limit)
body, err := f.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("list branches: %w", err)
}
var branches []giteaBranch
if err := json.Unmarshal(body, &branches); err != nil {
return nil, fmt.Errorf("decode branches: %w", err)
}
for _, b := range branches {
allBranches = append(allBranches, b.Name)
}
if len(branches) < limit {
break
}
page++
}
return allBranches, nil
}
// GetLatestCommitSHA returns the latest commit SHA for a branch.
func (f *GiteaContentFetcher) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches/%s",
f.baseURL, owner, repo, branch)
body, err := f.doGet(ctx, url)
if err != nil {
return "", fmt.Errorf("get branch info: %w", err)
}
var b giteaBranch
if err := json.Unmarshal(body, &b); err != nil {
return "", fmt.Errorf("decode branch: %w", err)
}
return b.Commit.ID, nil
}
// FolderEntry represents a file or directory in the repo tree.
type FolderEntry struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
}
// ListTree returns the full directory tree for a branch, useful for the folder picker.
func (f *GiteaContentFetcher) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true",
f.baseURL, owner, repo, branch)
body, err := f.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("list tree: %w", err)
}
var tree giteaTreeResponse
if err := json.Unmarshal(body, &tree); err != nil {
return nil, fmt.Errorf("decode tree: %w", err)
}
entries := make([]FolderEntry, 0, len(tree.Entries))
for _, e := range tree.Entries {
entries = append(entries, FolderEntry{
Path: e.Path,
IsDir: e.Type == "tree",
})
}
return entries, nil
}
// DownloadFolder downloads all files from a specific folder path in the repo
// to a local temporary directory. Returns the path to the temp directory.
func (f *GiteaContentFetcher) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error {
// Get the full tree.
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true",
f.baseURL, owner, repo, branch)
body, err := f.doGet(ctx, url)
if err != nil {
return fmt.Errorf("fetch tree: %w", err)
}
var tree giteaTreeResponse
if err := json.Unmarshal(body, &tree); err != nil {
return fmt.Errorf("decode tree: %w", err)
}
// Normalize folder path.
folderPath = strings.TrimPrefix(folderPath, "/")
folderPath = strings.TrimSuffix(folderPath, "/")
prefix := folderPath + "/"
// Download each file in the folder.
for _, entry := range tree.Entries {
if entry.Type != "blob" {
continue
}
if !strings.HasPrefix(entry.Path, prefix) {
continue
}
relativePath := strings.TrimPrefix(entry.Path, prefix)
localPath := filepath.Join(destDir, filepath.FromSlash(relativePath))
// Create parent directories.
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
return fmt.Errorf("create directory for %s: %w", relativePath, err)
}
// Download the file.
fileURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s",
f.baseURL, owner, repo, entry.Path, branch)
if err := f.downloadFile(ctx, fileURL, localPath); err != nil {
return fmt.Errorf("download %s: %w", relativePath, err)
}
}
return nil
}
// TestConnection verifies that the repository is accessible.
func (f *GiteaContentFetcher) TestConnection(ctx context.Context, owner, repo string) error {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s", f.baseURL, owner, repo)
_, err := f.doGet(ctx, url)
if err != nil {
return fmt.Errorf("test connection: %w", err)
}
return nil
}
// doGet performs an authenticated GET request and returns the response body.
func (f *GiteaContentFetcher) doGet(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if f.token != "" {
req.Header.Set("Authorization", "token "+f.token)
}
req.Header.Set("Accept", "application/json")
resp, err := f.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
// downloadFile downloads a URL to a local file path.
func (f *GiteaContentFetcher) downloadFile(ctx context.Context, url, localPath string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
if f.token != "" {
req.Header.Set("Authorization", "token "+f.token)
}
resp, err := f.httpClient.Do(req)
if err != nil {
return fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url)
}
file, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
return fmt.Errorf("write file: %w", err)
}
return nil
}
+276
View File
@@ -0,0 +1,276 @@
package staticsite
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
)
// GitHubProvider implements GitProvider for GitHub repositories.
type GitHubProvider struct {
apiBase string // "https://api.github.com" for github.com
token string
httpClient *http.Client
}
// NewGitHubProvider creates a new GitHub provider.
// baseURL should be "https://github.com" or a GitHub Enterprise URL.
func NewGitHubProvider(baseURL, token string) *GitHubProvider {
apiBase := "https://api.github.com"
base := strings.TrimRight(baseURL, "/")
if base != "https://github.com" && base != "http://github.com" {
// GitHub Enterprise: API is at {base}/api/v3
apiBase = base + "/api/v3"
}
return &GitHubProvider{
apiBase: apiBase,
token: token,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (g *GitHubProvider) Name() string { return "github" }
func (g *GitHubProvider) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) {
var allRepos []RepoInfo
if query != "" {
// Use search API.
url := fmt.Sprintf("%s/search/repositories?q=%s&per_page=50", g.apiBase, query)
body, err := g.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("search repos: %w", err)
}
var result struct {
Items []struct {
Owner struct {
Login string `json:"login"`
} `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Private bool `json:"private"`
HTMLURL string `json:"html_url"`
} `json:"items"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("decode search: %w", err)
}
for _, r := range result.Items {
allRepos = append(allRepos, RepoInfo{
Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName,
Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL,
})
}
return allRepos, nil
}
// List authenticated user's repos.
page := 1
for {
url := fmt.Sprintf("%s/user/repos?per_page=100&page=%d&sort=updated", g.apiBase, page)
body, err := g.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("list repos: %w", err)
}
var repos []struct {
Owner struct {
Login string `json:"login"`
} `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Private bool `json:"private"`
HTMLURL string `json:"html_url"`
}
if err := json.Unmarshal(body, &repos); err != nil {
return nil, fmt.Errorf("decode repos: %w", err)
}
for _, r := range repos {
allRepos = append(allRepos, RepoInfo{
Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName,
Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL,
})
}
if len(repos) < 100 {
break
}
page++
}
return allRepos, nil
}
func (g *GitHubProvider) TestConnection(ctx context.Context, owner, repo string) error {
url := fmt.Sprintf("%s/repos/%s/%s", g.apiBase, owner, repo)
_, err := g.doGet(ctx, url)
return err
}
func (g *GitHubProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
var allBranches []string
page := 1
for {
url := fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d",
g.apiBase, owner, repo, page)
body, err := g.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("list branches: %w", err)
}
var branches []struct {
Name string `json:"name"`
}
if err := json.Unmarshal(body, &branches); err != nil {
return nil, fmt.Errorf("decode branches: %w", err)
}
for _, b := range branches {
allBranches = append(allBranches, b.Name)
}
if len(branches) < 100 {
break
}
page++
}
return allBranches, nil
}
func (g *GitHubProvider) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) {
url := fmt.Sprintf("%s/repos/%s/%s/branches/%s", g.apiBase, owner, repo, branch)
body, err := g.doGet(ctx, url)
if err != nil {
return "", fmt.Errorf("get branch: %w", err)
}
var result struct {
Commit struct {
SHA string `json:"sha"`
} `json:"commit"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("decode branch: %w", err)
}
return result.Commit.SHA, nil
}
func (g *GitHubProvider) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) {
url := fmt.Sprintf("%s/repos/%s/%s/git/trees/%s?recursive=1",
g.apiBase, owner, repo, branch)
body, err := g.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("list tree: %w", err)
}
var tree struct {
Tree []struct {
Path string `json:"path"`
Type string `json:"type"` // "blob" or "tree"
} `json:"tree"`
}
if err := json.Unmarshal(body, &tree); err != nil {
return nil, fmt.Errorf("decode tree: %w", err)
}
entries := make([]FolderEntry, 0, len(tree.Tree))
for _, e := range tree.Tree {
entries = append(entries, FolderEntry{
Path: e.Path,
IsDir: e.Type == "tree",
})
}
return entries, nil
}
func (g *GitHubProvider) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error {
// Get tree to find files in folder.
entries, err := g.ListTree(ctx, owner, repo, branch)
if err != nil {
return fmt.Errorf("list tree: %w", err)
}
folderPath = strings.TrimPrefix(folderPath, "/")
folderPath = strings.TrimSuffix(folderPath, "/")
prefix := folderPath + "/"
for _, entry := range entries {
if entry.IsDir {
continue
}
if !strings.HasPrefix(entry.Path, prefix) {
continue
}
relativePath := strings.TrimPrefix(entry.Path, prefix)
localPath := filepath.Join(destDir, filepath.FromSlash(relativePath))
// GitHub raw content URL.
// For github.com: https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}
// For GHE: {baseURL}/{owner}/{repo}/raw/{branch}/{path}
var fileURL string
if g.apiBase == "https://api.github.com" {
fileURL = fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s",
owner, repo, branch, entry.Path)
} else {
// GHE: use API contents endpoint.
fileURL = fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s",
g.apiBase, owner, repo, entry.Path, branch)
}
if err := downloadFileHTTP(ctx, g.httpClient, fileURL, localPath, g.setAuth); err != nil {
return fmt.Errorf("download %s: %w", relativePath, err)
}
}
return nil
}
func (g *GitHubProvider) doGet(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
g.setAuth(req)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := g.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
func (g *GitHubProvider) setAuth(req *http.Request) {
if g.token != "" {
req.Header.Set("Authorization", "Bearer "+g.token)
}
}
+254
View File
@@ -0,0 +1,254 @@
package staticsite
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
)
// GitLabProvider implements GitProvider for GitLab repositories.
type GitLabProvider struct {
apiBase string // e.g., "https://gitlab.com/api/v4"
rawBase string // e.g., "https://gitlab.com"
token string
httpClient *http.Client
}
// NewGitLabProvider creates a new GitLab provider.
// baseURL should be "https://gitlab.com" or a self-hosted GitLab URL.
func NewGitLabProvider(baseURL, token string) *GitLabProvider {
base := strings.TrimRight(baseURL, "/")
return &GitLabProvider{
apiBase: base + "/api/v4",
rawBase: base,
token: token,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (g *GitLabProvider) Name() string { return "gitlab" }
// projectPath returns the URL-encoded project path (owner/repo → owner%2Frepo).
func projectPath(owner, repo string) string {
return url.PathEscape(owner + "/" + repo)
}
func (g *GitLabProvider) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) {
var allRepos []RepoInfo
page := 1
for {
apiURL := fmt.Sprintf("%s/projects?membership=true&per_page=100&page=%d&order_by=last_activity_at", g.apiBase, page)
if query != "" {
apiURL += "&search=" + url.QueryEscape(query)
}
body, err := g.doGet(ctx, apiURL)
if err != nil {
return nil, fmt.Errorf("list repos: %w", err)
}
var projects []struct {
PathWithNamespace string `json:"path_with_namespace"`
Name string `json:"name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
WebURL string `json:"web_url"`
Namespace struct {
Path string `json:"path"`
} `json:"namespace"`
}
if err := json.Unmarshal(body, &projects); err != nil {
return nil, fmt.Errorf("decode repos: %w", err)
}
for _, p := range projects {
allRepos = append(allRepos, RepoInfo{
Owner: p.Namespace.Path,
Name: p.Name,
FullName: p.PathWithNamespace,
Description: p.Description,
Private: p.Visibility != "public",
HTMLURL: p.WebURL,
})
}
if len(projects) < 100 {
break
}
page++
}
return allRepos, nil
}
func (g *GitLabProvider) TestConnection(ctx context.Context, owner, repo string) error {
apiURL := fmt.Sprintf("%s/projects/%s", g.apiBase, projectPath(owner, repo))
_, err := g.doGet(ctx, apiURL)
return err
}
func (g *GitLabProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
var allBranches []string
page := 1
for {
apiURL := fmt.Sprintf("%s/projects/%s/repository/branches?per_page=100&page=%d",
g.apiBase, projectPath(owner, repo), page)
body, err := g.doGet(ctx, apiURL)
if err != nil {
return nil, fmt.Errorf("list branches: %w", err)
}
var branches []struct {
Name string `json:"name"`
}
if err := json.Unmarshal(body, &branches); err != nil {
return nil, fmt.Errorf("decode branches: %w", err)
}
for _, b := range branches {
allBranches = append(allBranches, b.Name)
}
if len(branches) < 100 {
break
}
page++
}
return allBranches, nil
}
func (g *GitLabProvider) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) {
apiURL := fmt.Sprintf("%s/projects/%s/repository/branches/%s",
g.apiBase, projectPath(owner, repo), url.PathEscape(branch))
body, err := g.doGet(ctx, apiURL)
if err != nil {
return "", fmt.Errorf("get branch: %w", err)
}
var result struct {
Commit struct {
ID string `json:"id"`
} `json:"commit"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("decode branch: %w", err)
}
return result.Commit.ID, nil
}
func (g *GitLabProvider) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) {
var allEntries []FolderEntry
page := 1
for {
apiURL := fmt.Sprintf("%s/projects/%s/repository/tree?ref=%s&recursive=true&per_page=100&page=%d",
g.apiBase, projectPath(owner, repo), url.QueryEscape(branch), page)
body, err := g.doGet(ctx, apiURL)
if err != nil {
return nil, fmt.Errorf("list tree: %w", err)
}
var entries []struct {
Path string `json:"path"`
Type string `json:"type"` // "blob" or "tree"
}
if err := json.Unmarshal(body, &entries); err != nil {
return nil, fmt.Errorf("decode tree: %w", err)
}
for _, e := range entries {
allEntries = append(allEntries, FolderEntry{
Path: e.Path,
IsDir: e.Type == "tree",
})
}
if len(entries) < 100 {
break
}
page++
}
return allEntries, nil
}
func (g *GitLabProvider) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error {
entries, err := g.ListTree(ctx, owner, repo, branch)
if err != nil {
return fmt.Errorf("list tree: %w", err)
}
folderPath = strings.TrimPrefix(folderPath, "/")
folderPath = strings.TrimSuffix(folderPath, "/")
prefix := folderPath + "/"
for _, entry := range entries {
if entry.IsDir {
continue
}
if !strings.HasPrefix(entry.Path, prefix) {
continue
}
relativePath := strings.TrimPrefix(entry.Path, prefix)
localPath := filepath.Join(destDir, filepath.FromSlash(relativePath))
// GitLab raw file URL: {base}/{owner}/{repo}/-/raw/{branch}/{path}
fileURL := fmt.Sprintf("%s/%s/%s/-/raw/%s/%s",
g.rawBase, owner, repo, branch, entry.Path)
if err := downloadFileHTTP(ctx, g.httpClient, fileURL, localPath, g.setAuth); err != nil {
return fmt.Errorf("download %s: %w", relativePath, err)
}
}
return nil
}
func (g *GitLabProvider) doGet(ctx context.Context, apiURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
g.setAuth(req)
req.Header.Set("Accept", "application/json")
resp, err := g.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
func (g *GitLabProvider) setAuth(req *http.Request) {
if g.token != "" {
req.Header.Set("PRIVATE-TOKEN", g.token)
}
}
+111
View File
@@ -0,0 +1,111 @@
package staticsite
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/store"
"github.com/robfig/cron/v3"
)
// HealthChecker periodically checks that deployed static site containers
// are still running. If a container has crashed, it updates the site status
// to "failed" and optionally triggers a redeploy.
type HealthChecker struct {
store *store.Store
docker *docker.Client
manager *Manager
cron *cron.Cron
mu sync.Mutex
entryID cron.EntryID
running bool
}
// NewHealthChecker creates a new static site health checker.
func NewHealthChecker(st *store.Store, dockerClient *docker.Client, mgr *Manager) *HealthChecker {
return &HealthChecker{
store: st,
docker: dockerClient,
manager: mgr,
cron: cron.New(),
}
}
// Start begins the periodic health check with the given interval (e.g., "5m", "1m").
func (h *HealthChecker) Start(interval string) error {
h.mu.Lock()
defer h.mu.Unlock()
duration, err := time.ParseDuration(interval)
if err != nil {
return fmt.Errorf("parse interval %q: %w", interval, err)
}
if h.running {
h.cron.Remove(h.entryID)
}
spec := fmt.Sprintf("@every %s", duration)
id, err := h.cron.AddFunc(spec, h.check)
if err != nil {
return fmt.Errorf("schedule health check: %w", err)
}
h.entryID = id
h.running = true
h.cron.Start()
slog.Info("static site health checker started", "interval", interval)
return nil
}
// Stop stops the periodic health checker.
func (h *HealthChecker) Stop() {
h.mu.Lock()
defer h.mu.Unlock()
if h.running {
h.cron.Stop()
h.running = false
slog.Info("static site health checker stopped")
}
}
// check runs a single health check pass over all deployed static sites.
func (h *HealthChecker) check() {
sites, err := h.store.GetAllStaticSites()
if err != nil {
slog.Error("static site health check: failed to list sites", "error", err)
return
}
ctx := context.Background()
for _, site := range sites {
if site.Status != "deployed" || site.ContainerID == "" {
continue
}
running, err := h.docker.IsContainerRunning(ctx, site.ContainerID)
if err != nil {
// Container might have been removed externally.
slog.Warn("static site health check: container inspect failed",
"site", site.Name, "container", site.ContainerID[:12], "error", err)
h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container not found")
h.manager.publishEvent(site.ID, site.Name, "failed: container not found")
continue
}
if !running {
slog.Warn("static site health check: container not running",
"site", site.Name, "container", site.ContainerID[:12])
h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container stopped unexpectedly")
h.manager.publishEvent(site.ID, site.Name, "failed: container stopped unexpectedly")
}
}
}
+691
View File
@@ -0,0 +1,691 @@
package staticsite
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"time"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/events"
"github.com/alexei/docker-watcher/internal/proxy"
"github.com/alexei/docker-watcher/internal/staticsite/deno"
"github.com/alexei/docker-watcher/internal/store"
)
// Manager orchestrates the static site deployment pipeline.
type Manager struct {
store *store.Store
docker *docker.Client
proxyProvider proxy.Provider
eventBus *events.Bus
encKey [32]byte
}
// NewManager creates a new static site manager.
func NewManager(
st *store.Store,
dockerClient *docker.Client,
proxyProvider proxy.Provider,
eventBus *events.Bus,
encKey [32]byte,
) *Manager {
return &Manager{
store: st,
docker: dockerClient,
proxyProvider: proxyProvider,
eventBus: eventBus,
encKey: encKey,
}
}
// SetProxyProvider updates the proxy provider at runtime.
func (m *Manager) SetProxyProvider(provider proxy.Provider) {
m.proxyProvider = provider
}
// Deploy fetches content from Gitea and deploys a static site container.
// If force is true, skips the "no changes" check and always rebuilds/redeploys.
func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
site, err := m.store.GetStaticSiteByID(siteID)
if err != nil {
return fmt.Errorf("get site: %w", err)
}
// Decrypt access token if present.
token := ""
if site.AccessToken != "" {
decrypted, err := crypto.Decrypt(m.encKey, site.AccessToken)
if err != nil {
slog.Warn("static site: failed to decrypt access token", "site", site.Name, "error", err)
} else {
token = decrypted
}
}
provider, err := NewGitProvider(ProviderType(site.Provider), site.GiteaURL, token)
if err != nil {
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create provider: %v", err))
return fmt.Errorf("create provider: %w", err)
}
// Check if there's a new commit.
latestSHA, err := provider.GetLatestCommitSHA(ctx, site.RepoOwner, site.RepoName, site.Branch)
if err != nil {
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("fetch commit SHA: %v", err))
return fmt.Errorf("get latest commit: %w", err)
}
// Skip redeploy only if SHA matches, status is deployed, container is running,
// proxy route exists, AND force is false. Manual deploys always force a full rebuild.
if !force && latestSHA == site.LastCommitSHA && site.Status == "deployed" && site.ContainerID != "" {
running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID)
if !running {
slog.Info("static site: container not running, forcing redeploy", "site", site.Name)
} else if site.Domain != "" {
// Also verify the proxy route still exists (it may have been deleted externally).
proxyOK, err := m.proxyProvider.RouteExists(ctx, site.Domain)
if err != nil {
slog.Warn("static site: proxy check failed, forcing redeploy", "site", site.Name, "error", err)
} else if !proxyOK {
slog.Info("static site: proxy route missing, forcing redeploy", "site", site.Name)
} else {
slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA)
return nil
}
} else {
slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA)
return nil
}
}
// Update status to syncing.
m.updateStatus(site.ID, "syncing", site.LastCommitSHA, "")
m.publishEvent(site.ID, site.Name, "syncing")
// Create temp directory for the build context.
buildDir, err := os.MkdirTemp("", "dw-site-"+site.Name+"-*")
if err != nil {
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create temp dir: %v", err))
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(buildDir)
// Download folder contents.
if err := provider.DownloadFolder(ctx, site.RepoOwner, site.RepoName, site.Branch, site.FolderPath, buildDir); err != nil {
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("download folder: %v", err))
return fmt.Errorf("download folder: %w", err)
}
// Render markdown if enabled.
if site.RenderMarkdown {
if err := RenderMarkdownFiles(buildDir); err != nil {
slog.Warn("static site: markdown rendering failed", "site", site.Name, "error", err)
}
}
// Determine mode: check for api/ subdirectory.
mode := site.Mode
apiDir := filepath.Join(buildDir, "api")
hasAPI := false
if info, err := os.Stat(apiDir); err == nil && info.IsDir() {
hasAPI = true
}
if mode == "deno" && !hasAPI {
// Fallback to static if no api/ folder found.
mode = "static"
slog.Info("static site: no api/ folder found, falling back to static mode", "site", site.Name)
}
// Prepare build context based on mode.
imageTag := fmt.Sprintf("dw-site-%s:latest", site.Name)
contextDir, err := os.MkdirTemp("", "dw-site-build-*")
if err != nil {
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create build context: %v", err))
return fmt.Errorf("create build context dir: %w", err)
}
defer os.RemoveAll(contextDir)
if mode == "deno" {
if err := m.prepareDenoBuild(buildDir, contextDir); err != nil {
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare deno build: %v", err))
return fmt.Errorf("prepare deno build: %w", err)
}
} else {
if err := m.prepareStaticBuild(buildDir, contextDir); err != nil {
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare static build: %v", err))
return fmt.Errorf("prepare static build: %w", err)
}
}
// Build Docker image.
if err := m.docker.BuildImage(ctx, contextDir, imageTag); err != nil {
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build image: %v", err))
return fmt.Errorf("build image: %w", err)
}
// Prepare environment variables (secrets).
env, err := m.buildEnvVars(site.ID)
if err != nil {
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build env vars: %v", err))
return fmt.Errorf("build env vars: %w", err)
}
// Determine container port.
containerPort := "80"
if mode == "deno" {
containerPort = "8000"
}
// Get network settings.
settings, err := m.store.GetSettings()
if err != nil {
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("get settings: %v", err))
return fmt.Errorf("get settings: %w", err)
}
networkName := settings.Network
networkID, err := m.docker.EnsureNetwork(ctx, networkName)
if err != nil {
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("ensure network: %v", err))
return fmt.Errorf("ensure network: %w", err)
}
containerName := fmt.Sprintf("dw-site-%s", site.Name)
// Create and start new container.
containerID, err := m.docker.CreateContainer(ctx, docker.ContainerConfig{
Name: containerName,
Image: imageTag,
Env: env,
ExposedPorts: []string{containerPort + "/tcp"},
NetworkName: networkName,
NetworkID: networkID,
Labels: map[string]string{
"docker-watcher.static-site": site.ID,
"docker-watcher.static-site-name": site.Name,
},
Project: "static-site",
Stage: site.Name,
})
if err != nil {
// Container might already exist — try to remove and recreate.
if site.ContainerID != "" {
m.docker.StopContainer(ctx, site.ContainerID, 10)
m.docker.RemoveContainer(ctx, site.ContainerID, true)
}
// Also try by name.
m.removeContainerByName(ctx, containerName)
containerID, err = m.docker.CreateContainer(ctx, docker.ContainerConfig{
Name: containerName,
Image: imageTag,
Env: env,
ExposedPorts: []string{containerPort + "/tcp"},
NetworkName: networkName,
NetworkID: networkID,
Labels: map[string]string{
"docker-watcher.static-site": site.ID,
"docker-watcher.static-site-name": site.Name,
},
Project: "static-site",
Stage: site.Name,
})
if err != nil {
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create container: %v", err))
return fmt.Errorf("create container: %w", err)
}
}
if err := m.docker.StartContainer(ctx, containerID); err != nil {
m.docker.RemoveContainer(ctx, containerID, true)
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("start container: %v", err))
return fmt.Errorf("start container: %w", err)
}
// Brief health check: wait 3 seconds and verify container is still running.
time.Sleep(3 * time.Second)
running, err := m.docker.IsContainerRunning(ctx, containerID)
if err != nil || !running {
// Grab container logs for the error message.
logMsg := "container exited immediately after start"
if logs, logErr := m.docker.ContainerLogs(ctx, containerID, false, "20"); logErr == nil {
buf, _ := io.ReadAll(logs)
logs.Close()
if len(buf) > 0 {
logMsg = string(buf)
// Truncate to reasonable length.
if len(logMsg) > 500 {
logMsg = logMsg[:500] + "..."
}
}
}
m.docker.RemoveContainer(ctx, containerID, true)
m.updateStatus(site.ID, "failed", latestSHA, logMsg)
return fmt.Errorf("container not running: %s", logMsg)
}
// Determine proxy target: container name + internal port (default),
// or server IP + host port for NPM remote mode.
internalPort, _ := strconv.Atoi(containerPort)
forwardHost := containerName
forwardPort := internalPort
if settings.NpmRemote && settings.ProxyProvider == "npm" {
if settings.ServerIP != "" {
hostPort, err := m.docker.InspectContainerPort(ctx, containerID, containerPort+"/tcp")
if err != nil {
slog.Warn("static site: could not get host port for remote NPM", "site", site.Name, "error", err)
} else {
forwardHost = settings.ServerIP
forwardPort = int(hostPort)
}
}
}
// Configure proxy if domain is set.
proxyRouteID := site.ProxyRouteID
if site.Domain != "" {
// Remove old proxy route if exists.
if site.ProxyRouteID != "" {
m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID)
}
routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{
SSLCertificateID: settings.SSLCertificateID,
})
if err != nil {
slog.Warn("static site: failed to configure proxy", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "error", err)
} else {
proxyRouteID = routeID
slog.Info("static site: proxy configured", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "routeID", routeID)
}
}
// Remove old container if different.
if site.ContainerID != "" && site.ContainerID != containerID {
m.docker.StopContainer(ctx, site.ContainerID, 10)
m.docker.RemoveContainer(ctx, site.ContainerID, true)
}
// Update site status.
if err := m.store.UpdateStaticSiteContainer(site.ID, containerID, proxyRouteID); err != nil {
slog.Error("static site: failed to update container info", "site", site.Name, "error", err)
}
m.updateStatus(site.ID, "deployed", latestSHA, "")
m.publishEvent(site.ID, site.Name, "deployed")
slog.Info("static site deployed", "site", site.Name, "sha", latestSHA[:8], "mode", mode)
return nil
}
// Remove stops and removes a static site's container and proxy route.
func (m *Manager) Remove(ctx context.Context, siteID string) error {
site, err := m.store.GetStaticSiteByID(siteID)
if err != nil {
return fmt.Errorf("get site: %w", err)
}
// Remove proxy route (best effort).
if site.ProxyRouteID != "" {
if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil {
slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err)
}
}
// Stop and remove container (best effort).
if site.ContainerID != "" {
m.docker.StopContainer(ctx, site.ContainerID, 10)
if err := m.docker.RemoveContainer(ctx, site.ContainerID, true); err != nil {
slog.Warn("static site: failed to remove container", "site", site.Name, "error", err)
}
}
return nil
}
// Stop stops a running static site container and removes its proxy route.
// The container is kept (not removed) so Start can bring it back without a full rebuild.
func (m *Manager) Stop(ctx context.Context, siteID string) error {
site, err := m.store.GetStaticSiteByID(siteID)
if err != nil {
return fmt.Errorf("get site: %w", err)
}
// Remove proxy route first (best effort).
if site.ProxyRouteID != "" {
if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil {
slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err)
}
}
// Stop container.
if site.ContainerID != "" {
if err := m.docker.StopContainer(ctx, site.ContainerID, 10); err != nil {
slog.Warn("static site: failed to stop container", "site", site.Name, "error", err)
}
}
// Clear proxy route ID; keep container ID.
if err := m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, ""); err != nil {
slog.Error("static site: failed to clear proxy route", "site", site.Name, "error", err)
}
m.updateStatus(site.ID, "stopped", site.LastCommitSHA, "")
m.publishEvent(site.ID, site.Name, "stopped")
slog.Info("static site stopped", "site", site.Name)
return nil
}
// Start starts a previously stopped static site container and reconfigures the proxy.
// If the container no longer exists, it triggers a full redeploy.
func (m *Manager) Start(ctx context.Context, siteID string) error {
site, err := m.store.GetStaticSiteByID(siteID)
if err != nil {
return fmt.Errorf("get site: %w", err)
}
// If no container exists, do a full deploy.
if site.ContainerID == "" {
return m.Deploy(ctx, siteID, true)
}
// Try to start the existing container.
if err := m.docker.StartContainer(ctx, site.ContainerID); err != nil {
slog.Warn("static site: failed to start container, falling back to redeploy", "site", site.Name, "error", err)
return m.Deploy(ctx, siteID, true)
}
// Verify it's running after a brief wait.
time.Sleep(2 * time.Second)
running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID)
if !running {
return m.Deploy(ctx, siteID, true)
}
// Reconfigure proxy if domain is set.
settings, err := m.store.GetSettings()
if err == nil && site.Domain != "" {
containerPort := "80"
if site.Mode == "deno" {
containerPort = "8000"
}
internalPort, _ := strconv.Atoi(containerPort)
containerName := fmt.Sprintf("dw-site-%s", site.Name)
forwardHost := containerName
forwardPort := internalPort
if settings.NpmRemote && settings.ProxyProvider == "npm" && settings.ServerIP != "" {
if hp, err := m.docker.InspectContainerPort(ctx, site.ContainerID, containerPort+"/tcp"); err == nil {
forwardHost = settings.ServerIP
forwardPort = int(hp)
}
}
routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{
SSLCertificateID: settings.SSLCertificateID,
})
if err != nil {
slog.Warn("static site: failed to reconfigure proxy on start", "site", site.Name, "error", err)
} else {
m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, routeID)
}
}
m.updateStatus(site.ID, "deployed", site.LastCommitSHA, "")
m.publishEvent(site.ID, site.Name, "deployed")
slog.Info("static site started", "site", site.Name)
return nil
}
// TestConnection tests connectivity to a Git repository.
func (m *Manager) TestConnection(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) error {
provider, err := m.createProvider(providerType, baseURL, accessToken)
if err != nil {
return err
}
return provider.TestConnection(ctx, owner, repo)
}
// ListBranches returns branches for a Git repository.
func (m *Manager) ListBranches(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) ([]string, error) {
provider, err := m.createProvider(providerType, baseURL, accessToken)
if err != nil {
return nil, err
}
return provider.ListBranches(ctx, owner, repo)
}
// ListTree returns the repository tree for the folder picker.
func (m *Manager) ListTree(ctx context.Context, providerType, baseURL, accessToken, owner, repo, branch string) ([]FolderEntry, error) {
provider, err := m.createProvider(providerType, baseURL, accessToken)
if err != nil {
return nil, err
}
return provider.ListTree(ctx, owner, repo, branch)
}
// ListRepos returns repositories from a Git server.
func (m *Manager) ListRepos(ctx context.Context, providerType, baseURL, accessToken, query string) ([]RepoInfo, error) {
provider, err := m.createProvider(providerType, baseURL, accessToken)
if err != nil {
return nil, err
}
return provider.ListRepos(ctx, query)
}
// DetectProvider autodetects the Git provider from a URL, with API probing.
func (m *Manager) DetectProvider(ctx context.Context, baseURL string) string {
return string(DetectProviderWithProbe(ctx, baseURL))
}
// createProvider builds a GitProvider from encrypted credentials.
func (m *Manager) createProvider(providerType, baseURL, accessToken string) (GitProvider, error) {
token := ""
if accessToken != "" {
decrypted, err := crypto.Decrypt(m.encKey, accessToken)
if err != nil {
token = accessToken // might be plaintext
} else {
token = decrypted
}
}
return NewGitProvider(ProviderType(providerType), baseURL, token)
}
// prepareDenoBuild sets up the build context for a Deno container.
func (m *Manager) prepareDenoBuild(srcDir, contextDir string) error {
// Move api/ to context.
apiSrc := filepath.Join(srcDir, "api")
apiDst := filepath.Join(contextDir, "api")
if err := os.Rename(apiSrc, apiDst); err != nil {
return fmt.Errorf("move api dir: %w", err)
}
// Move remaining files to public/.
publicDir := filepath.Join(contextDir, "public")
if err := os.Rename(srcDir, publicDir); err != nil {
// If rename fails (cross-device), use copy.
if err := copyDir(srcDir, publicDir); err != nil {
return fmt.Errorf("copy public dir: %w", err)
}
}
// Scan routes and generate router.
routes, err := deno.ScanRoutes(apiDst)
if err != nil {
return fmt.Errorf("scan routes: %w", err)
}
routerSrc, err := deno.GenerateRouter(routes)
if err != nil {
return fmt.Errorf("generate router: %w", err)
}
if err := os.WriteFile(filepath.Join(contextDir, "router.ts"), []byte(routerSrc), 0o644); err != nil {
return fmt.Errorf("write router.ts: %w", err)
}
// Generate Dockerfile.
dockerfile := deno.GenerateDockerfile()
if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
return fmt.Errorf("write Dockerfile: %w", err)
}
return nil
}
// prepareStaticBuild sets up the build context for a static nginx container.
func (m *Manager) prepareStaticBuild(srcDir, contextDir string) error {
// Copy all files to context directory.
if err := copyDir(srcDir, contextDir); err != nil {
return fmt.Errorf("copy files: %w", err)
}
// Generate Dockerfile.
dockerfile := deno.GenerateStaticDockerfile()
if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
return fmt.Errorf("write Dockerfile: %w", err)
}
return nil
}
// buildEnvVars decrypts secrets and builds environment variable list.
func (m *Manager) buildEnvVars(siteID string) ([]string, error) {
secrets, err := m.store.GetStaticSiteSecretsBySiteID(siteID)
if err != nil {
return nil, fmt.Errorf("get secrets: %w", err)
}
env := make([]string, 0, len(secrets))
for _, s := range secrets {
value := s.Value
if s.Encrypted {
decrypted, err := crypto.Decrypt(m.encKey, value)
if err != nil {
return nil, fmt.Errorf("decrypt secret %s: %w", s.Key, err)
}
value = decrypted
}
env = append(env, s.Key+"="+value)
}
return env, nil
}
// removeContainerByName removes a container by its name (best effort).
func (m *Manager) removeContainerByName(ctx context.Context, name string) {
containers, err := m.docker.ListContainers(ctx, nil)
if err != nil {
return
}
for _, c := range containers {
if c.Name == name {
m.docker.StopContainer(ctx, c.ID, 10)
m.docker.RemoveContainer(ctx, c.ID, true)
return
}
}
}
// updateStatus updates the site status in the database.
// On failure, it also publishes an event to the event log.
func (m *Manager) updateStatus(id, status, commitSHA, errMsg string) {
if err := m.store.UpdateStaticSiteStatus(id, status, commitSHA, errMsg); err != nil {
slog.Error("static site: failed to update status", "id", id, "status", status, "error", err)
}
// Persist failures to event log automatically.
if status == "failed" {
site, err := m.store.GetStaticSiteByID(id)
siteName := id
if err == nil {
siteName = site.Name
}
m.publishEvent(id, siteName, "failed: "+errMsg)
}
}
// publishEvent publishes a static site status event on the event bus
// and persists it to the event log for the dashboard.
func (m *Manager) publishEvent(siteID, siteName, status string) {
m.eventBus.Publish(events.Event{
Type: events.EventStaticSiteStatus,
Payload: events.StaticSiteStatusPayload{
SiteID: siteID,
Name: siteName,
Status: status,
},
})
// Persist to event log.
severity := "info"
message := fmt.Sprintf("Static site \"%s\": %s", siteName, status)
if status == "failed" {
severity = "error"
}
metadata := fmt.Sprintf(`{"site_id":"%s","site_name":"%s","status":"%s"}`, siteID, siteName, status)
evt, err := m.store.InsertEvent(store.EventLog{
Source: "static_site",
Severity: severity,
Message: message,
Metadata: metadata,
})
if err != nil {
slog.Error("static site: failed to persist event log", "error", err)
return
}
// Publish the persisted event for SSE clients.
m.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
ID: evt.ID,
Source: "static_site",
Severity: severity,
Message: message,
Metadata: metadata,
CreatedAt: evt.CreatedAt,
},
})
}
// copyDir recursively copies a directory.
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
dstPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, 0o755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(dstPath, data, info.Mode())
})
}
// hostPortStr converts a uint16 port to a string for proxy configuration.
func hostPortStr(port uint16) string {
return strconv.FormatUint(uint64(port), 10)
}
+83
View File
@@ -0,0 +1,83 @@
package staticsite
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/yuin/goldmark"
)
// RenderMarkdownFiles walks the directory and converts all .md files to .html.
// The original .md file is kept alongside the generated .html file.
func RenderMarkdownFiles(dir string) error {
md := goldmark.New()
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".md" && ext != ".markdown" {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
var buf bytes.Buffer
if err := md.Convert(src, &buf); err != nil {
return fmt.Errorf("render %s: %w", path, err)
}
html := wrapHTML(extractTitle(src), buf.String())
htmlPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".html"
if err := os.WriteFile(htmlPath, []byte(html), 0o644); err != nil {
return fmt.Errorf("write %s: %w", htmlPath, err)
}
return nil
})
}
// extractTitle finds the first # heading in markdown content.
func extractTitle(src []byte) string {
for _, line := range strings.Split(string(src), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "# ") {
return strings.TrimPrefix(trimmed, "# ")
}
}
return "Page"
}
// wrapHTML wraps rendered markdown in a minimal HTML document.
func wrapHTML(title, body string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; color: #333; }
pre { background: #f4f4f4; padding: 1rem; border-radius: 4px; overflow-x: auto; }
code { background: #f4f4f4; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
pre code { background: none; padding: 0; }
img { max-width: 100%%; height: auto; }
a { color: #0366d6; }
</style>
</head>
<body>
%s
</body>
</html>`, title, body)
}
+171
View File
@@ -0,0 +1,171 @@
package staticsite
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// RepoInfo represents a repository returned by the provider's list/search API.
type RepoInfo struct {
Owner string `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"` // "owner/name"
Description string `json:"description"`
Private bool `json:"private"`
HTMLURL string `json:"html_url"`
}
// GitProvider abstracts Git hosting API operations.
// Implementations exist for Gitea/Forgejo/Gogs, GitHub, and GitLab.
type GitProvider interface {
// Name returns the provider identifier (e.g., "gitea", "github", "gitlab").
Name() string
// TestConnection verifies that the repository is accessible.
TestConnection(ctx context.Context, owner, repo string) error
// ListRepos returns repositories accessible with the current token.
// If query is non-empty, results are filtered by name.
ListRepos(ctx context.Context, query string) ([]RepoInfo, error)
// ListBranches returns all branch names for a repository.
ListBranches(ctx context.Context, owner, repo string) ([]string, error)
// GetLatestCommitSHA returns the latest commit SHA for a branch.
GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error)
// ListTree returns the full directory tree for a branch.
ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error)
// DownloadFolder downloads all files from a folder path to a local directory.
DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error
}
// ProviderType identifies a Git hosting provider.
type ProviderType string
const (
ProviderGitea ProviderType = "gitea" // Also works for Forgejo and Gogs.
ProviderGitHub ProviderType = "github"
ProviderGitLab ProviderType = "gitlab"
)
// ValidProviderTypes lists all supported provider types.
var ValidProviderTypes = []ProviderType{ProviderGitea, ProviderGitHub, ProviderGitLab}
// NewGitProvider creates a GitProvider for the given type.
// If providerType is empty, it attempts autodetection from the baseURL.
func NewGitProvider(providerType ProviderType, baseURL, token string) (GitProvider, error) {
if providerType == "" {
providerType = DetectProvider(baseURL)
}
switch providerType {
case ProviderGitea:
return NewGiteaContentFetcher(baseURL, token), nil
case ProviderGitHub:
return NewGitHubProvider(baseURL, token), nil
case ProviderGitLab:
return NewGitLabProvider(baseURL, token), nil
default:
return nil, fmt.Errorf("unsupported git provider: %s", providerType)
}
}
// DetectProvider guesses the provider type from a base URL.
func DetectProvider(baseURL string) ProviderType {
lower := strings.ToLower(baseURL)
switch {
case strings.Contains(lower, "github.com"):
return ProviderGitHub
case strings.Contains(lower, "gitlab.com"):
return ProviderGitLab
default:
// Default to Gitea for self-hosted instances (Gitea/Forgejo/Gogs all share the same API).
return ProviderGitea
}
}
// DetectProviderWithProbe tries to autodetect the provider by probing known API endpoints.
// Falls back to URL-based detection if probing fails.
func DetectProviderWithProbe(ctx context.Context, baseURL string) ProviderType {
// First try URL-based detection for well-known hosts.
urlBased := DetectProvider(baseURL)
if urlBased == ProviderGitHub || urlBased == ProviderGitLab {
return urlBased
}
// For unknown hosts, probe for Gitea/GitLab API signatures.
client := &http.Client{Timeout: 5 * time.Second}
base := strings.TrimRight(baseURL, "/")
// Try Gitea/Forgejo API.
if resp, err := httpGet(ctx, client, base+"/api/v1/version"); err == nil && resp == http.StatusOK {
return ProviderGitea
}
// Try GitLab API.
if resp, err := httpGet(ctx, client, base+"/api/v4/version"); err == nil && resp == http.StatusOK {
return ProviderGitLab
}
// Default to Gitea.
return ProviderGitea
}
// httpGet performs a simple GET and returns the status code.
func httpGet(ctx context.Context, client *http.Client, url string) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, err
}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
resp.Body.Close()
return resp.StatusCode, nil
}
// downloadFileHTTP is a shared helper for downloading a file from a URL.
func downloadFileHTTP(ctx context.Context, client *http.Client, url, localPath string, authHeader func(r *http.Request)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
if authHeader != nil {
authHeader(req)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url)
}
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
file, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
return fmt.Errorf("write file: %w", err)
}
return nil
}
+37
View File
@@ -195,6 +195,43 @@ type Volume struct {
UpdatedAt string `json:"updated_at"`
}
// StaticSite represents a static site deployed from a Git repository folder.
type StaticSite struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"` // "gitea", "github", "gitlab"; empty = autodetect
GiteaURL string `json:"gitea_url"` // base URL, e.g. https://git.example.com
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
FolderPath string `json:"folder_path"` // path within repo, e.g. "Pages"
AccessToken string `json:"access_token"` // encrypted; optional for public repos
Domain string `json:"domain"` // full domain for proxy
Mode string `json:"mode"` // "static" or "deno"
RenderMarkdown bool `json:"render_markdown"`
SyncTrigger string `json:"sync_trigger"` // "push", "tag", "manual"
TagPattern string `json:"tag_pattern"` // glob pattern for tag-based sync
ContainerID string `json:"container_id"`
ProxyRouteID string `json:"proxy_route_id"`
Status string `json:"status"` // idle, syncing, deployed, failed
LastSyncAt string `json:"last_sync_at"`
LastCommitSHA string `json:"last_commit_sha"`
Error string `json:"error"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// StaticSiteSecret represents an encrypted environment variable for a static site's Deno backend.
type StaticSiteSecret struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// EventLog represents a persistent event log entry.
type EventLog struct {
ID int64 `json:"id"`
+112
View File
@@ -0,0 +1,112 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateStaticSiteSecret inserts a new secret for a static site.
func (s *Store) CreateStaticSiteSecret(secret StaticSiteSecret) (StaticSiteSecret, error) {
secret.ID = uuid.New().String()
secret.CreatedAt = Now()
secret.UpdatedAt = secret.CreatedAt
_, err := s.db.Exec(
`INSERT INTO static_site_secrets (id, site_id, key, value, encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
secret.ID, secret.SiteID, secret.Key, secret.Value,
BoolToInt(secret.Encrypted), secret.CreatedAt, secret.UpdatedAt,
)
if err != nil {
return StaticSiteSecret{}, fmt.Errorf("insert static site secret: %w", err)
}
return secret, nil
}
// GetStaticSiteSecretsBySiteID returns all secrets for a static site.
func (s *Store) GetStaticSiteSecretsBySiteID(siteID string) ([]StaticSiteSecret, error) {
rows, err := s.db.Query(
`SELECT id, site_id, key, value, encrypted, created_at, updated_at
FROM static_site_secrets WHERE site_id = ? ORDER BY key`, siteID,
)
if err != nil {
return nil, fmt.Errorf("query static site secrets: %w", err)
}
defer rows.Close()
secrets := []StaticSiteSecret{}
for rows.Next() {
secret, err := scanStaticSiteSecret(rows)
if err != nil {
return nil, err
}
secrets = append(secrets, secret)
}
return secrets, rows.Err()
}
// GetStaticSiteSecretByID returns a single secret by ID.
func (s *Store) GetStaticSiteSecretByID(id string) (StaticSiteSecret, error) {
var secret StaticSiteSecret
var encrypted int
err := s.db.QueryRow(
`SELECT id, site_id, key, value, encrypted, created_at, updated_at
FROM static_site_secrets WHERE id = ?`, id,
).Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted,
&secret.CreatedAt, &secret.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return StaticSiteSecret{}, fmt.Errorf("static site secret %s: %w", id, ErrNotFound)
}
if err != nil {
return StaticSiteSecret{}, fmt.Errorf("query static site secret: %w", err)
}
secret.Encrypted = encrypted != 0
return secret, nil
}
// UpdateStaticSiteSecret updates an existing secret.
func (s *Store) UpdateStaticSiteSecret(secret StaticSiteSecret) error {
secret.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE static_site_secrets SET key=?, value=?, encrypted=?, updated_at=?
WHERE id=?`,
secret.Key, secret.Value, BoolToInt(secret.Encrypted), secret.UpdatedAt, secret.ID,
)
if err != nil {
return fmt.Errorf("update static site secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site secret %s: %w", secret.ID, ErrNotFound)
}
return nil
}
// DeleteStaticSiteSecret removes a secret by ID.
func (s *Store) DeleteStaticSiteSecret(id string) error {
result, err := s.db.Exec(`DELETE FROM static_site_secrets WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete static site secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site secret %s: %w", id, ErrNotFound)
}
return nil
}
// scanStaticSiteSecret scans a secret row from a *sql.Rows cursor.
func scanStaticSiteSecret(rows *sql.Rows) (StaticSiteSecret, error) {
var secret StaticSiteSecret
var encrypted int
err := rows.Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted,
&secret.CreatedAt, &secret.UpdatedAt)
if err != nil {
return StaticSiteSecret{}, fmt.Errorf("scan static site secret: %w", err)
}
secret.Encrypted = encrypted != 0
return secret, nil
}
+202
View File
@@ -0,0 +1,202 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// staticSiteCols is the column list for static_sites queries.
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error,
created_at, updated_at`
// CreateStaticSite inserts a new static site and returns it.
func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
site.ID = uuid.New().String()
site.CreatedAt = Now()
site.UpdatedAt = site.CreatedAt
_, err := s.db.Exec(
`INSERT INTO static_sites (`+staticSiteCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
site.LastCommitSHA, site.Error, site.CreatedAt, site.UpdatedAt,
)
if err != nil {
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
}
return site, nil
}
// GetStaticSiteByID returns a single static site by its ID.
func (s *Store) GetStaticSiteByID(id string) (StaticSite, error) {
site, err := scanStaticSiteRow(s.db.QueryRow(
`SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return StaticSite{}, fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
if err != nil {
return StaticSite{}, fmt.Errorf("query static site: %w", err)
}
return site, nil
}
// GetAllStaticSites returns every static site ordered by name.
func (s *Store) GetAllStaticSites() ([]StaticSite, error) {
rows, err := s.db.Query(
`SELECT ` + staticSiteCols + ` FROM static_sites ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query static sites: %w", err)
}
defer rows.Close()
sites := []StaticSite{}
for rows.Next() {
site, err := scanStaticSiteRows(rows)
if err != nil {
return nil, err
}
sites = append(sites, site)
}
return sites, rows.Err()
}
// GetStaticSitesByRepo returns all static sites for a given repo owner/name.
func (s *Store) GetStaticSitesByRepo(giteaURL, owner, name string) ([]StaticSite, error) {
rows, err := s.db.Query(
`SELECT `+staticSiteCols+`
FROM static_sites WHERE gitea_url = ? AND repo_owner = ? AND repo_name = ?
ORDER BY name`,
giteaURL, owner, name,
)
if err != nil {
return nil, fmt.Errorf("query static sites by repo: %w", err)
}
defer rows.Close()
sites := []StaticSite{}
for rows.Next() {
site, err := scanStaticSiteRows(rows)
if err != nil {
return nil, err
}
sites = append(sites, site)
}
return sites, rows.Err()
}
// UpdateStaticSite updates an existing static site's configuration fields.
func (s *Store) UpdateStaticSite(site StaticSite) error {
site.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?,
folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?,
sync_trigger=?, tag_pattern=?, updated_at=?
WHERE id=?`,
site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch,
site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
site.UpdatedAt, site.ID,
)
if err != nil {
return fmt.Errorf("update static site: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", site.ID, ErrNotFound)
}
return nil
}
// UpdateStaticSiteStatus updates the deployment status fields.
func (s *Store) UpdateStaticSiteStatus(id, status, commitSHA, errMsg string) error {
now := Now()
result, err := s.db.Exec(
`UPDATE static_sites SET status=?, last_commit_sha=?, last_sync_at=?, error=?, updated_at=?
WHERE id=?`,
status, commitSHA, now, errMsg, now, id,
)
if err != nil {
return fmt.Errorf("update static site status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
return nil
}
// UpdateStaticSiteContainer updates the container and proxy route IDs after deployment.
func (s *Store) UpdateStaticSiteContainer(id, containerID, proxyRouteID string) error {
now := Now()
result, err := s.db.Exec(
`UPDATE static_sites SET container_id=?, proxy_route_id=?, updated_at=? WHERE id=?`,
containerID, proxyRouteID, now, id,
)
if err != nil {
return fmt.Errorf("update static site container: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
return nil
}
// 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)
if err != nil {
return fmt.Errorf("delete static site: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
return nil
}
// scanStaticSiteRow scans a static site from a *sql.Row.
func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
var site StaticSite
var renderMarkdown int
err := row.Scan(
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &site.CreatedAt, &site.UpdatedAt,
)
if err != nil {
return StaticSite{}, err
}
site.RenderMarkdown = renderMarkdown != 0
return site, nil
}
// scanStaticSiteRows scans a static site from a *sql.Rows cursor.
func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
var site StaticSite
var renderMarkdown int
err := rows.Scan(
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &site.CreatedAt, &site.UpdatedAt,
)
if err != nil {
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
}
site.RenderMarkdown = renderMarkdown != 0
return site, nil
}
+39
View File
@@ -123,6 +123,8 @@ func (s *Store) runMigrations() error {
`ALTER TABLE settings ADD COLUMN public_ip TEXT NOT NULL DEFAULT ''`,
// Image prune threshold (MB). Warn on dashboard when exceeded. 0 = disabled.
`ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`,
// Add provider column to static_sites (2026-04-11).
`ALTER TABLE static_sites ADD COLUMN provider TEXT NOT NULL DEFAULT ''`,
}
for _, m := range migrations {
@@ -144,6 +146,7 @@ func (s *Store) runMigrations() error {
`CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`,
`CREATE INDEX IF NOT EXISTS idx_static_site_secrets_site_id ON static_site_secrets(site_id)`,
}
for _, idx := range indexes {
if _, err := s.db.Exec(idx); err != nil {
@@ -361,6 +364,42 @@ CREATE TABLE IF NOT EXISTS backups (
backup_type TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS static_sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
provider TEXT NOT NULL DEFAULT '',
gitea_url TEXT NOT NULL DEFAULT '',
repo_owner TEXT NOT NULL DEFAULT '',
repo_name TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT 'main',
folder_path TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
domain TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'static',
render_markdown INTEGER NOT NULL DEFAULT 0,
sync_trigger TEXT NOT NULL DEFAULT 'manual',
tag_pattern TEXT NOT NULL DEFAULT '',
container_id TEXT NOT NULL DEFAULT '',
proxy_route_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'idle',
last_sync_at TEXT NOT NULL DEFAULT '',
last_commit_sha TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS static_site_secrets (
id TEXT PRIMARY KEY,
site_id TEXT NOT NULL REFERENCES static_sites(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
encrypted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(site_id, key)
);
`
// Now returns the current time formatted for SQLite storage.
+106
View File
@@ -604,4 +604,110 @@ export function fetchContainerStats(
);
}
// ── Static Sites ──────────────────────────────────────────────────────
import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types';
export function listStaticSites(): Promise<StaticSite[]> {
return get<StaticSite[]>('/api/sites');
}
export function getStaticSite(id: string): Promise<StaticSite> {
return get<StaticSite>(`/api/sites/${id}`);
}
export function createStaticSite(data: Partial<StaticSite>): Promise<StaticSite> {
return post<StaticSite>('/api/sites', data);
}
export function updateStaticSite(id: string, data: Partial<StaticSite>): Promise<StaticSite> {
return put<StaticSite>(`/api/sites/${id}`, data);
}
export function deleteStaticSite(id: string): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/sites/${id}`);
}
export function deployStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/deploy`);
}
export function stopStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/stop`);
}
export function startStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/start`);
}
export function listStaticSiteRepos(data: {
provider?: string;
gitea_url: string;
access_token?: string;
query?: string;
}): Promise<RepoInfo[]> {
return post<RepoInfo[]>('/api/sites/repos', data);
}
export function detectStaticSiteProvider(url: string): Promise<{ provider: GitProvider }> {
return post<{ provider: GitProvider }>('/api/sites/detect-provider', { url });
}
export function testStaticSiteConnection(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
}): Promise<{ status: string }> {
return post<{ status: string }>('/api/sites/test-connection', data);
}
export function listStaticSiteBranches(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
}): Promise<string[]> {
return post<string[]>('/api/sites/branches', data);
}
export function listStaticSiteTree(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
branch: string;
}): Promise<FolderEntry[]> {
return post<FolderEntry[]>('/api/sites/tree', data);
}
export function listStaticSiteSecrets(siteId: string): Promise<StaticSiteSecret[]> {
return get<StaticSiteSecret[]>(`/api/sites/${siteId}/secrets`);
}
export function createStaticSiteSecret(
siteId: string,
data: { key: string; value: string; encrypted?: boolean }
): Promise<StaticSiteSecret> {
return post<StaticSiteSecret>(`/api/sites/${siteId}/secrets`, data);
}
export function updateStaticSiteSecret(
siteId: string,
secretId: string,
data: { key?: string; value?: string; encrypted?: boolean }
): Promise<StaticSiteSecret> {
return put<StaticSiteSecret>(`/api/sites/${siteId}/secrets/${secretId}`, data);
}
export function deleteStaticSiteSecret(
siteId: string,
secretId: string
): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/sites/${siteId}/secrets/${secretId}`);
}
export { ApiError };
+78 -2
View File
@@ -17,7 +17,8 @@
"events": "Events",
"settings": "Settings",
"logout": "Log out",
"dns": "DNS Records"
"dns": "DNS Records",
"sites": "Sites"
},
"dashboard": {
"title": "Dashboard",
@@ -539,6 +540,77 @@
},
"lastChecked": "Last checked"
},
"sites": {
"title": "Static Sites",
"addSite": "New Site",
"newSite": "New Static Site",
"createSite": "Create Site",
"noSites": "No static sites",
"noSitesDesc": "Deploy static content from a Git repository folder.",
"searchPlaceholder": "Search sites by name, domain, or repo...",
"noMatching": "No sites match your search.",
"name": "Name",
"domain": "Domain",
"mode": "Mode",
"status": "Status",
"lastSync": "Last Sync",
"deploy": "Deploy",
"stop": "Stop",
"start": "Start",
"openSite": "Open Site",
"confirmDelete": "Delete Site",
"confirmDeleteMsg": "This will permanently delete the site and remove its container",
"siteInfo": "Site Information",
"folder": "Folder",
"syncTrigger": "Sync Trigger",
"commitSha": "Commit SHA",
"secrets": "Secrets",
"addSecret": "Add Secret",
"noSecrets": "No secrets configured. Add secrets if your site needs server-side API keys.",
"secretKey": "Key",
"secretValue": "Value",
"encryptSecret": "Encrypt value",
"saveSecret": "Add Secret",
"step1Title": "1. Repository",
"step2Title": "2. Select Branch",
"step3Title": "3. Select Folder",
"step4Title": "4. Configuration",
"step5Title": "5. Review & Create",
"fullRepoUrl": "Repository URL",
"fullRepoUrlHelp": "Paste a full URL to auto-fill the fields below (e.g., https://git.example.com/owner/repo)",
"serverUrl": "Server URL",
"repoUrl": "Git Server URL",
"repoUrlHelp": "Paste a full repo URL or enter the server base URL (Gitea, Forgejo, Gogs)",
"repoOwner": "Owner",
"repoName": "Repository",
"accessToken": "Access Token",
"accessTokenPlaceholder": "Optional — for private repos",
"accessTokenHelp": "Personal access token with repo read permissions. Leave empty for public repos.",
"noToken": "None (public repo)",
"testConnection": "Test Connection",
"connectionSuccess": "Repository is accessible",
"loadingBranches": "Loading branches...",
"selectBranch": "Select a branch",
"chooseBranch": "Choose a branch...",
"branch": "Branch",
"loadingTree": "Loading repository tree...",
"selectFolder": "Select the folder containing your site files",
"selectedFolder": "Selected folder",
"siteName": "Site Name",
"domainHelp": "Public domain for the site. Proxy will be configured automatically.",
"modeStaticDesc": "HTML, CSS, JS, images served via Nginx",
"modeDenoDesc": "Static files + server-side API from api/ folder",
"triggerManual": "Manual",
"triggerPush": "On Push",
"triggerTag": "On Tag",
"tagPattern": "Tag Pattern",
"tagPatternHelp": "Glob pattern for matching tags (e.g., v*, pages-*)",
"renderMarkdown": "Render Markdown files to HTML",
"provider": "Git Provider",
"detectedProvider": "Auto-detected",
"browseRepos": "Browse repositories",
"selectRepo": "Select a repository"
},
"common": {
"cancel": "Cancel",
"confirm": "Confirm",
@@ -556,7 +628,11 @@
"restart": "Restart",
"remove": "Remove",
"instance": "instance",
"instances": "instances"
"instances": "instances",
"next": "Next",
"yes": "Yes",
"no": "No",
"saving": "Saving..."
},
"instance": {
"stopConfirm": "This will stop the running container. The instance can be started again later.",
+78 -2
View File
@@ -17,7 +17,8 @@
"events": "События",
"settings": "Настройки",
"logout": "Выйти",
"dns": "DNS-записи"
"dns": "DNS-записи",
"sites": "Сайты"
},
"dashboard": {
"title": "Панель управления",
@@ -539,6 +540,77 @@
},
"lastChecked": "Последняя проверка"
},
"sites": {
"title": "Статические сайты",
"addSite": "Новый сайт",
"newSite": "Новый статический сайт",
"createSite": "Создать сайт",
"noSites": "Нет статических сайтов",
"noSitesDesc": "Разверните статический контент из папки Git-репозитория.",
"searchPlaceholder": "Поиск по имени, домену или репозиторию...",
"noMatching": "Нет сайтов, соответствующих поиску.",
"name": "Имя",
"domain": "Домен",
"mode": "Режим",
"status": "Статус",
"lastSync": "Последняя синхр.",
"deploy": "Развернуть",
"stop": "Остановить",
"start": "Запустить",
"openSite": "Открыть сайт",
"confirmDelete": "Удалить сайт",
"confirmDeleteMsg": "Это удалит сайт и его контейнер",
"siteInfo": "Информация о сайте",
"folder": "Папка",
"syncTrigger": "Триггер синхр.",
"commitSha": "Коммит SHA",
"secrets": "Секреты",
"addSecret": "Добавить секрет",
"noSecrets": "Секреты не настроены. Добавьте их, если сайту нужны серверные API-ключи.",
"secretKey": "Ключ",
"secretValue": "Значение",
"encryptSecret": "Шифровать значение",
"saveSecret": "Добавить секрет",
"step1Title": "1. Репозиторий",
"step2Title": "2. Выбор ветки",
"step3Title": "3. Выбор папки",
"step4Title": "4. Настройки",
"step5Title": "5. Проверка и создание",
"fullRepoUrl": "URL репозитория",
"fullRepoUrlHelp": "Вставьте полный URL для автозаполнения полей ниже (напр., https://git.example.com/owner/repo)",
"serverUrl": "URL сервера",
"repoUrl": "URL Git-сервера",
"repoUrlHelp": "Вставьте полный URL репозитория или базовый URL сервера (Gitea, Forgejo, Gogs)",
"repoOwner": "Владелец",
"repoName": "Репозиторий",
"accessToken": "Токен доступа",
"accessTokenPlaceholder": "Необязательно — для приватных репозиториев",
"accessTokenHelp": "Персональный токен с правами на чтение репозитория. Оставьте пустым для публичных.",
"noToken": "Нет (публичный репо)",
"testConnection": "Проверить соединение",
"connectionSuccess": "Репозиторий доступен",
"loadingBranches": "Загрузка веток...",
"selectBranch": "Выберите ветку",
"chooseBranch": "Выберите ветку...",
"branch": "Ветка",
"loadingTree": "Загрузка дерева репозитория...",
"selectFolder": "Выберите папку с файлами сайта",
"selectedFolder": "Выбранная папка",
"siteName": "Имя сайта",
"domainHelp": "Публичный домен сайта. Прокси будет настроен автоматически.",
"modeStaticDesc": "HTML, CSS, JS, изображения через Nginx",
"modeDenoDesc": "Статические файлы + серверный API из папки api/",
"triggerManual": "Вручную",
"triggerPush": "При пуше",
"triggerTag": "По тегу",
"tagPattern": "Паттерн тега",
"tagPatternHelp": "Glob-паттерн для тегов (напр., v*, pages-*)",
"renderMarkdown": "Рендерить Markdown-файлы в HTML",
"provider": "Git-провайдер",
"detectedProvider": "Автоопределён",
"browseRepos": "Обзор репозиториев",
"selectRepo": "Выберите репозиторий"
},
"common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
@@ -556,7 +628,11 @@
"restart": "Перезапустить",
"remove": "Удалить",
"instance": "экземпляр",
"instances": "экземпляров"
"instances": "экземпляров",
"next": "Далее",
"yes": "Да",
"no": "Нет",
"saving": "Сохранение..."
},
"instance": {
"stopConfirm": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.",
+57
View File
@@ -336,6 +336,63 @@ export interface StaleContainer {
days_stale: number;
}
/** A static site deployed from a Git repository folder. */
export interface StaticSite {
id: string;
name: string;
provider: GitProvider;
gitea_url: string;
repo_owner: string;
repo_name: string;
branch: string;
folder_path: string;
access_token: string;
domain: string;
mode: 'static' | 'deno';
render_markdown: boolean;
sync_trigger: 'push' | 'tag' | 'manual';
tag_pattern: string;
container_id: string;
proxy_route_id: string;
status: StaticSiteStatus;
last_sync_at: string;
last_commit_sha: string;
error: string;
created_at: string;
updated_at: string;
}
export type StaticSiteStatus = 'idle' | 'syncing' | 'deployed' | 'failed' | 'stopped';
export type GitProvider = '' | 'gitea' | 'github' | 'gitlab';
/** An encrypted environment variable for a static site's Deno backend. */
export interface StaticSiteSecret {
id: string;
site_id: string;
key: string;
value: string;
encrypted: boolean;
created_at: string;
updated_at: string;
}
/** A repository from the Git provider's API. */
export interface RepoInfo {
owner: string;
name: string;
full_name: string;
description: string;
private: boolean;
html_url: string;
}
/** A folder entry from the Gitea repo tree. */
export interface FolderEntry {
path: string;
is_dir: boolean;
}
/** Container CPU and memory stats from the Docker stats API. */
export interface ContainerStats {
cpu_percent: number;
+8 -1
View File
@@ -6,13 +6,14 @@
import Toast from '$lib/components/Toast.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons';
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe } from '$lib/components/icons';
import { goto } from '$app/navigation';
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
import { instanceStatusStore } from '$lib/stores/instance-status';
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
import { logout as apiLogout, getHealth } from '$lib/api';
import { publishEventLog } from '$lib/stores/event-log-bus';
import type { DockerHealth, ProxyHealth } from '$lib/types';
import { t } from '$lib/i18n';
@@ -25,6 +26,7 @@
const navItems = [
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe' },
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
@@ -96,6 +98,9 @@
},
onDeployStatus(payload) {
instanceStatusStore.notifyDeploy(payload);
},
onEventLog(payload) {
publishEventLog(payload);
}
});
@@ -175,6 +180,8 @@
<IconDashboard size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'projects'}
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'globe'}
<IconGlobe size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'deploy'}
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'proxies'}
+267
View File
@@ -0,0 +1,267 @@
<script lang="ts">
import type { StaticSite } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { IconPlus, IconSearch, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let sites = $state<StaticSite[]>([]);
let loading = $state(true);
let error = $state('');
let searchQuery = $state('');
let deploying = $state<Record<string, boolean>>({});
let confirmDelete = $state<StaticSite | null>(null);
const filteredSites = $derived(
searchQuery.trim()
? sites.filter(s => {
const q = searchQuery.toLowerCase();
return s.name.toLowerCase().includes(q)
|| s.domain.toLowerCase().includes(q)
|| s.repo_name.toLowerCase().includes(q);
})
: sites
);
async function loadSites() {
loading = true;
error = '';
try {
sites = await api.listStaticSites();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load sites';
} finally {
loading = false;
}
}
async function handleDeploy(site: StaticSite) {
deploying = { ...deploying, [site.id]: true };
try {
await api.deployStaticSite(site.id);
// Refresh after a short delay to pick up status change.
setTimeout(() => loadSites(), 2000);
} catch (e) {
error = e instanceof Error ? e.message : 'Deploy failed';
} finally {
deploying = { ...deploying, [site.id]: false };
}
}
async function handleStop(site: StaticSite) {
try {
await api.stopStaticSite(site.id);
setTimeout(() => loadSites(), 2000);
} catch (e) {
error = e instanceof Error ? e.message : 'Stop failed';
}
}
async function handleStart(site: StaticSite) {
try {
await api.startStaticSite(site.id);
setTimeout(() => loadSites(), 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Start failed';
}
}
async function handleDelete() {
if (!confirmDelete) return;
const id = confirmDelete.id;
confirmDelete = null;
try {
await api.deleteStaticSite(id);
await loadSites();
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
}
}
function statusBadge(status: string): { text: string; class: string } {
switch (status) {
case 'deployed':
return { text: 'Deployed', class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
case 'syncing':
return { text: 'Syncing', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
case 'failed':
return { text: 'Failed', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
default:
return { text: 'Idle', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
}
}
function modeBadge(mode: string): { text: string; class: string } {
if (mode === 'deno') {
return { text: 'Deno', class: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' };
}
return { text: 'Static', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
}
$effect(() => {
loadSites();
});
</script>
<svelte:head>
<title>{$t('sites.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('sites.title')}</h1>
<a
href="/sites/new"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-all duration-150 active:animate-press"
>
<IconPlus size={16} />
{$t('sites.addSite')}
</a>
</div>
{#if loading}
<SkeletonTable rows={4} cols={5} />
{: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={loadSites}>
{$t('common.retry')}
</button>
</div>
{:else if sites.length === 0}
<EmptyState
title={$t('sites.noSites')}
description={$t('sites.noSitesDesc')}
actionLabel={$t('sites.addSite')}
onaction={() => { window.location.href = '/sites/new'; }}
/>
{:else}
<!-- Search -->
<div class="relative">
<IconSearch size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
<input
type="text"
bind:value={searchQuery}
placeholder={$t('sites.searchPlaceholder')}
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2.5 pl-10 pr-4 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
/>
</div>
{#if filteredSites.length === 0}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
<p class="text-sm text-[var(--text-tertiary)]">{$t('sites.noMatching')}</p>
</div>
{:else}
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.name')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.domain')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.mode')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.status')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.lastSync')}</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each filteredSites as site (site.id)}
{@const status = statusBadge(site.status)}
{@const mode = modeBadge(site.mode)}
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150">
<td class="whitespace-nowrap px-6 py-4">
<a href="/sites/{site.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
{site.name}
</a>
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{site.repo_owner}/{site.repo_name}</p>
</td>
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm">
{#if site.domain}
<a href="https://{site.domain}" target="_blank" rel="noopener noreferrer" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
{site.domain}
</a>
{:else}
<span class="text-[var(--text-tertiary)]">-</span>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {mode.class}">
{mode.text}
</span>
</td>
<td class="whitespace-nowrap px-6 py-4">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {status.class}">
{status.text}
</span>
{#if site.error}
<p class="mt-0.5 max-w-[200px] truncate text-xs text-red-500" title={site.error}>{site.error}</p>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
{#if site.last_sync_at}
{new Date(site.last_sync_at).toLocaleString()}
{:else}
-
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<button
type="button"
title={$t('sites.deploy')}
disabled={deploying[site.id]}
onclick={() => handleDeploy(site)}
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
>
<IconRefresh size={16} class={deploying[site.id] ? 'animate-spin' : ''} />
</button>
{#if site.status === 'stopped'}
<button
type="button"
title={$t('sites.start')}
onclick={() => handleStart(site)}
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-emerald-600 hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconPlay size={16} />
</button>
{:else if site.status === 'deployed'}
<button
type="button"
title={$t('sites.stop')}
onclick={() => handleStop(site)}
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconStop size={16} />
</button>
{/if}
<button
type="button"
title={$t('common.delete')}
onclick={() => { confirmDelete = site; }}
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
{#if confirmDelete}
<ConfirmDialog
open={confirmDelete !== null}
title={$t('sites.confirmDelete')}
message={`${$t('sites.confirmDeleteMsg')} "${confirmDelete.name}"?`}
confirmLabel={$t('common.delete')}
onconfirm={handleDelete}
oncancel={() => { confirmDelete = null; }}
/>
{/if}
+322
View File
@@ -0,0 +1,322 @@
<script lang="ts">
import type { StaticSite, StaticSiteSecret } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import FormField from '$lib/components/FormField.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { IconArrowLeft, IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
let site = $state<StaticSite | null>(null);
let secrets = $state<StaticSiteSecret[]>([]);
let loading = $state(true);
let error = $state('');
let deploying = $state(false);
let confirmDelete = $state(false);
// Secret form.
let showSecretForm = $state(false);
let secretKey = $state('');
let secretValue = $state('');
let secretEncrypted = $state(true);
let secretSubmitting = $state(false);
const siteId = $derived($page.params.id);
async function loadSite() {
loading = true;
error = '';
try {
site = await api.getStaticSite(siteId!);
secrets = await api.listStaticSiteSecrets(siteId!);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load site';
} finally {
loading = false;
}
}
async function handleDeploy() {
if (!site) return;
deploying = true;
try {
await api.deployStaticSite(site.id);
setTimeout(() => loadSite(), 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Deploy failed';
} finally {
deploying = false;
}
}
async function handleStop() {
if (!site) return;
try {
await api.stopStaticSite(site.id);
setTimeout(() => loadSite(), 2000);
} catch (e) {
error = e instanceof Error ? e.message : 'Stop failed';
}
}
async function handleStart() {
if (!site) return;
try {
await api.startStaticSite(site.id);
setTimeout(() => loadSite(), 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Start failed';
}
}
async function handleDelete() {
if (!site) return;
confirmDelete = false;
try {
await api.deleteStaticSite(site.id);
goto('/sites');
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
}
}
async function handleAddSecret() {
if (!site || !secretKey.trim()) return;
secretSubmitting = true;
try {
await api.createStaticSiteSecret(site.id, {
key: secretKey.trim(),
value: secretValue,
encrypted: secretEncrypted
});
secretKey = '';
secretValue = '';
secretEncrypted = true;
showSecretForm = false;
secrets = await api.listStaticSiteSecrets(site.id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add secret';
} finally {
secretSubmitting = false;
}
}
async function handleDeleteSecret(secretId: string) {
if (!site) return;
try {
await api.deleteStaticSiteSecret(site.id, secretId);
secrets = await api.listStaticSiteSecrets(site.id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete secret';
}
}
function statusBadge(status: string): { text: string; class: string } {
switch (status) {
case 'deployed': return { text: 'Deployed', class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
case 'syncing': return { text: 'Syncing', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
case 'failed': return { text: 'Failed', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
default: return { text: 'Idle', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
}
}
$effect(() => {
void siteId;
loadSite();
});
</script>
<svelte:head>
<title>{site?.name ?? $t('sites.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
{#if loading}
<div class="flex items-center gap-2 py-8">
<IconLoader size={20} class="animate-spin text-[var(--text-tertiary)]" />
<span class="text-[var(--text-tertiary)]">{$t('common.loading')}</span>
</div>
{:else if error && !site}
<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>
</div>
{:else if site}
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/sites" class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors">
<IconArrowLeft size={20} />
</a>
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{site.name}</h1>
<p class="text-sm text-[var(--text-tertiary)]">{site.repo_owner}/{site.repo_name} &middot; {site.branch}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
disabled={deploying}
onclick={handleDeploy}
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
>
<IconRefresh size={16} class={deploying ? 'animate-spin' : ''} />
{$t('sites.deploy')}
</button>
{#if site.status === 'stopped'}
<button
type="button"
onclick={handleStart}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-emerald-600 hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconPlay size={16} />
{$t('sites.start')}
</button>
{:else if site.status === 'deployed'}
<button
type="button"
onclick={handleStop}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconStop size={16} />
{$t('sites.stop')}
</button>
{/if}
{#if site.domain}
<a
href="https://{site.domain}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconGlobe size={16} />
{$t('sites.openSite')}
</a>
{/if}
<button
type="button"
onclick={() => { confirmDelete = true; }}
class="rounded-lg border border-[var(--color-danger-light)] px-4 py-2.5 text-sm font-medium text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors"
>
<IconTrash size={16} />
</button>
</div>
</div>
{#if error}
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-3">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
</div>
{/if}
<!-- Status & Info -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Site Info -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
<h2 class="text-base font-semibold text-[var(--text-primary)] mb-4">{$t('sites.siteInfo')}</h2>
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<span class="text-[var(--text-tertiary)]">{$t('sites.status')}</span>
<span>
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {statusBadge(site.status).class}">{statusBadge(site.status).text}</span>
</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.mode')}</span>
<span class="text-[var(--text-primary)]">{site.mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.domain')}</span>
<span class="text-[var(--text-primary)] font-mono text-xs">{site.domain || '-'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.folder')}</span>
<span class="text-[var(--text-primary)] font-mono text-xs">{site.folder_path || '/ (root)'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.syncTrigger')}</span>
<span class="text-[var(--text-primary)]">{site.sync_trigger}{site.sync_trigger === 'tag' ? ` (${site.tag_pattern})` : ''}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.lastSync')}</span>
<span class="text-[var(--text-primary)]">{site.last_sync_at ? new Date(site.last_sync_at).toLocaleString() : '-'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.commitSha')}</span>
<span class="text-[var(--text-primary)] font-mono text-xs">{site.last_commit_sha ? site.last_commit_sha.slice(0, 8) : '-'}</span>
</div>
{#if site.error}
<div class="mt-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-3">
<p class="text-xs text-red-600 dark:text-red-400">{site.error}</p>
</div>
{/if}
</div>
<!-- Secrets -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-[var(--text-primary)]">{$t('sites.secrets')}</h2>
<button
type="button"
onclick={() => { showSecretForm = !showSecretForm; }}
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconPlus size={14} />
{$t('sites.addSecret')}
</button>
</div>
{#if showSecretForm}
<div class="mb-4 space-y-3 rounded-lg bg-[var(--surface-card-hover)] p-4">
<FormField label={$t('sites.secretKey')} name="secretKey" bind:value={secretKey} placeholder="API_KEY" required />
<FormField label={$t('sites.secretValue')} name="secretValue" bind:value={secretValue} placeholder="sk-..." />
<label class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<input type="checkbox" bind:checked={secretEncrypted} class="rounded border-[var(--border-input)]" />
{$t('sites.encryptSecret')}
</label>
<button
type="button"
disabled={!secretKey.trim() || secretSubmitting}
onclick={handleAddSecret}
class="rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
>
{secretSubmitting ? $t('common.saving') : $t('sites.saveSecret')}
</button>
</div>
{/if}
{#if secrets.length === 0}
<p class="text-sm text-[var(--text-tertiary)]">{$t('sites.noSecrets')}</p>
{:else}
<div class="space-y-2">
{#each secrets as secret (secret.id)}
<div class="flex items-center justify-between rounded-lg border border-[var(--border-secondary)] px-3 py-2">
<div class="flex items-center gap-2">
{#if secret.encrypted}
<IconLock size={14} class="text-[var(--text-tertiary)]" />
{:else}
<IconUnlock size={14} class="text-[var(--text-tertiary)]" />
{/if}
<span class="font-mono text-sm text-[var(--text-primary)]">{secret.key}</span>
<span class="text-xs text-[var(--text-tertiary)]">{secret.value}</span>
</div>
<button
type="button"
onclick={() => handleDeleteSecret(secret.id)}
class="rounded p-1 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
>
<IconTrash size={14} />
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
{#if confirmDelete}
<ConfirmDialog
open={confirmDelete}
title={$t('sites.confirmDelete')}
message={`${$t('sites.confirmDeleteMsg')} "${site?.name}"?`}
confirmLabel={$t('common.delete')}
onconfirm={handleDelete}
oncancel={() => { confirmDelete = false; }}
/>
{/if}
+671
View File
@@ -0,0 +1,671 @@
<script lang="ts">
import type { FolderEntry, GitProvider } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { goto } from '$app/navigation';
import FormField from '$lib/components/FormField.svelte';
import { IconArrowLeft, IconCheck, IconLoader, IconChevronRight, IconSearch } from '$lib/components/icons';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import type { EntityPickerItem } from '$lib/types';
// Provider options.
const providerOptions: { value: GitProvider; label: string }[] = [
{ value: '', label: 'Auto-detect' },
{ value: 'gitea', label: 'Gitea / Forgejo / Gogs' },
{ value: 'github', label: 'GitHub' },
{ value: 'gitlab', label: 'GitLab' },
];
// Wizard state.
let step = $state(1);
const totalSteps = 5;
// Step 1: Repo URL.
let fullRepoUrl = $state('');
let provider = $state<GitProvider>('');
let detectedProvider = $state<GitProvider>('');
let detecting = $state(false);
let giteaUrl = $state('');
let repoOwner = $state('');
let repoName = $state('');
let accessToken = $state('');
let connectionTested = $state(false);
let connectionError = $state('');
let testing = $state(false);
// Repo picker.
let showRepoPicker = $state(false);
let repoPickerItems = $state<EntityPickerItem[]>([]);
let repoPickerLoading = $state(false);
// The effective provider (explicit selection or autodetected).
const effectiveProvider = $derived(provider || detectedProvider || 'gitea');
// Step 2: Branch picker.
let branches = $state<string[]>([]);
let selectedBranch = $state('');
let branchesLoading = $state(false);
let showBranchPicker = $state(false);
// Step 3: Folder picker.
let tree = $state<FolderEntry[]>([]);
let selectedFolder = $state('');
let treeLoading = $state(false);
let expandedDirs = $state<Set<string>>(new Set());
// Step 4: Configuration.
let siteName = $state('');
let domain = $state('');
let mode = $state<'static' | 'deno'>('static');
let renderMarkdown = $state(false);
let syncTrigger = $state<'push' | 'tag' | 'manual'>('manual');
let tagPattern = $state('');
// Step 5: Review + submit.
let submitting = $state(false);
let submitError = $state('');
// Parse repo URL into components and autodetect provider.
function parseRepoUrl(url: string) {
try {
const parsed = new URL(url.trim());
const pathParts = parsed.pathname.split('/').filter(Boolean);
if (pathParts.length >= 2) {
giteaUrl = `${parsed.protocol}//${parsed.host}`;
repoOwner = pathParts[0];
repoName = pathParts[1];
}
} catch {
// Not a valid URL yet.
}
}
async function browseRepos() {
if (!giteaUrl) return;
showRepoPicker = true;
if (repoPickerItems.length > 0) return;
repoPickerLoading = true;
try {
await autoDetectProvider();
const repos = await api.listStaticSiteRepos({
provider: effectiveProvider,
gitea_url: giteaUrl,
access_token: accessToken || undefined,
});
repoPickerItems = repos.map(r => ({
value: JSON.stringify({ owner: r.owner, name: r.name }),
label: r.full_name,
description: r.description || undefined,
icon: r.private ? 'lock' : undefined,
}));
} catch {
repoPickerItems = [];
} finally {
repoPickerLoading = false;
}
}
function selectPickedRepo(value: string) {
const parsed = JSON.parse(value) as { owner: string; name: string };
repoOwner = parsed.owner;
repoName = parsed.name;
showRepoPicker = false;
}
async function autoDetectProvider() {
if (!giteaUrl || provider) return; // skip if manually selected
detecting = true;
try {
const result = await api.detectStaticSiteProvider(giteaUrl);
detectedProvider = result.provider;
} catch {
detectedProvider = 'gitea';
} finally {
detecting = false;
}
}
async function testConnection() {
testing = true;
connectionError = '';
connectionTested = false;
try {
// Autodetect provider if not manually set.
await autoDetectProvider();
await api.testStaticSiteConnection({
provider: effectiveProvider,
gitea_url: giteaUrl,
access_token: accessToken || undefined,
repo_owner: repoOwner,
repo_name: repoName
});
connectionTested = true;
} catch (e) {
connectionError = e instanceof Error ? e.message : 'Connection failed';
} finally {
testing = false;
}
}
async function loadBranches() {
branchesLoading = true;
try {
branches = await api.listStaticSiteBranches({
provider: effectiveProvider,
gitea_url: giteaUrl,
access_token: accessToken || undefined,
repo_owner: repoOwner,
repo_name: repoName
});
if (branches.length > 0 && !selectedBranch) {
// Default to main/master if available.
selectedBranch = branches.find(b => b === 'main') ?? branches.find(b => b === 'master') ?? branches[0];
}
} catch {
branches = [];
} finally {
branchesLoading = false;
}
}
async function loadTree() {
treeLoading = true;
try {
tree = await api.listStaticSiteTree({
provider: effectiveProvider,
gitea_url: giteaUrl,
access_token: accessToken || undefined,
repo_owner: repoOwner,
repo_name: repoName,
branch: selectedBranch
});
} catch {
tree = [];
} finally {
treeLoading = false;
}
}
function goToStep(s: number) {
step = s;
if (s === 2 && branches.length === 0) loadBranches();
if (s === 3 && tree.length === 0) loadTree();
if (s === 4) {
if (!siteName) siteName = repoName;
// Autodetect Deno mode: check if selected folder has an api/ subdirectory.
const apiPrefix = selectedFolder ? selectedFolder + '/api' : 'api';
const hasApi = tree.some(e => e.is_dir && (e.path === apiPrefix || e.path.startsWith(apiPrefix + '/')));
if (hasApi) {
mode = 'deno';
}
}
}
// Tree helpers.
const folders = $derived(tree.filter(e => e.is_dir).sort((a, b) => a.path.localeCompare(b.path)));
function getTopLevelFolders(): FolderEntry[] {
return folders.filter(f => !f.path.includes('/'));
}
function getChildFolders(parentPath: string): FolderEntry[] {
return folders.filter(f => {
if (!f.path.startsWith(parentPath + '/')) return false;
const rest = f.path.slice(parentPath.length + 1);
return !rest.includes('/');
});
}
function toggleDir(path: string) {
const next = new Set(expandedDirs);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
expandedDirs = next;
}
function selectFolder(path: string) {
selectedFolder = path;
}
// Branch picker items.
const branchPickerItems = $derived<EntityPickerItem[]>(
branches.map(b => ({ value: b, label: b }))
);
async function handleSubmit() {
submitting = true;
submitError = '';
try {
const site = await api.createStaticSite({
name: siteName,
provider: effectiveProvider,
gitea_url: giteaUrl,
repo_owner: repoOwner,
repo_name: repoName,
branch: selectedBranch,
folder_path: selectedFolder,
access_token: accessToken || undefined,
domain: domain || undefined,
mode,
render_markdown: renderMarkdown,
sync_trigger: syncTrigger,
tag_pattern: syncTrigger === 'tag' ? tagPattern : undefined
});
goto(`/sites/${site.id}`);
} catch (e) {
submitError = e instanceof Error ? e.message : 'Failed to create site';
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$t('sites.newSite')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-3">
<a href="/sites" class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors">
<IconArrowLeft size={20} />
</a>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('sites.newSite')}</h1>
</div>
<!-- Progress -->
<div class="flex items-center gap-2">
{#each Array(totalSteps) as _, i}
<div class="h-1.5 flex-1 rounded-full transition-colors {i < step ? 'bg-[var(--color-brand-600)]' : 'bg-[var(--border-primary)]'}"></div>
{/each}
</div>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 animate-scale-in">
<!-- Step 1: Repository -->
{#if step === 1}
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step1Title')}</h2>
<div class="space-y-4">
<!-- Provider selector -->
<div class="space-y-2">
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.provider')}</label>
<div class="flex gap-2 flex-wrap">
{#each providerOptions as opt}
<button
type="button"
class="rounded-lg border px-3 py-2 text-sm font-medium transition-colors {provider === opt.value ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
onclick={() => { provider = opt.value; detectedProvider = ''; }}
>
{opt.label}
</button>
{/each}
</div>
{#if provider === '' && detectedProvider}
<p class="text-xs text-emerald-600 dark:text-emerald-400">
{$t('sites.detectedProvider')}: {providerOptions.find(o => o.value === detectedProvider)?.label ?? detectedProvider}
</p>
{/if}
</div>
<!-- Paste full URL for auto-fill -->
<FormField
label={$t('sites.fullRepoUrl')}
name="fullRepoUrl"
bind:value={fullRepoUrl}
placeholder="https://git.example.com/owner/repo"
helpText={$t('sites.fullRepoUrlHelp')}
oninput={(e) => {
const val = (e.target as HTMLInputElement).value;
if (val.includes('/') && val.startsWith('http')) {
parseRepoUrl(val);
autoDetectProvider();
}
}}
/>
<!-- Individual fields (auto-filled or manual) -->
<FormField label={$t('sites.serverUrl')} name="serverUrl" bind:value={giteaUrl} placeholder="https://git.example.com" required />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label={$t('sites.repoOwner')} name="repoOwner" bind:value={repoOwner} placeholder="username" required />
<div class="flex items-end gap-2">
<div class="flex-1">
<FormField label={$t('sites.repoName')} name="repoName" bind:value={repoName} placeholder="my-app" required />
</div>
<button
type="button"
onclick={browseRepos}
title={$t('sites.browseRepos')}
disabled={!giteaUrl}
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
{#if repoPickerLoading}
<IconLoader size={16} class="animate-spin" />
{:else}
<IconSearch size={16} />
{/if}
</button>
</div>
</div>
<EntityPicker
bind:open={showRepoPicker}
items={repoPickerItems}
current={repoOwner && repoName ? JSON.stringify({ owner: repoOwner, name: repoName }) : ''}
title={$t('sites.selectRepo')}
placeholder={$t('entityPicker.search')}
onselect={selectPickedRepo}
onclose={() => { showRepoPicker = false; }}
/>
<FormField
label={$t('sites.accessToken')}
name="accessToken"
type="password"
bind:value={accessToken}
placeholder={$t('sites.accessTokenPlaceholder')}
helpText={$t('sites.accessTokenHelp')}
/>
{#if connectionError}
<div class="rounded-lg bg-[var(--color-danger-light)] p-3">
<p class="text-sm text-[var(--color-danger)]">{connectionError}</p>
</div>
{/if}
{#if connectionTested}
<div class="rounded-lg bg-emerald-50 dark:bg-emerald-900/20 p-3 flex items-center gap-2">
<IconCheck size={16} class="text-emerald-600" />
<p class="text-sm text-emerald-700 dark:text-emerald-400">{$t('sites.connectionSuccess')}</p>
</div>
{/if}
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
disabled={!giteaUrl || !repoOwner || !repoName || testing}
onclick={testConnection}
>
{#if testing}
<IconLoader size={14} class="inline mr-1 animate-spin" />
{/if}
{$t('sites.testConnection')}
</button>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
disabled={!connectionTested}
onclick={() => goToStep(2)}
>
{$t('common.next')}
</button>
</div>
<!-- Step 2: Branch -->
{:else if step === 2}
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step2Title')}</h2>
{#if branchesLoading}
<div class="flex items-center gap-2 py-4">
<IconLoader size={16} class="animate-spin text-[var(--text-tertiary)]" />
<span class="text-sm text-[var(--text-tertiary)]">{$t('sites.loadingBranches')}</span>
</div>
{:else}
<div class="space-y-2">
<p class="text-sm text-[var(--text-secondary)] mb-3">{$t('sites.selectBranch')}</p>
<button
type="button"
class="w-full text-left rounded-lg border border-[var(--border-primary)] px-4 py-3 text-sm hover:bg-[var(--surface-card-hover)] transition-colors"
onclick={() => { showBranchPicker = true; }}
>
<span class="font-medium text-[var(--text-primary)]">{selectedBranch || $t('sites.chooseBranch')}</span>
</button>
<EntityPicker
bind:open={showBranchPicker}
items={branchPickerItems}
current={selectedBranch}
title={$t('sites.selectBranch')}
placeholder={$t('entityPicker.search')}
onselect={(val) => { selectedBranch = val; showBranchPicker = false; tree = []; }}
onclose={() => { showBranchPicker = false; }}
/>
</div>
{/if}
<div class="mt-6 flex justify-between">
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 1; }}>
{$t('common.back')}
</button>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
disabled={!selectedBranch}
onclick={() => goToStep(3)}
>
{$t('common.next')}
</button>
</div>
<!-- Step 3: Folder -->
{:else if step === 3}
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step3Title')}</h2>
{#if treeLoading}
<div class="flex items-center gap-2 py-4">
<IconLoader size={16} class="animate-spin text-[var(--text-tertiary)]" />
<span class="text-sm text-[var(--text-tertiary)]">{$t('sites.loadingTree')}</span>
</div>
{:else}
<p class="text-sm text-[var(--text-secondary)] mb-3">{$t('sites.selectFolder')}</p>
<!-- Root option -->
<button
type="button"
class="w-full text-left rounded-lg px-4 py-2 text-sm transition-colors mb-1 {selectedFolder === '' ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-secondary)]'}"
onclick={() => selectFolder('')}
>
/ (root)
</button>
<div class="max-h-64 overflow-y-auto rounded-lg border border-[var(--border-primary)] p-2">
{#each getTopLevelFolders() as folder (folder.path)}
{@const isSelected = selectedFolder === folder.path}
{@const isExpanded = expandedDirs.has(folder.path)}
{@const children = getChildFolders(folder.path)}
<div>
<div class="flex items-center gap-1">
{#if children.length > 0}
<button type="button" class="p-0.5 text-[var(--text-tertiary)]" onclick={() => toggleDir(folder.path)}>
<IconChevronRight size={14} class="transition-transform {isExpanded ? 'rotate-90' : ''}" />
</button>
{:else}
<span class="w-5"></span>
{/if}
<button
type="button"
class="flex-1 text-left rounded px-2 py-1.5 text-sm transition-colors {isSelected ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-primary)]'}"
onclick={() => selectFolder(folder.path)}
>
{folder.path}
</button>
</div>
{#if isExpanded}
<div class="ml-5">
{#each children as child (child.path)}
{@const childSelected = selectedFolder === child.path}
<button
type="button"
class="w-full text-left rounded px-2 py-1.5 text-sm transition-colors {childSelected ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-secondary)]'}"
onclick={() => selectFolder(child.path)}
>
{child.path.split('/').pop()}
</button>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{#if selectedFolder}
<p class="mt-2 text-xs text-[var(--text-tertiary)]">{$t('sites.selectedFolder')}: <strong>{selectedFolder || '/'}</strong></p>
{/if}
{/if}
<div class="mt-6 flex justify-between">
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 2; }}>
{$t('common.back')}
</button>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors"
onclick={() => goToStep(4)}
>
{$t('common.next')}
</button>
</div>
<!-- Step 4: Configuration -->
{:else if step === 4}
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step4Title')}</h2>
<div class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label={$t('sites.siteName')} name="siteName" bind:value={siteName} placeholder="my-site" required />
<FormField label={$t('sites.domain')} name="domain" bind:value={domain} placeholder="site.example.com" helpText={$t('sites.domainHelp')} />
</div>
<!-- Mode -->
<div class="space-y-2">
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.mode')}</label>
<div class="flex gap-3">
<button
type="button"
class="flex-1 rounded-lg border px-4 py-3 text-sm text-left transition-colors {mode === 'static' ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}"
onclick={() => { mode = 'static'; }}
>
<div class="font-medium text-[var(--text-primary)]">Static</div>
<div class="text-xs text-[var(--text-tertiary)] mt-0.5">{$t('sites.modeStaticDesc')}</div>
</button>
<button
type="button"
class="flex-1 rounded-lg border px-4 py-3 text-sm text-left transition-colors {mode === 'deno' ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}"
onclick={() => { mode = 'deno'; }}
>
<div class="font-medium text-[var(--text-primary)]">Deno</div>
<div class="text-xs text-[var(--text-tertiary)] mt-0.5">{$t('sites.modeDenoDesc')}</div>
</button>
</div>
</div>
<!-- Sync trigger -->
<div class="space-y-2">
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.syncTrigger')}</label>
<div class="flex gap-3">
{#each [
{ value: 'manual', label: $t('sites.triggerManual') },
{ value: 'push', label: $t('sites.triggerPush') },
{ value: 'tag', label: $t('sites.triggerTag') }
] as opt}
<button
type="button"
class="flex-1 rounded-lg border px-4 py-2.5 text-sm text-center font-medium transition-colors {syncTrigger === opt.value ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
onclick={() => { syncTrigger = opt.value as 'push' | 'tag' | 'manual'; }}
>
{opt.label}
</button>
{/each}
</div>
</div>
{#if syncTrigger === 'tag'}
<FormField label={$t('sites.tagPattern')} name="tagPattern" bind:value={tagPattern} placeholder="v*" helpText={$t('sites.tagPatternHelp')} />
{/if}
<!-- Options -->
<label class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<input type="checkbox" bind:checked={renderMarkdown} class="rounded border-[var(--border-input)]" />
{$t('sites.renderMarkdown')}
</label>
</div>
<div class="mt-6 flex justify-between">
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 3; }}>
{$t('common.back')}
</button>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
disabled={!siteName.trim()}
onclick={() => { step = 5; }}
>
{$t('common.next')}
</button>
</div>
<!-- Step 5: Review -->
{:else if step === 5}
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step5Title')}</h2>
<div class="space-y-3 text-sm">
<div class="grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg bg-[var(--surface-card-hover)] p-4">
<span class="text-[var(--text-tertiary)]">{$t('sites.provider')}</span>
<span class="text-[var(--text-primary)]">{providerOptions.find(o => o.value === effectiveProvider)?.label ?? effectiveProvider}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.repoUrl')}</span>
<span class="text-[var(--text-primary)] font-mono text-xs">{giteaUrl}/{repoOwner}/{repoName}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.branch')}</span>
<span class="text-[var(--text-primary)]">{selectedBranch}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.folder')}</span>
<span class="text-[var(--text-primary)]">{selectedFolder || '/ (root)'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.siteName')}</span>
<span class="text-[var(--text-primary)]">{siteName}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.domain')}</span>
<span class="text-[var(--text-primary)]">{domain || '-'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.mode')}</span>
<span class="text-[var(--text-primary)]">{mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.syncTrigger')}</span>
<span class="text-[var(--text-primary)]">{syncTrigger}{syncTrigger === 'tag' ? ` (${tagPattern})` : ''}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.renderMarkdown')}</span>
<span class="text-[var(--text-primary)]">{renderMarkdown ? $t('common.yes') : $t('common.no')}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.accessToken')}</span>
<span class="text-[var(--text-primary)]">{accessToken ? '••••••••' : $t('sites.noToken')}</span>
</div>
</div>
{#if submitError}
<div class="mt-4 rounded-lg bg-[var(--color-danger-light)] p-3">
<p class="text-sm text-[var(--color-danger)]">{submitError}</p>
</div>
{/if}
<div class="mt-6 flex justify-between">
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 4; }}>
{$t('common.back')}
</button>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
disabled={submitting}
onclick={handleSubmit}
>
{#if submitting}
<IconLoader size={14} class="inline mr-1 animate-spin" />
{/if}
{$t('sites.createSite')}
</button>
</div>
{/if}
</div>
</div>