From 1978a31cbf2ccc3fad025a71287d4fb8673c1349 Mon Sep 17 00:00:00 2001 From: KartoffelChips <104089082+KartoffelChipss@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:04:50 +0200 Subject: [PATCH] Added deployments overview --- backend/app/github/github.go | 65 +++++- backend/app/handlers/deploy.go | 60 ++++- backend/app/handlers/deployment.go | 91 ++++++++ backend/app/models/deployment.go | 35 +++ .../app/repository/deployment_repository.go | 17 ++ backend/app/routes/routes.go | 14 +- backend/docs/docs.go | 214 ++++++++++++++++++ backend/docs/swagger.json | 214 ++++++++++++++++++ backend/docs/swagger.yaml | 143 ++++++++++++ .../internal/database/deployment_sqlite.go | 149 ++++++++++++ backend/internal/database/init_sqlite.go | 11 + frontend/src/api/deployments.api.ts | 18 ++ frontend/src/api/types/deployments.ts | 17 ++ .../src/components/DeploymentStatusBadge.tsx | 39 ++++ .../src/hooks/api/useDeploymentsForSite.ts | 10 + frontend/src/pages/Main/Main.tsx | 11 +- .../src/pages/SiteOverview/DeploymentsTab.tsx | 79 +++---- .../src/pages/SiteOverview/OverviewTab.tsx | 90 +++++--- .../src/pages/SiteOverview/SiteOverview.tsx | 75 +----- frontend/src/utils/dateTime.ts | 55 +++++ frontend/src/utils/formatDate.ts | 11 - 21 files changed, 1249 insertions(+), 169 deletions(-) create mode 100644 backend/app/handlers/deployment.go create mode 100644 backend/app/models/deployment.go create mode 100644 backend/app/repository/deployment_repository.go create mode 100644 backend/internal/database/deployment_sqlite.go create mode 100644 frontend/src/api/deployments.api.ts create mode 100644 frontend/src/api/types/deployments.ts create mode 100644 frontend/src/components/DeploymentStatusBadge.tsx create mode 100644 frontend/src/hooks/api/useDeploymentsForSite.ts create mode 100644 frontend/src/utils/dateTime.ts delete mode 100644 frontend/src/utils/formatDate.ts diff --git a/backend/app/github/github.go b/backend/app/github/github.go index 2bb90b5..7896e87 100644 --- a/backend/app/github/github.go +++ b/backend/app/github/github.go @@ -2,6 +2,7 @@ package github import ( "archive/zip" + "encoding/json" "fmt" "io" "io/fs" @@ -11,7 +12,67 @@ import ( "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( "https://api.github.com/repos/%s/%s/zipball/%s", repoOwner, repoName, branch, @@ -59,7 +120,7 @@ func FetchAndDeployBranch(repoOwner, repoName, branch, pat, destDir string) erro 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) if err != nil { return fmt.Errorf("reading temp dir: %w", err) diff --git a/backend/app/handlers/deploy.go b/backend/app/handlers/deploy.go index 4e2e119..c26c81e 100644 --- a/backend/app/handlers/deploy.go +++ b/backend/app/handlers/deploy.go @@ -11,20 +11,22 @@ import ( "quay/internal/envconfig" "quay/internal/security" "strings" + "time" "github.com/gofiber/fiber/v3" ) -type UpdateSiteHandler struct { - EnvCfg *envconfig.EnvConfig - SiteRepo repository.SiteRepository +type DeploySiteHandler struct { + EnvCfg *envconfig.EnvConfig + SiteRepo repository.SiteRepository + DeploymentRepo repository.DeploymentRepository } -func NewUpdateSiteHandler(envCfg *envconfig.EnvConfig, siteRepo repository.SiteRepository) *UpdateSiteHandler { - return &UpdateSiteHandler{EnvCfg: envCfg, SiteRepo: siteRepo} +func NewDeploySiteHandler(envCfg *envconfig.EnvConfig, siteRepo repository.SiteRepository, deploymentRepo repository.DeploymentRepository) *DeploySiteHandler { + 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") if siteId == "" { return c.Status(400).JSON(models.APIError{ @@ -40,7 +42,7 @@ func (h *UpdateSiteHandler) PostDeploy(c fiber.Ctx) error { Message: "Site not found", }) } - log.Println("Message getting site: ", err) + log.Println("Error getting site: ", err) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{ Message: "Unexpected error while getting site", }) @@ -54,6 +56,7 @@ func (h *UpdateSiteHandler) PostDeploy(c fiber.Ctx) error { deployToken := site.DeployToken if deployToken == "" { + log.Println("No deploy token configured for site " + siteId) return c.Status(500).JSON(models.APIError{ 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) if _, err := filepath.Abs(sitePath); err != nil { + log.Println("Error getting absolute path of site: ", err) return c.Status(500).JSON(models.APIError{ 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.Repository, site.Branch, @@ -90,11 +107,38 @@ func (h *UpdateSiteHandler) PostDeploy(c fiber.Ctx) error { 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 { + 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{ 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) } diff --git a/backend/app/handlers/deployment.go b/backend/app/handlers/deployment.go new file mode 100644 index 0000000..086532e --- /dev/null +++ b/backend/app/handlers/deployment.go @@ -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), + }) +} diff --git a/backend/app/models/deployment.go b/backend/app/models/deployment.go new file mode 100644 index 0000000..127d0fe --- /dev/null +++ b/backend/app/models/deployment.go @@ -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"` +} diff --git a/backend/app/repository/deployment_repository.go b/backend/app/repository/deployment_repository.go new file mode 100644 index 0000000..8f594fe --- /dev/null +++ b/backend/app/repository/deployment_repository.go @@ -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 +} diff --git a/backend/app/routes/routes.go b/backend/app/routes/routes.go index 3de4881..4a4730c 100644 --- a/backend/app/routes/routes.go +++ b/backend/app/routes/routes.go @@ -17,15 +17,18 @@ import ( func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, db *sql.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) + deploymentsHandler := handlers.NewDeploymentHandler(deploymentRepository) api := app.Group("/api/v1", middleware.APIHostGuard(envCfg.DashboardHost)) api.Get("/health", handlers.HealthCheck) - api.Post("/deploy", updateSiteHandler.PostDeploy) + api.Post("/deploy", deploySiteHandler.PostDeploy) + // Sites api.Get("/sites", siteHandler.GetSites) api.Get("/sites/:id", siteHandler.GetSite) 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.Patch("/sites/:id/enabled", siteHandler.ToggleEnabled) + // Forward rules api.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules) api.Post("/sites/:id/forward-rules", siteHandler.PostForwardRule) api.Get("/forward-rules/:id", siteHandler.GetForwardRule) api.Put("/forward-rules/:id", siteHandler.PutForwardRule) api.Delete("/forward-rules/:id", siteHandler.DeleteForwardRule) + // Custom headers (header rules) api.Get("/sites/:id/custom-headers", siteHandler.GetSiteCustomHeaders) api.Post("/sites/:id/custom-headers", siteHandler.PostCustomHeaders) api.Get("/custom-headers/:id", siteHandler.GetCustomHeaders) api.Put("/custom-headers/:id", siteHandler.PutCustomHeaders) api.Delete("/custom-headers/:id", siteHandler.DeleteCustomHeaders) + // Headers api.Get("/custom-headers/:id/headers", siteHandler.GetCustomHeaderHeaders) api.Post("/custom-headers/:id/headers", siteHandler.PostHeader) api.Get("/headers/:id", siteHandler.GetHeader) api.Put("/headers/:id", siteHandler.PutHeader) 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 { return c.Status(fiber.StatusNotFound).JSON(&models.APIError{ Message: "Endpoint not found", diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 5486b18..9b2b3ee 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -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}": { "get": { "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": { "get": { "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": { @@ -966,11 +1099,58 @@ const docTemplate = `{ "id": { "type": "string" }, + "regex": { + "type": "boolean" + }, "source": { "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": { "type": "object", "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": { "type": "object", "properties": { @@ -1025,6 +1219,9 @@ const docTemplate = `{ "branch": { "type": "string" }, + "created_at": { + "type": "string" + }, "custom_headers": { "type": "array", "items": { @@ -1052,6 +1249,12 @@ const docTemplate = `{ "id": { "type": "string" }, + "index_file": { + "type": "string" + }, + "name": { + "type": "string" + }, "not_found_file": { "type": "string" }, @@ -1063,6 +1266,17 @@ const docTemplate = `{ }, "spa": { "type": "boolean" + }, + "trailing_slash": { + "type": "boolean" + } + } + }, + "models.ToggleEnabledResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" } } } diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 770f61c..ec708cc 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -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}": { "get": { "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": { "get": { "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": { @@ -960,11 +1093,58 @@ "id": { "type": "string" }, + "regex": { + "type": "boolean" + }, "source": { "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": { "type": "object", "properties": { @@ -999,6 +1179,20 @@ } } }, + "models.GetDeploymentsBySiteResponse": { + "type": "object", + "properties": { + "deployments": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Deployment" + } + }, + "total": { + "type": "integer" + } + } + }, "models.Header": { "type": "object", "properties": { @@ -1019,6 +1213,9 @@ "branch": { "type": "string" }, + "created_at": { + "type": "string" + }, "custom_headers": { "type": "array", "items": { @@ -1046,6 +1243,12 @@ "id": { "type": "string" }, + "index_file": { + "type": "string" + }, + "name": { + "type": "string" + }, "not_found_file": { "type": "string" }, @@ -1057,6 +1260,17 @@ }, "spa": { "type": "boolean" + }, + "trailing_slash": { + "type": "boolean" + } + } + }, + "models.ToggleEnabledResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" } } } diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 715156a..8f103a4 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -20,9 +20,42 @@ definitions: type: array id: type: string + regex: + type: boolean source: type: string 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: properties: destination: @@ -45,6 +78,15 @@ definitions: total: type: integer type: object + models.GetDeploymentsBySiteResponse: + properties: + deployments: + items: + $ref: '#/definitions/models.Deployment' + type: array + total: + type: integer + type: object models.Header: properties: id: @@ -58,6 +100,8 @@ definitions: properties: branch: type: string + created_at: + type: string custom_headers: items: $ref: '#/definitions/models.CustomHeaders' @@ -76,6 +120,10 @@ definitions: type: string id: type: string + index_file: + type: string + name: + type: string not_found_file: type: string owner: @@ -84,6 +132,13 @@ definitions: type: string spa: type: boolean + trailing_slash: + type: boolean + type: object + models.ToggleEnabledResponse: + properties: + enabled: + type: boolean type: object host: localhost:4321 info: @@ -249,6 +304,35 @@ paths: summary: Create a header tags: - 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}: delete: description: Deletes a forward rule by ID @@ -635,6 +719,35 @@ paths: summary: Create a custom header group tags: - 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: get: description: Returns all forward rules associated with the given site @@ -702,6 +815,36 @@ paths: summary: Create a forward rule tags: - 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" tags: - description: Manage sites diff --git a/backend/internal/database/deployment_sqlite.go b/backend/internal/database/deployment_sqlite.go new file mode 100644 index 0000000..d1fea1f --- /dev/null +++ b/backend/internal/database/deployment_sqlite.go @@ -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 +} diff --git a/backend/internal/database/init_sqlite.go b/backend/internal/database/init_sqlite.go index 7d0cdce..7d6b78f 100644 --- a/backend/internal/database/init_sqlite.go +++ b/backend/internal/database/init_sqlite.go @@ -49,6 +49,17 @@ CREATE TABLE IF NOT EXISTS headers ( value TEXT NOT NULL, 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 { diff --git a/frontend/src/api/deployments.api.ts b/frontend/src/api/deployments.api.ts new file mode 100644 index 0000000..3a398f7 --- /dev/null +++ b/frontend/src/api/deployments.api.ts @@ -0,0 +1,18 @@ +import { makeApiUrl } from '.'; +import type { Deployment } from './types/deployments'; + +export const getDeploymentsForSite = async ( + siteId: string, + limit: number = 100 +): Promise => { + 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; +}; diff --git a/frontend/src/api/types/deployments.ts b/frontend/src/api/types/deployments.ts new file mode 100644 index 0000000..402e86f --- /dev/null +++ b/frontend/src/api/types/deployments.ts @@ -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; +} diff --git a/frontend/src/components/DeploymentStatusBadge.tsx b/frontend/src/components/DeploymentStatusBadge.tsx new file mode 100644 index 0000000..f0825ff --- /dev/null +++ b/frontend/src/components/DeploymentStatusBadge.tsx @@ -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 ; + case 'failed': + return ; + case 'running': + return ; + case 'pending': + return ; + } +}; + +const DeploymentStatusBadge = ({ status }: { status: DeploymentStatus }) => { + const statusText = status.charAt(0).toUpperCase() + status.slice(1); + + return ( +
+ + +
+ ); +}; + +export default DeploymentStatusBadge; diff --git a/frontend/src/hooks/api/useDeploymentsForSite.ts b/frontend/src/hooks/api/useDeploymentsForSite.ts new file mode 100644 index 0000000..62034ca --- /dev/null +++ b/frontend/src/hooks/api/useDeploymentsForSite.ts @@ -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, + }); +} diff --git a/frontend/src/pages/Main/Main.tsx b/frontend/src/pages/Main/Main.tsx index 72e97ca..38d37fc 100644 --- a/frontend/src/pages/Main/Main.tsx +++ b/frontend/src/pages/Main/Main.tsx @@ -16,6 +16,8 @@ import { useSites } from '../../hooks/api/useSites'; import type { Site } from '../../api/types/site'; import { Skeleton } from '../../components/ui/skeleton'; import { memo } from 'react'; +import { useDeploymentsForSite } from '../../hooks/api/useDeploymentsForSite'; +import { formatDateRelativeOrAbsolute } from '../../utils/dateTime'; const SiteAvatar = ({ name, enabled }: { name: string; enabled: boolean }) => (
( ); const SiteRow = ({ site }: { site: Site }) => { + const { data: deployments } = useDeploymentsForSite(site.id, 1); + return ( @@ -47,11 +51,14 @@ const SiteRow = ({ site }: { site: Site }) => {
- {/* TODO: Replace with actual deployment time */}

Last deployed

-

1 hour ago

+

+ {deployments && deployments.length > 0 + ? formatDateRelativeOrAbsolute(deployments[0].created_at) + : '—'} +

{site.enabled ? : } diff --git a/frontend/src/pages/SiteOverview/DeploymentsTab.tsx b/frontend/src/pages/SiteOverview/DeploymentsTab.tsx index 6bc7275..e17145a 100644 --- a/frontend/src/pages/SiteOverview/DeploymentsTab.tsx +++ b/frontend/src/pages/SiteOverview/DeploymentsTab.tsx @@ -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 { Card, @@ -15,24 +15,12 @@ import { TableHeader, TableRow, } from '../../components/ui/table'; -import type { MockDeploymentStatus, MockSite } from './SiteOverview'; -import { formatDate } from '../../utils/formatDate'; -import { cn } from '../../lib/utils'; +import { formatDateRelativeOrAbsolute, formatDuration } from '../../utils/dateTime'; +import type { Deployment } from '../../api/types/deployments'; +import DeploymentStatusBadge from '../../components/DeploymentStatusBadge'; +import type { Site } from '../../api/types/site'; -const StatusIcon = ({ status }: { status: MockDeploymentStatus }) => { - switch (status) { - case 'success': - return ; - case 'failed': - return ; - case 'building': - return ; - case 'queued': - return ; - } -}; - -const DeploymentsTab = ({ site }: { site: MockSite }) => ( +const DeploymentsTab = ({ deployments, site }: { deployments?: Deployment[]; site: Site }) => (
@@ -55,33 +43,36 @@ const DeploymentsTab = ({ site }: { site: MockSite }) => ( - {site.deployments.map((d) => ( - - -
- - -
-
- {d.commit} - - {d.message} - - - {formatDate(d.timestamp)} - -
- ))} + {d.commit_hash.substring(0, 7)} + + + + {d.message} + + + {d.start_time && d.finish_time + ? formatDuration(d.start_time, d.finish_time, false) + : '—'} + + + {formatDateRelativeOrAbsolute(d.created_at)} + + + ); + })}
diff --git a/frontend/src/pages/SiteOverview/OverviewTab.tsx b/frontend/src/pages/SiteOverview/OverviewTab.tsx index 4b4121a..785cf09 100644 --- a/frontend/src/pages/SiteOverview/OverviewTab.tsx +++ b/frontend/src/pages/SiteOverview/OverviewTab.tsx @@ -8,15 +8,24 @@ import { CardDescription, } from '../../components/ui/card'; import { Separator } from '../../components/ui/separator'; -import { formatDate } from '../../utils/formatDate'; -import { Table, TableBody, TableHead, TableHeader, TableRow } from '../../components/ui/table'; +import { formatDateRelativeOrAbsolute, formatDuration } from '../../utils/dateTime'; +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 host = site.git_server === 'github' ? 'github.com' : 'gitlab.com'; return `https://${host}/${site.owner}/${site.repository}`; }; -const OverviewTab = ({ site }: { site: Site }) => ( +const OverviewTab = ({ site, deployments }: { site: Site; deployments?: Deployment[] }) => (
@@ -79,7 +88,11 @@ const OverviewTab = ({ site }: { site: Site }) => (
Last Deployed - {formatDate('2024-01-01T12:00:00Z')} + + {deployments + ? formatDateRelativeOrAbsolute(deployments[0]?.created_at) + : '—'} +
@@ -90,32 +103,51 @@ const OverviewTab = ({ site }: { site: Site }) => ( Last 3 deployments for this site. - - - - Status - Commit - Message - Time - - - - {/* {site.deployments.slice(0, 3).map((d) => ( - - - - - {d.commit} - - {d.message} - - - {formatDate(d.timestamp)} - + {deployments && deployments.length > 0 && ( +
+ + + Status + Commit + Message + Duration + Time - ))} */} - -
+ + + {deployments.slice(0, 3).map((d) => { + const githubCommitUrl = `https://github.com/${site.owner}/${site.repository}/commit/${d.commit_hash}`; + return ( + + + + + + + {d.commit_hash.substring(0, 7)} + + + + {d.message} + + + {d.start_time && d.finish_time + ? formatDuration(d.start_time, d.finish_time, false) + : '—'} + + + {formatDateRelativeOrAbsolute(d.created_at)} + + + ); + })} + + + )}
diff --git a/frontend/src/pages/SiteOverview/SiteOverview.tsx b/frontend/src/pages/SiteOverview/SiteOverview.tsx index 86c89cb..bd229b4 100644 --- a/frontend/src/pages/SiteOverview/SiteOverview.tsx +++ b/frontend/src/pages/SiteOverview/SiteOverview.tsx @@ -27,73 +27,7 @@ import { EmptyMedia, EmptyTitle, } from '../../components/ui/empty'; - -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', - }, - ], -}; +import { useDeploymentsForSite } from '../../hooks/api/useDeploymentsForSite'; const VALID_TABS = [ 'overview', @@ -163,8 +97,7 @@ const SiteOverview = () => { const [searchParams, setSearchParams] = useSearchParams(); const { data: site, isLoading: isLoadingSite, error: siteError } = useSite(id!); const toggleSiteEnabled = useToggleSiteEnabled(id!); - - const mockSite = MOCK_SITE; + const { data: deployments } = useDeploymentsForSite(id!, 100); const tabParam = searchParams.get('tab'); const activeTab: TabValue = @@ -266,11 +199,11 @@ const SiteOverview = () => {
- + - + diff --git a/frontend/src/utils/dateTime.ts b/frontend/src/utils/dateTime.ts new file mode 100644 index 0000000..bd575e5 --- /dev/null +++ b/frontend/src/utils/dateTime.ts @@ -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); + } +}; diff --git a/frontend/src/utils/formatDate.ts b/frontend/src/utils/formatDate.ts deleted file mode 100644 index e98c7c6..0000000 --- a/frontend/src/utils/formatDate.ts +++ /dev/null @@ -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', - }); -};