Add frontend #1
@@ -2,6 +2,7 @@ package github
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -11,7 +12,67 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func FetchAndDeployBranch(repoOwner, repoName, branch, pat, destDir string) error {
|
// DeployResult holds metadata about the deployed commit.
|
||||||
|
type DeployResult struct {
|
||||||
|
CommitHash string `json:"commit_hash"`
|
||||||
|
CommitMessage string `json:"commit_message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchAndDeployBranch(repoOwner, repoName, branch, pat, destDir string) (*DeployResult, error) {
|
||||||
|
result, err := fetchBranchHead(repoOwner, repoName, branch, pat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching branch head: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = downloadAndExtract(repoOwner, repoName, branch, pat, destDir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchBranchHead returns the SHA and commit message for the tip of the given branch.
|
||||||
|
func fetchBranchHead(owner, repo, branch, pat string) (*DeployResult, error) {
|
||||||
|
url := fmt.Sprintf(
|
||||||
|
"https://api.github.com/repos/%s/%s/commits/%s",
|
||||||
|
owner, repo, branch,
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+pat)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching commit: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("GitHub returned %s for %s", resp.Status, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Commit struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"commit"`
|
||||||
|
}
|
||||||
|
if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("decoding commit response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DeployResult{
|
||||||
|
CommitHash: payload.SHA,
|
||||||
|
CommitMessage: strings.SplitN(payload.Commit.Message, "\n", 2)[0],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadAndExtract(repoOwner, repoName, branch, pat, destDir string) error {
|
||||||
archiveURL := fmt.Sprintf(
|
archiveURL := fmt.Sprintf(
|
||||||
"https://api.github.com/repos/%s/%s/zipball/%s",
|
"https://api.github.com/repos/%s/%s/zipball/%s",
|
||||||
repoOwner, repoName, branch,
|
repoOwner, repoName, branch,
|
||||||
@@ -59,7 +120,7 @@ func FetchAndDeployBranch(repoOwner, repoName, branch, pat, destDir string) erro
|
|||||||
return fmt.Errorf("unzipping: %w", err)
|
return fmt.Errorf("unzipping: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// github wraps everything in one top-level folder ({owner}-{repo}-{sha}/).
|
// GitHub wraps everything in one top-level folder ({owner}-{repo}-{sha}/).
|
||||||
entries, err := os.ReadDir(tmpDir)
|
entries, err := os.ReadDir(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading temp dir: %w", err)
|
return fmt.Errorf("reading temp dir: %w", err)
|
||||||
|
|||||||
@@ -11,20 +11,22 @@ import (
|
|||||||
"quay/internal/envconfig"
|
"quay/internal/envconfig"
|
||||||
"quay/internal/security"
|
"quay/internal/security"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UpdateSiteHandler struct {
|
type DeploySiteHandler struct {
|
||||||
EnvCfg *envconfig.EnvConfig
|
EnvCfg *envconfig.EnvConfig
|
||||||
SiteRepo repository.SiteRepository
|
SiteRepo repository.SiteRepository
|
||||||
|
DeploymentRepo repository.DeploymentRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUpdateSiteHandler(envCfg *envconfig.EnvConfig, siteRepo repository.SiteRepository) *UpdateSiteHandler {
|
func NewDeploySiteHandler(envCfg *envconfig.EnvConfig, siteRepo repository.SiteRepository, deploymentRepo repository.DeploymentRepository) *DeploySiteHandler {
|
||||||
return &UpdateSiteHandler{EnvCfg: envCfg, SiteRepo: siteRepo}
|
return &DeploySiteHandler{EnvCfg: envCfg, SiteRepo: siteRepo, DeploymentRepo: deploymentRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UpdateSiteHandler) PostDeploy(c fiber.Ctx) error {
|
func (h *DeploySiteHandler) PostDeploy(c fiber.Ctx) error {
|
||||||
siteId := c.Query("site")
|
siteId := c.Query("site")
|
||||||
if siteId == "" {
|
if siteId == "" {
|
||||||
return c.Status(400).JSON(models.APIError{
|
return c.Status(400).JSON(models.APIError{
|
||||||
@@ -40,7 +42,7 @@ func (h *UpdateSiteHandler) PostDeploy(c fiber.Ctx) error {
|
|||||||
Message: "Site not found",
|
Message: "Site not found",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
log.Println("Message getting site: ", err)
|
log.Println("Error getting site: ", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||||
Message: "Unexpected error while getting site",
|
Message: "Unexpected error while getting site",
|
||||||
})
|
})
|
||||||
@@ -54,6 +56,7 @@ func (h *UpdateSiteHandler) PostDeploy(c fiber.Ctx) error {
|
|||||||
|
|
||||||
deployToken := site.DeployToken
|
deployToken := site.DeployToken
|
||||||
if deployToken == "" {
|
if deployToken == "" {
|
||||||
|
log.Println("No deploy token configured for site " + siteId)
|
||||||
return c.Status(500).JSON(models.APIError{
|
return c.Status(500).JSON(models.APIError{
|
||||||
Message: "Deploy token not configured for this site",
|
Message: "Deploy token not configured for this site",
|
||||||
})
|
})
|
||||||
@@ -77,12 +80,26 @@ func (h *UpdateSiteHandler) PostDeploy(c fiber.Ctx) error {
|
|||||||
|
|
||||||
sitePath := filepath.Join(h.EnvCfg.StoragePath, site.ID)
|
sitePath := filepath.Join(h.EnvCfg.StoragePath, site.ID)
|
||||||
if _, err := filepath.Abs(sitePath); err != nil {
|
if _, err := filepath.Abs(sitePath); err != nil {
|
||||||
|
log.Println("Error getting absolute path of site: ", err)
|
||||||
return c.Status(500).JSON(models.APIError{
|
return c.Status(500).JSON(models.APIError{
|
||||||
Message: "Failed to resolve site path",
|
Message: "Failed to resolve site path",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
err = github.FetchAndDeployBranch(
|
deployId, err := h.DeploymentRepo.CreateDeployment(&models.Deployment{
|
||||||
|
SiteId: siteId,
|
||||||
|
Status: models.DeploymentStatusRunning,
|
||||||
|
StartTime: time.Now().UTC().Format(time.DateTime),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error creating deployment: ", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||||
|
Message: "Failed to create deployment",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := github.FetchAndDeployBranch(
|
||||||
site.Owner,
|
site.Owner,
|
||||||
site.Repository,
|
site.Repository,
|
||||||
site.Branch,
|
site.Branch,
|
||||||
@@ -90,11 +107,38 @@ func (h *UpdateSiteHandler) PostDeploy(c fiber.Ctx) error {
|
|||||||
sitePath,
|
sitePath,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if res != nil {
|
||||||
|
deployUpdateErr := h.DeploymentRepo.UpdateDeploymentGitInfo(deployId, res.CommitHash, res.CommitMessage)
|
||||||
|
if deployUpdateErr != nil {
|
||||||
|
log.Println("Error updating deployment git info: ", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Println("Error deploying site: ", err)
|
||||||
|
|
||||||
|
deployUpdateErr := h.DeploymentRepo.UpdateDeploymentStatus(deployId, models.DeploymentStatusFailed)
|
||||||
|
if deployUpdateErr != nil {
|
||||||
|
log.Println("Error updating deployment status to failed: ", err)
|
||||||
|
}
|
||||||
|
deployUpdateErr = h.DeploymentRepo.UpdateDeploymentFinishTime(deployId, time.Now().UTC())
|
||||||
|
if deployUpdateErr != nil {
|
||||||
|
log.Println("Error updating deployment finish time: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
return c.Status(500).JSON(models.APIError{
|
return c.Status(500).JSON(models.APIError{
|
||||||
Message: "Failed to deploy site: " + err.Error(),
|
Message: "Failed to deploy site: " + err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deployUpdateErr := h.DeploymentRepo.UpdateDeploymentStatus(deployId, models.DeploymentStatusSuccess)
|
||||||
|
if deployUpdateErr != nil {
|
||||||
|
log.Println("Error updating deployment status to success: ", err)
|
||||||
|
}
|
||||||
|
deployUpdateErr = h.DeploymentRepo.UpdateDeploymentFinishTime(deployId, time.Now().UTC())
|
||||||
|
if deployUpdateErr != nil {
|
||||||
|
log.Println("Error updating deployment finish time: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
return c.SendStatus(201)
|
return c.SendStatus(201)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"quay/app/models"
|
||||||
|
"quay/app/repository"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeploymentHandler struct {
|
||||||
|
Repo repository.DeploymentRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeploymentHandler(repo repository.DeploymentRepository) *DeploymentHandler {
|
||||||
|
return &DeploymentHandler{Repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeployment godoc
|
||||||
|
// @Summary Get deployment by ID
|
||||||
|
// @Description Get a single deployment by its ID
|
||||||
|
// @Tags Deployments
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Deployment ID"
|
||||||
|
// @Success 200 {object} models.Deployment
|
||||||
|
// @Failure 404 {object} models.APIError
|
||||||
|
// @Failure 500 {object} models.APIError
|
||||||
|
// @Router /deployments/{id} [get]
|
||||||
|
func (h *DeploymentHandler) GetDeployment(c fiber.Ctx) error {
|
||||||
|
id := c.Params("id")
|
||||||
|
|
||||||
|
deployment, err := h.Repo.GetDeploymentByID(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||||
|
Message: "Deployment not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
log.Println("Error getting deployment by id: ", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||||
|
Message: "Unexpected error while getting deployment by id",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).JSON(&deployment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeploymentsBySite godoc
|
||||||
|
// @Summary Get deployments for a site
|
||||||
|
// @Description Get a list of deployments for a specific site
|
||||||
|
// @Tags Deployments
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param siteId path string true "Site ID"
|
||||||
|
// @Param limit query int false "Maximum number of deployments to return" default(100)
|
||||||
|
// @Success 200 {object} models.GetDeploymentsBySiteResponse
|
||||||
|
// @Failure 500 {object} models.APIError
|
||||||
|
// @Router /sites/{siteId}/deployments [get]
|
||||||
|
func (h *DeploymentHandler) GetDeploymentsBySite(c fiber.Ctx) error {
|
||||||
|
siteId := c.Params("id")
|
||||||
|
limit, err := strconv.Atoi(c.Query("limit", "100"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||||
|
Message: "Invalid limit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deployments, err := h.Repo.GetDeploymentsForSite(siteId, limit)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return c.JSON(models.GetDeploymentsBySiteResponse{
|
||||||
|
Deployments: []models.Deployment{},
|
||||||
|
Total: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Error getting deployments: ", err)
|
||||||
|
return c.JSON(models.APIError{
|
||||||
|
Message: "Unexpected error while getting deployments: ",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(models.GetDeploymentsBySiteResponse{
|
||||||
|
Deployments: deployments,
|
||||||
|
Total: len(deployments),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type DeploymentStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeploymentStatusPending DeploymentStatus = "pending"
|
||||||
|
DeploymentStatusRunning DeploymentStatus = "running"
|
||||||
|
DeploymentStatusSuccess DeploymentStatus = "success"
|
||||||
|
DeploymentStatusFailed DeploymentStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s DeploymentStatus) IsValid() bool {
|
||||||
|
switch s {
|
||||||
|
case DeploymentStatusPending, DeploymentStatusRunning,
|
||||||
|
DeploymentStatusSuccess, DeploymentStatusFailed:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type Deployment struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
SiteId string `json:"site_id"`
|
||||||
|
CommitHash string `json:"commit_hash"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Status DeploymentStatus `json:"status"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
FinishTime string `json:"finish_time"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetDeploymentsBySiteResponse struct {
|
||||||
|
Deployments []Deployment `json:"deployments"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"quay/app/models"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeploymentRepository interface {
|
||||||
|
GetDeploymentByID(id string) (*models.Deployment, error)
|
||||||
|
GetDeploymentsForSite(deploymentId string, limit int) ([]models.Deployment, error)
|
||||||
|
CreateDeployment(deployment *models.Deployment) (string, error)
|
||||||
|
UpdateDeployment(deployment *models.Deployment) error
|
||||||
|
UpdateDeploymentStatus(deploymentId string, status models.DeploymentStatus) error
|
||||||
|
UpdateDeploymentFinishTime(deploymentId string, finishTime time.Time) error
|
||||||
|
UpdateDeploymentGitInfo(deploymentId, commitHash, message string) error
|
||||||
|
DeleteDeployment(id string) error
|
||||||
|
}
|
||||||
@@ -17,15 +17,18 @@ import (
|
|||||||
|
|
||||||
func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, db *sql.DB) {
|
func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, db *sql.DB) {
|
||||||
siteRepository := cachedrepo.NewCachedSiteRepository(database.NewSQLiteSiteRepository(db))
|
siteRepository := cachedrepo.NewCachedSiteRepository(database.NewSQLiteSiteRepository(db))
|
||||||
|
deploymentRepository := database.NewSQLiteDeploymentRepository(db)
|
||||||
|
|
||||||
updateSiteHandler := handlers.NewUpdateSiteHandler(envCfg, siteRepository)
|
deploySiteHandler := handlers.NewDeploySiteHandler(envCfg, siteRepository, deploymentRepository)
|
||||||
siteHandler := handlers.NewSiteHandler(siteRepository)
|
siteHandler := handlers.NewSiteHandler(siteRepository)
|
||||||
|
deploymentsHandler := handlers.NewDeploymentHandler(deploymentRepository)
|
||||||
|
|
||||||
api := app.Group("/api/v1", middleware.APIHostGuard(envCfg.DashboardHost))
|
api := app.Group("/api/v1", middleware.APIHostGuard(envCfg.DashboardHost))
|
||||||
api.Get("/health", handlers.HealthCheck)
|
api.Get("/health", handlers.HealthCheck)
|
||||||
|
|
||||||
api.Post("/deploy", updateSiteHandler.PostDeploy)
|
api.Post("/deploy", deploySiteHandler.PostDeploy)
|
||||||
|
|
||||||
|
// Sites
|
||||||
api.Get("/sites", siteHandler.GetSites)
|
api.Get("/sites", siteHandler.GetSites)
|
||||||
api.Get("/sites/:id", siteHandler.GetSite)
|
api.Get("/sites/:id", siteHandler.GetSite)
|
||||||
api.Post("/sites", siteHandler.PostSite)
|
api.Post("/sites", siteHandler.PostSite)
|
||||||
@@ -33,24 +36,31 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d
|
|||||||
api.Delete("/sites/:id", siteHandler.DeleteSite)
|
api.Delete("/sites/:id", siteHandler.DeleteSite)
|
||||||
api.Patch("/sites/:id/enabled", siteHandler.ToggleEnabled)
|
api.Patch("/sites/:id/enabled", siteHandler.ToggleEnabled)
|
||||||
|
|
||||||
|
// Forward rules
|
||||||
api.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules)
|
api.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules)
|
||||||
api.Post("/sites/:id/forward-rules", siteHandler.PostForwardRule)
|
api.Post("/sites/:id/forward-rules", siteHandler.PostForwardRule)
|
||||||
api.Get("/forward-rules/:id", siteHandler.GetForwardRule)
|
api.Get("/forward-rules/:id", siteHandler.GetForwardRule)
|
||||||
api.Put("/forward-rules/:id", siteHandler.PutForwardRule)
|
api.Put("/forward-rules/:id", siteHandler.PutForwardRule)
|
||||||
api.Delete("/forward-rules/:id", siteHandler.DeleteForwardRule)
|
api.Delete("/forward-rules/:id", siteHandler.DeleteForwardRule)
|
||||||
|
|
||||||
|
// Custom headers (header rules)
|
||||||
api.Get("/sites/:id/custom-headers", siteHandler.GetSiteCustomHeaders)
|
api.Get("/sites/:id/custom-headers", siteHandler.GetSiteCustomHeaders)
|
||||||
api.Post("/sites/:id/custom-headers", siteHandler.PostCustomHeaders)
|
api.Post("/sites/:id/custom-headers", siteHandler.PostCustomHeaders)
|
||||||
api.Get("/custom-headers/:id", siteHandler.GetCustomHeaders)
|
api.Get("/custom-headers/:id", siteHandler.GetCustomHeaders)
|
||||||
api.Put("/custom-headers/:id", siteHandler.PutCustomHeaders)
|
api.Put("/custom-headers/:id", siteHandler.PutCustomHeaders)
|
||||||
api.Delete("/custom-headers/:id", siteHandler.DeleteCustomHeaders)
|
api.Delete("/custom-headers/:id", siteHandler.DeleteCustomHeaders)
|
||||||
|
|
||||||
|
// Headers
|
||||||
api.Get("/custom-headers/:id/headers", siteHandler.GetCustomHeaderHeaders)
|
api.Get("/custom-headers/:id/headers", siteHandler.GetCustomHeaderHeaders)
|
||||||
api.Post("/custom-headers/:id/headers", siteHandler.PostHeader)
|
api.Post("/custom-headers/:id/headers", siteHandler.PostHeader)
|
||||||
api.Get("/headers/:id", siteHandler.GetHeader)
|
api.Get("/headers/:id", siteHandler.GetHeader)
|
||||||
api.Put("/headers/:id", siteHandler.PutHeader)
|
api.Put("/headers/:id", siteHandler.PutHeader)
|
||||||
api.Delete("/headers/:id", siteHandler.DeleteHeader)
|
api.Delete("/headers/:id", siteHandler.DeleteHeader)
|
||||||
|
|
||||||
|
// Deployments
|
||||||
|
api.Get("/deployments/:id", deploymentsHandler.GetDeployment)
|
||||||
|
api.Get("/sites/:id/deployments", deploymentsHandler.GetDeploymentsBySite)
|
||||||
|
|
||||||
api.Use(func(c fiber.Ctx) error {
|
api.Use(func(c fiber.Ctx) error {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||||
Message: "Endpoint not found",
|
Message: "Endpoint not found",
|
||||||
|
|||||||
@@ -250,6 +250,50 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/deployments/{id}": {
|
||||||
|
"get": {
|
||||||
|
"description": "Get a single deployment by its ID",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Deployments"
|
||||||
|
],
|
||||||
|
"summary": "Get deployment by ID",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Deployment ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.Deployment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.APIError"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/forward-rules/{id}": {
|
"/forward-rules/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a single forward rule by ID",
|
"description": "Returns a single forward rule by ID",
|
||||||
@@ -832,6 +876,50 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/sites/{id}/enabled": {
|
||||||
|
"patch": {
|
||||||
|
"description": "Enable or disable a site by its ID",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Sites"
|
||||||
|
],
|
||||||
|
"summary": "Toggle site enabled status",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Site ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.ToggleEnabledResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.APIError"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/sites/{id}/forward-rules": {
|
"/sites/{id}/forward-rules": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns all forward rules associated with the given site",
|
"description": "Returns all forward rules associated with the given site",
|
||||||
@@ -932,6 +1020,51 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/sites/{siteId}/deployments": {
|
||||||
|
"get": {
|
||||||
|
"description": "Get a list of deployments for a specific site",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Deployments"
|
||||||
|
],
|
||||||
|
"summary": "Get deployments for a site",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Site ID",
|
||||||
|
"name": "siteId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"default": 100,
|
||||||
|
"description": "Maximum number of deployments to return",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.GetDeploymentsBySiteResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
@@ -966,11 +1099,58 @@ const docTemplate = `{
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"regex": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"models.Deployment": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"commit_hash": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"finish_time": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"site_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"start_time": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/definitions/models.DeploymentStatus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models.DeploymentStatus": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"success",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"x-enum-varnames": [
|
||||||
|
"DeploymentStatusPending",
|
||||||
|
"DeploymentStatusRunning",
|
||||||
|
"DeploymentStatusSuccess",
|
||||||
|
"DeploymentStatusFailed"
|
||||||
|
]
|
||||||
|
},
|
||||||
"models.ForwardRule": {
|
"models.ForwardRule": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1005,6 +1185,20 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"models.GetDeploymentsBySiteResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"deployments": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/models.Deployment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"models.Header": {
|
"models.Header": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1025,6 +1219,9 @@ const docTemplate = `{
|
|||||||
"branch": {
|
"branch": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"custom_headers": {
|
"custom_headers": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -1052,6 +1249,12 @@ const docTemplate = `{
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"index_file": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"not_found_file": {
|
"not_found_file": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1063,6 +1266,17 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
"spa": {
|
"spa": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"trailing_slash": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models.ToggleEnabledResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/deployments/{id}": {
|
||||||
|
"get": {
|
||||||
|
"description": "Get a single deployment by its ID",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Deployments"
|
||||||
|
],
|
||||||
|
"summary": "Get deployment by ID",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Deployment ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.Deployment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.APIError"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/forward-rules/{id}": {
|
"/forward-rules/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns a single forward rule by ID",
|
"description": "Returns a single forward rule by ID",
|
||||||
@@ -826,6 +870,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/sites/{id}/enabled": {
|
||||||
|
"patch": {
|
||||||
|
"description": "Enable or disable a site by its ID",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Sites"
|
||||||
|
],
|
||||||
|
"summary": "Toggle site enabled status",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Site ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.ToggleEnabledResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.APIError"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/sites/{id}/forward-rules": {
|
"/sites/{id}/forward-rules": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns all forward rules associated with the given site",
|
"description": "Returns all forward rules associated with the given site",
|
||||||
@@ -926,6 +1014,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/sites/{siteId}/deployments": {
|
||||||
|
"get": {
|
||||||
|
"description": "Get a list of deployments for a specific site",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Deployments"
|
||||||
|
],
|
||||||
|
"summary": "Get deployments for a site",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Site ID",
|
||||||
|
"name": "siteId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"default": 100,
|
||||||
|
"description": "Maximum number of deployments to return",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.GetDeploymentsBySiteResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.APIError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
@@ -960,11 +1093,58 @@
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"regex": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"models.Deployment": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"commit_hash": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"finish_time": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"site_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"start_time": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/definitions/models.DeploymentStatus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models.DeploymentStatus": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"success",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"x-enum-varnames": [
|
||||||
|
"DeploymentStatusPending",
|
||||||
|
"DeploymentStatusRunning",
|
||||||
|
"DeploymentStatusSuccess",
|
||||||
|
"DeploymentStatusFailed"
|
||||||
|
]
|
||||||
|
},
|
||||||
"models.ForwardRule": {
|
"models.ForwardRule": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -999,6 +1179,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"models.GetDeploymentsBySiteResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"deployments": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/models.Deployment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"models.Header": {
|
"models.Header": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1019,6 +1213,9 @@
|
|||||||
"branch": {
|
"branch": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"custom_headers": {
|
"custom_headers": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -1046,6 +1243,12 @@
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"index_file": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"not_found_file": {
|
"not_found_file": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1057,6 +1260,17 @@
|
|||||||
},
|
},
|
||||||
"spa": {
|
"spa": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"trailing_slash": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models.ToggleEnabledResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,42 @@ definitions:
|
|||||||
type: array
|
type: array
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
|
regex:
|
||||||
|
type: boolean
|
||||||
source:
|
source:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
models.Deployment:
|
||||||
|
properties:
|
||||||
|
commit_hash:
|
||||||
|
type: string
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
finish_time:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
site_id:
|
||||||
|
type: string
|
||||||
|
start_time:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
$ref: '#/definitions/models.DeploymentStatus'
|
||||||
|
type: object
|
||||||
|
models.DeploymentStatus:
|
||||||
|
enum:
|
||||||
|
- pending
|
||||||
|
- running
|
||||||
|
- success
|
||||||
|
- failed
|
||||||
|
type: string
|
||||||
|
x-enum-varnames:
|
||||||
|
- DeploymentStatusPending
|
||||||
|
- DeploymentStatusRunning
|
||||||
|
- DeploymentStatusSuccess
|
||||||
|
- DeploymentStatusFailed
|
||||||
models.ForwardRule:
|
models.ForwardRule:
|
||||||
properties:
|
properties:
|
||||||
destination:
|
destination:
|
||||||
@@ -45,6 +78,15 @@ definitions:
|
|||||||
total:
|
total:
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
models.GetDeploymentsBySiteResponse:
|
||||||
|
properties:
|
||||||
|
deployments:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/models.Deployment'
|
||||||
|
type: array
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
models.Header:
|
models.Header:
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
@@ -58,6 +100,8 @@ definitions:
|
|||||||
properties:
|
properties:
|
||||||
branch:
|
branch:
|
||||||
type: string
|
type: string
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
custom_headers:
|
custom_headers:
|
||||||
items:
|
items:
|
||||||
$ref: '#/definitions/models.CustomHeaders'
|
$ref: '#/definitions/models.CustomHeaders'
|
||||||
@@ -76,6 +120,10 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
|
index_file:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
not_found_file:
|
not_found_file:
|
||||||
type: string
|
type: string
|
||||||
owner:
|
owner:
|
||||||
@@ -84,6 +132,13 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
spa:
|
spa:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
trailing_slash:
|
||||||
|
type: boolean
|
||||||
|
type: object
|
||||||
|
models.ToggleEnabledResponse:
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
type: object
|
type: object
|
||||||
host: localhost:4321
|
host: localhost:4321
|
||||||
info:
|
info:
|
||||||
@@ -249,6 +304,35 @@ paths:
|
|||||||
summary: Create a header
|
summary: Create a header
|
||||||
tags:
|
tags:
|
||||||
- Headers
|
- Headers
|
||||||
|
/deployments/{id}:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get a single deployment by its ID
|
||||||
|
parameters:
|
||||||
|
- description: Deployment ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/models.Deployment'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/models.APIError'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/models.APIError'
|
||||||
|
summary: Get deployment by ID
|
||||||
|
tags:
|
||||||
|
- Deployments
|
||||||
/forward-rules/{id}:
|
/forward-rules/{id}:
|
||||||
delete:
|
delete:
|
||||||
description: Deletes a forward rule by ID
|
description: Deletes a forward rule by ID
|
||||||
@@ -635,6 +719,35 @@ paths:
|
|||||||
summary: Create a custom header group
|
summary: Create a custom header group
|
||||||
tags:
|
tags:
|
||||||
- Custom-Headers
|
- Custom-Headers
|
||||||
|
/sites/{id}/enabled:
|
||||||
|
patch:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Enable or disable a site by its ID
|
||||||
|
parameters:
|
||||||
|
- description: Site ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/models.ToggleEnabledResponse'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/models.APIError'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/models.APIError'
|
||||||
|
summary: Toggle site enabled status
|
||||||
|
tags:
|
||||||
|
- Sites
|
||||||
/sites/{id}/forward-rules:
|
/sites/{id}/forward-rules:
|
||||||
get:
|
get:
|
||||||
description: Returns all forward rules associated with the given site
|
description: Returns all forward rules associated with the given site
|
||||||
@@ -702,6 +815,36 @@ paths:
|
|||||||
summary: Create a forward rule
|
summary: Create a forward rule
|
||||||
tags:
|
tags:
|
||||||
- Forward-Rules
|
- Forward-Rules
|
||||||
|
/sites/{siteId}/deployments:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get a list of deployments for a specific site
|
||||||
|
parameters:
|
||||||
|
- description: Site ID
|
||||||
|
in: path
|
||||||
|
name: siteId
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- default: 100
|
||||||
|
description: Maximum number of deployments to return
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/models.GetDeploymentsBySiteResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/models.APIError'
|
||||||
|
summary: Get deployments for a site
|
||||||
|
tags:
|
||||||
|
- Deployments
|
||||||
swagger: "2.0"
|
swagger: "2.0"
|
||||||
tags:
|
tags:
|
||||||
- description: Manage sites
|
- description: Manage sites
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"quay/app/models"
|
||||||
|
"quay/app/repository"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SQLiteDeploymentRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSQLiteDeploymentRepository(db *sql.DB) *SQLiteDeploymentRepository {
|
||||||
|
return &SQLiteDeploymentRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ repository.DeploymentRepository = (*SQLiteDeploymentRepository)(nil)
|
||||||
|
|
||||||
|
func (r *SQLiteDeploymentRepository) GetDeploymentByID(id string) (*models.Deployment, error) {
|
||||||
|
row := r.db.QueryRow(`
|
||||||
|
SELECT id, site_id, commit_hash, message, status, start_time, finish_time, created_at
|
||||||
|
FROM deployments WHERE id = ?`, id)
|
||||||
|
|
||||||
|
d, err := scanDeployment(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get deployment: %w", err)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteDeploymentRepository) GetDeploymentsForSite(siteId string, limit int) ([]models.Deployment, error) {
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, site_id, commit_hash, message, status, start_time, finish_time, created_at
|
||||||
|
FROM deployments WHERE site_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?`, siteId, limit)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get deployments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var deployments []models.Deployment
|
||||||
|
for rows.Next() {
|
||||||
|
d, err := scanDeployment(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan deployment: %w", err)
|
||||||
|
}
|
||||||
|
deployments = append(deployments, *d)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate deployments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return deployments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteDeploymentRepository) CreateDeployment(d *models.Deployment) (string, error) {
|
||||||
|
d.Id = uuid.NewString()
|
||||||
|
if d.Status == "" || !d.Status.IsValid() {
|
||||||
|
d.Status = models.DeploymentStatusPending
|
||||||
|
}
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
INSERT INTO deployments (id, site_id, commit_hash, message, status, start_time, finish_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
d.Id, d.SiteId, d.CommitHash, d.Message, d.Status, d.StartTime, d.FinishTime,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create deployment: %w", err)
|
||||||
|
}
|
||||||
|
return d.Id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteDeploymentRepository) UpdateDeployment(d *models.Deployment) error {
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
UPDATE deployments SET site_id=?, commit_hash=?, message=?, status=?, start_time=?, finish_time=?
|
||||||
|
WHERE id=?`,
|
||||||
|
d.SiteId, d.CommitHash, d.Message, d.Status, d.StartTime, d.FinishTime, d.Id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update deployment: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteDeploymentRepository) UpdateDeploymentStatus(deploymentId string, status models.DeploymentStatus) error {
|
||||||
|
if !status.IsValid() {
|
||||||
|
return fmt.Errorf("invalid deployment status")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
UPDATE deployments SET status=? WHERE id=?`,
|
||||||
|
status, deploymentId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update deployment status: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteDeploymentRepository) UpdateDeploymentFinishTime(deploymentId string, finishTime time.Time) error {
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
UPDATE deployments SET finish_time=? WHERE id=?`,
|
||||||
|
finishTime.Format(time.DateTime), deploymentId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update deployment finish time: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteDeploymentRepository) UpdateDeploymentGitInfo(deploymentId, commitHash, message string) error {
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
UPDATE deployments SET commit_hash=?, message=? WHERE id=?`,
|
||||||
|
commitHash, message, deploymentId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update deployment git info: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SQLiteDeploymentRepository) DeleteDeployment(id string) error {
|
||||||
|
_, err := r.db.Exec(`DELETE FROM deployments WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete deployment: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDeployment(s scanner) (*models.Deployment, error) {
|
||||||
|
var d models.Deployment
|
||||||
|
err := s.Scan(
|
||||||
|
&d.Id, &d.SiteId, &d.CommitHash, &d.Message,
|
||||||
|
&d.Status, &d.StartTime, &d.FinishTime, &d.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
@@ -49,6 +49,17 @@ CREATE TABLE IF NOT EXISTS headers (
|
|||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
FOREIGN KEY (custom_header_id) REFERENCES custom_headers(id) ON DELETE CASCADE
|
FOREIGN KEY (custom_header_id) REFERENCES custom_headers(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS deployments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
site_id TEXT NOT NULL,
|
||||||
|
commit_hash TEXT,
|
||||||
|
message TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
start_time TIMESTAMP NOT NULL DEFAULT 0,
|
||||||
|
finish_time TIMESTAMP NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
`)
|
`)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { makeApiUrl } from '.';
|
||||||
|
import type { Deployment } from './types/deployments';
|
||||||
|
|
||||||
|
export const getDeploymentsForSite = async (
|
||||||
|
siteId: string,
|
||||||
|
limit: number = 100
|
||||||
|
): Promise<Deployment[]> => {
|
||||||
|
const response = await fetch(makeApiUrl(`/sites/${siteId}/deployments?limit=${limit}`), {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch deployments');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.deployments;
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type DeploymentStatus = 'pending' | 'running' | 'success' | 'failed';
|
||||||
|
|
||||||
|
export interface Deployment {
|
||||||
|
id: string;
|
||||||
|
site_id: string;
|
||||||
|
commit_hash: string;
|
||||||
|
message: string;
|
||||||
|
status: DeploymentStatus;
|
||||||
|
start_time: string;
|
||||||
|
finish_time: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDeploymentsBySiteResponse {
|
||||||
|
deployments: Deployment[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { CheckCircle2, Clock, Loader2, XCircle } from 'lucide-react';
|
||||||
|
import type { DeploymentStatus } from '../api/types/deployments';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
const StatusIcon = ({ status }: { status: DeploymentStatus }) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircle2 className="w-4 h-4 text-success" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="w-4 h-4 text-destructive" />;
|
||||||
|
case 'running':
|
||||||
|
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="w-4 h-4 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeploymentStatusBadge = ({ status }: { status: DeploymentStatus }) => {
|
||||||
|
const statusText = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIcon status={status} />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm capitalize hidden sm:inline',
|
||||||
|
status === 'success' && 'text-success',
|
||||||
|
status === 'failed' && 'text-destructive',
|
||||||
|
status === 'running' && 'text-primary',
|
||||||
|
status === 'pending' && 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeploymentStatusBadge;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getDeploymentsForSite } from '../../api/deployments.api';
|
||||||
|
|
||||||
|
export function useDeploymentsForSite(siteId: string, limit: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['deployments', siteId, limit],
|
||||||
|
queryFn: async () => getDeploymentsForSite(siteId, limit),
|
||||||
|
enabled: !!siteId,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import { useSites } from '../../hooks/api/useSites';
|
|||||||
import type { Site } from '../../api/types/site';
|
import type { Site } from '../../api/types/site';
|
||||||
import { Skeleton } from '../../components/ui/skeleton';
|
import { Skeleton } from '../../components/ui/skeleton';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
import { useDeploymentsForSite } from '../../hooks/api/useDeploymentsForSite';
|
||||||
|
import { formatDateRelativeOrAbsolute } from '../../utils/dateTime';
|
||||||
|
|
||||||
const SiteAvatar = ({ name, enabled }: { name: string; enabled: boolean }) => (
|
const SiteAvatar = ({ name, enabled }: { name: string; enabled: boolean }) => (
|
||||||
<div
|
<div
|
||||||
@@ -28,6 +30,8 @@ const SiteAvatar = ({ name, enabled }: { name: string; enabled: boolean }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const SiteRow = ({ site }: { site: Site }) => {
|
const SiteRow = ({ site }: { site: Site }) => {
|
||||||
|
const { data: deployments } = useDeploymentsForSite(site.id, 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/sites/${site.id}`}>
|
<Link to={`/sites/${site.id}`}>
|
||||||
<Card className="flex flex-row items-center justify-between px-4 py-3 cursor-pointer hover:border-border transition-colors">
|
<Card className="flex flex-row items-center justify-between px-4 py-3 cursor-pointer hover:border-border transition-colors">
|
||||||
@@ -47,11 +51,14 @@ const SiteRow = ({ site }: { site: Site }) => {
|
|||||||
|
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-5">
|
||||||
<div className="text-right hidden sm:block">
|
<div className="text-right hidden sm:block">
|
||||||
{/* TODO: Replace with actual deployment time */}
|
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground mb-0.5">
|
<p className="text-[11px] uppercase tracking-wide text-muted-foreground mb-0.5">
|
||||||
Last deployed
|
Last deployed
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">1 hour ago</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{deployments && deployments.length > 0
|
||||||
|
? formatDateRelativeOrAbsolute(deployments[0].created_at)
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={site.enabled ? 'default' : 'secondary'} className="gap-1.5">
|
<Badge variant={site.enabled ? 'default' : 'secondary'} className="gap-1.5">
|
||||||
{site.enabled ? <Check /> : <X />}
|
{site.enabled ? <Check /> : <X />}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CheckCircle2, Clock, Loader2, RotateCcw, XCircle } from 'lucide-react';
|
import { RotateCcw } from 'lucide-react';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -15,24 +15,12 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '../../components/ui/table';
|
} from '../../components/ui/table';
|
||||||
import type { MockDeploymentStatus, MockSite } from './SiteOverview';
|
import { formatDateRelativeOrAbsolute, formatDuration } from '../../utils/dateTime';
|
||||||
import { formatDate } from '../../utils/formatDate';
|
import type { Deployment } from '../../api/types/deployments';
|
||||||
import { cn } from '../../lib/utils';
|
import DeploymentStatusBadge from '../../components/DeploymentStatusBadge';
|
||||||
|
import type { Site } from '../../api/types/site';
|
||||||
|
|
||||||
const StatusIcon = ({ status }: { status: MockDeploymentStatus }) => {
|
const DeploymentsTab = ({ deployments, site }: { deployments?: Deployment[]; site: Site }) => (
|
||||||
switch (status) {
|
|
||||||
case 'success':
|
|
||||||
return <CheckCircle2 className="w-4 h-4 text-success" />;
|
|
||||||
case 'failed':
|
|
||||||
return <XCircle className="w-4 h-4 text-destructive" />;
|
|
||||||
case 'building':
|
|
||||||
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
|
|
||||||
case 'queued':
|
|
||||||
return <Clock className="w-4 h-4 text-muted-foreground" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const DeploymentsTab = ({ site }: { site: MockSite }) => (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -55,33 +43,36 @@ const DeploymentsTab = ({ site }: { site: MockSite }) => (
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{site.deployments.map((d) => (
|
{deployments?.map((d) => {
|
||||||
<TableRow key={d.id}>
|
const githubCommitUrl = `https://github.com/${site.owner}/${site.repository}/commit/${d.commit_hash}`;
|
||||||
<TableCell>
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<TableRow key={d.id}>
|
||||||
<StatusIcon status={d.status} />
|
<TableCell>
|
||||||
<span
|
<DeploymentStatusBadge status={d.status} />
|
||||||
className={cn(
|
</TableCell>
|
||||||
'text-sm capitalize hidden sm:inline',
|
<TableCell className="font-mono text-xs">
|
||||||
d.status === 'success' && 'text-success',
|
<a
|
||||||
d.status === 'failed' && 'text-destructive',
|
href={githubCommitUrl}
|
||||||
d.status === 'building' && 'text-primary',
|
target="_blank"
|
||||||
d.status === 'queued' && 'text-muted-foreground'
|
rel="noopener noreferrer"
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{d.status}
|
{d.commit_hash.substring(0, 7)}
|
||||||
</span>
|
</a>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||||
<TableCell className="font-mono text-xs">{d.commit}</TableCell>
|
{d.message}
|
||||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
</TableCell>
|
||||||
{d.message}
|
<TableCell className="text-right text-muted-foreground">
|
||||||
</TableCell>
|
{d.start_time && d.finish_time
|
||||||
<TableCell className="text-right text-muted-foreground">
|
? formatDuration(d.start_time, d.finish_time, false)
|
||||||
{formatDate(d.timestamp)}
|
: '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
<TableCell className="text-right text-muted-foreground">
|
||||||
))}
|
{formatDateRelativeOrAbsolute(d.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -8,15 +8,24 @@ import {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
} from '../../components/ui/card';
|
} from '../../components/ui/card';
|
||||||
import { Separator } from '../../components/ui/separator';
|
import { Separator } from '../../components/ui/separator';
|
||||||
import { formatDate } from '../../utils/formatDate';
|
import { formatDateRelativeOrAbsolute, formatDuration } from '../../utils/dateTime';
|
||||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from '../../components/ui/table';
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '../../components/ui/table';
|
||||||
|
import type { Deployment } from '../../api/types/deployments';
|
||||||
|
import DeploymentStatusBadge from '../../components/DeploymentStatusBadge';
|
||||||
|
|
||||||
const repoUrl = (site: Site) => {
|
const repoUrl = (site: Site) => {
|
||||||
const host = site.git_server === 'github' ? 'github.com' : 'gitlab.com';
|
const host = site.git_server === 'github' ? 'github.com' : 'gitlab.com';
|
||||||
return `https://${host}/${site.owner}/${site.repository}`;
|
return `https://${host}/${site.owner}/${site.repository}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const OverviewTab = ({ site }: { site: Site }) => (
|
const OverviewTab = ({ site, deployments }: { site: Site; deployments?: Deployment[] }) => (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -79,7 +88,11 @@ const OverviewTab = ({ site }: { site: Site }) => (
|
|||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Last Deployed</span>
|
<span className="text-muted-foreground">Last Deployed</span>
|
||||||
<span>{formatDate('2024-01-01T12:00:00Z')}</span>
|
<span>
|
||||||
|
{deployments
|
||||||
|
? formatDateRelativeOrAbsolute(deployments[0]?.created_at)
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -90,32 +103,51 @@ const OverviewTab = ({ site }: { site: Site }) => (
|
|||||||
<CardDescription>Last 3 deployments for this site.</CardDescription>
|
<CardDescription>Last 3 deployments for this site.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
{deployments && deployments.length > 0 && (
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableRow>
|
<TableHeader>
|
||||||
<TableHead>Status</TableHead>
|
<TableRow>
|
||||||
<TableHead>Commit</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="hidden sm:table-cell">Message</TableHead>
|
<TableHead>Commit</TableHead>
|
||||||
<TableHead className="text-right">Time</TableHead>
|
<TableHead className="hidden sm:table-cell">Message</TableHead>
|
||||||
</TableRow>
|
<TableHead className="text-right">Duration</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="text-right">Time</TableHead>
|
||||||
<TableBody>
|
|
||||||
{/* {site.deployments.slice(0, 3).map((d) => (
|
|
||||||
<TableRow key={d.id}>
|
|
||||||
<TableCell>
|
|
||||||
<StatusIcon status={d.status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">{d.commit}</TableCell>
|
|
||||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
|
||||||
{d.message}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">
|
|
||||||
{formatDate(d.timestamp)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))} */}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{deployments.slice(0, 3).map((d) => {
|
||||||
|
const githubCommitUrl = `https://github.com/${site.owner}/${site.repository}/commit/${d.commit_hash}`;
|
||||||
|
return (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell>
|
||||||
|
<DeploymentStatusBadge status={d.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
<a
|
||||||
|
href={githubCommitUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{d.commit_hash.substring(0, 7)}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||||
|
{d.message}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground">
|
||||||
|
{d.start_time && d.finish_time
|
||||||
|
? formatDuration(d.start_time, d.finish_time, false)
|
||||||
|
: '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground">
|
||||||
|
{formatDateRelativeOrAbsolute(d.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,73 +27,7 @@ import {
|
|||||||
EmptyMedia,
|
EmptyMedia,
|
||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from '../../components/ui/empty';
|
} from '../../components/ui/empty';
|
||||||
|
import { useDeploymentsForSite } from '../../hooks/api/useDeploymentsForSite';
|
||||||
export type MockDeploymentStatus = 'success' | 'failed' | 'building' | 'queued';
|
|
||||||
|
|
||||||
export interface MockDeployment {
|
|
||||||
id: string;
|
|
||||||
commit: string;
|
|
||||||
message: string;
|
|
||||||
status: MockDeploymentStatus;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MockSite {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
gitServer: 'github' | 'gitlab';
|
|
||||||
owner: string;
|
|
||||||
repository: string;
|
|
||||||
branch: string;
|
|
||||||
domain: string;
|
|
||||||
spa: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
lastDeployed: string;
|
|
||||||
deployments: MockDeployment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_SITE: MockSite = {
|
|
||||||
id: '1',
|
|
||||||
name: 'My Portfolio',
|
|
||||||
gitServer: 'github',
|
|
||||||
owner: 'janedoe',
|
|
||||||
repository: 'portfolio',
|
|
||||||
branch: 'gh-pages',
|
|
||||||
domain: 'janedoe.dev',
|
|
||||||
spa: true,
|
|
||||||
enabled: true,
|
|
||||||
lastDeployed: '2026-04-06T10:32:00Z',
|
|
||||||
deployments: [
|
|
||||||
{
|
|
||||||
id: 'd1',
|
|
||||||
commit: 'a3f8c21',
|
|
||||||
message: 'Update hero section copy',
|
|
||||||
status: 'success',
|
|
||||||
timestamp: '2026-04-06T10:32:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'd2',
|
|
||||||
commit: 'b7e1d44',
|
|
||||||
message: 'Add project cards component',
|
|
||||||
status: 'success',
|
|
||||||
timestamp: '2026-04-05T16:18:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'd3',
|
|
||||||
commit: 'c92fa08',
|
|
||||||
message: 'Fix broken image paths',
|
|
||||||
status: 'failed',
|
|
||||||
timestamp: '2026-04-05T14:05:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'd4',
|
|
||||||
commit: 'de610bb',
|
|
||||||
message: 'Initial commit',
|
|
||||||
status: 'success',
|
|
||||||
timestamp: '2026-04-04T09:00:00Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const VALID_TABS = [
|
const VALID_TABS = [
|
||||||
'overview',
|
'overview',
|
||||||
@@ -163,8 +97,7 @@ const SiteOverview = () => {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { data: site, isLoading: isLoadingSite, error: siteError } = useSite(id!);
|
const { data: site, isLoading: isLoadingSite, error: siteError } = useSite(id!);
|
||||||
const toggleSiteEnabled = useToggleSiteEnabled(id!);
|
const toggleSiteEnabled = useToggleSiteEnabled(id!);
|
||||||
|
const { data: deployments } = useDeploymentsForSite(id!, 100);
|
||||||
const mockSite = MOCK_SITE;
|
|
||||||
|
|
||||||
const tabParam = searchParams.get('tab');
|
const tabParam = searchParams.get('tab');
|
||||||
const activeTab: TabValue =
|
const activeTab: TabValue =
|
||||||
@@ -266,11 +199,11 @@ const SiteOverview = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="overview">
|
<TabsContent value="overview">
|
||||||
<OverviewTab site={site} />
|
<OverviewTab site={site} deployments={deployments} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="deployments">
|
<TabsContent value="deployments">
|
||||||
<DeploymentsTab site={mockSite} />
|
<DeploymentsTab site={site} deployments={deployments} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="settings">
|
<TabsContent value="settings">
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export const formatDate = (iso: string) => {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatRelativeDate = (iso: string) => {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const diffSeconds = Math.floor(diffMs / 1000);
|
||||||
|
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffSeconds < 60) return `${diffSeconds} seconds ago`;
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes} minutes ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours} hours ago`;
|
||||||
|
return `${diffDays} days ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDuration = (startIso: string, endIso: string, full: boolean = false) => {
|
||||||
|
if (!startIso || !endIso) return '—';
|
||||||
|
const start = new Date(startIso);
|
||||||
|
const end = new Date(endIso);
|
||||||
|
console.log('Calculating duration between', start, 'and', end);
|
||||||
|
const diffMs = end.getTime() - start.getTime();
|
||||||
|
const diffSeconds = Math.floor(diffMs / 1000);
|
||||||
|
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
|
|
||||||
|
if (diffSeconds < 60) return `${diffSeconds}${full ? ' seconds' : 's'}`;
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes}${full ? ' minutes' : 'm'}`;
|
||||||
|
return `${diffHours}${full ? ' hours' : 'h'}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateRelativeOrAbsolute = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays < 7) {
|
||||||
|
return formatRelativeDate(iso);
|
||||||
|
} else {
|
||||||
|
return formatDate(iso);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export const formatDate = (iso: string) => {
|
|
||||||
if (!iso) return '—';
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user