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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "••••••••"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;"]
|
||||
`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}
|
||||
@@ -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} · {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}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user