Added rules frontend

This commit is contained in:
2026-04-08 18:11:56 +02:00
parent 1978a31cbf
commit 88323ed4fe
23 changed files with 1412 additions and 81 deletions
@@ -175,22 +175,26 @@ func (c *CachedSiteRepository) CreateForwardRule(siteID string, fr *models.Forwa
return nil return nil
} }
func (c *CachedSiteRepository) UpdateForwardRule(fr *models.ForwardRule) error { func (c *CachedSiteRepository) UpdateForwardRule(siteID string, fr *models.ForwardRule) error {
if err := c.inner.UpdateForwardRule(fr); err != nil { if err := c.inner.UpdateForwardRule(siteID, fr); err != nil {
return err return err
} }
c.mu.Lock() c.mu.Lock()
c.forwardRules[fr.ID] = fr c.forwardRules[fr.ID] = fr
delete(c.sites, siteID)
c.siteListValid = false
c.mu.Unlock() c.mu.Unlock()
return nil return nil
} }
func (c *CachedSiteRepository) DeleteForwardRule(id string) error { func (c *CachedSiteRepository) DeleteForwardRule(siteID string, id string) error {
if err := c.inner.DeleteForwardRule(id); err != nil { if err := c.inner.DeleteForwardRule(siteID, id); err != nil {
return err return err
} }
c.mu.Lock() c.mu.Lock()
delete(c.forwardRules, id) delete(c.forwardRules, id)
delete(c.sites, siteID)
c.siteListValid = false
c.mu.Unlock() c.mu.Unlock()
return nil return nil
} }
@@ -228,22 +232,26 @@ func (c *CachedSiteRepository) CreateCustomHeaders(siteID string, ch *models.Cus
return nil return nil
} }
func (c *CachedSiteRepository) UpdateCustomHeaders(ch *models.CustomHeaders) error { func (c *CachedSiteRepository) UpdateCustomHeaders(siteID string, ch *models.CustomHeaders) error {
if err := c.inner.UpdateCustomHeaders(ch); err != nil { if err := c.inner.UpdateCustomHeaders(siteID, ch); err != nil {
return err return err
} }
c.mu.Lock() c.mu.Lock()
c.customHeaders[ch.ID] = ch c.customHeaders[ch.ID] = ch
delete(c.sites, siteID)
c.siteListValid = false
c.mu.Unlock() c.mu.Unlock()
return nil return nil
} }
func (c *CachedSiteRepository) DeleteCustomHeaders(id string) error { func (c *CachedSiteRepository) DeleteCustomHeaders(siteID string, id string) error {
if err := c.inner.DeleteCustomHeaders(id); err != nil { if err := c.inner.DeleteCustomHeaders(siteID, id); err != nil {
return err return err
} }
c.mu.Lock() c.mu.Lock()
delete(c.customHeaders, id) delete(c.customHeaders, id)
delete(c.sites, siteID)
c.siteListValid = false
c.mu.Unlock() c.mu.Unlock()
return nil return nil
} }
@@ -269,33 +277,39 @@ func (c *CachedSiteRepository) GetHeader(id string) (*models.Header, error) {
return h, nil return h, nil
} }
func (c *CachedSiteRepository) CreateHeader(customHeaderID string, h *models.Header) error { func (c *CachedSiteRepository) CreateHeader(siteID string, customHeaderID string, h *models.Header) error {
if err := c.inner.CreateHeader(customHeaderID, h); err != nil { if err := c.inner.CreateHeader(siteID, customHeaderID, h); err != nil {
return err return err
} }
c.mu.Lock() c.mu.Lock()
c.headers[h.ID] = h c.headers[h.ID] = h
delete(c.customHeaders, customHeaderID) delete(c.customHeaders, customHeaderID)
delete(c.sites, siteID)
c.siteListValid = false
c.mu.Unlock() c.mu.Unlock()
return nil return nil
} }
func (c *CachedSiteRepository) UpdateHeader(h *models.Header) error { func (c *CachedSiteRepository) UpdateHeader(siteID string, h *models.Header) error {
if err := c.inner.UpdateHeader(h); err != nil { if err := c.inner.UpdateHeader(siteID, h); err != nil {
return err return err
} }
c.mu.Lock() c.mu.Lock()
c.headers[h.ID] = h c.headers[h.ID] = h
delete(c.sites, siteID)
c.siteListValid = false
c.mu.Unlock() c.mu.Unlock()
return nil return nil
} }
func (c *CachedSiteRepository) DeleteHeader(id string) error { func (c *CachedSiteRepository) DeleteHeader(siteID string, id string) error {
if err := c.inner.DeleteHeader(id); err != nil { if err := c.inner.DeleteHeader(siteID, id); err != nil {
return err return err
} }
c.mu.Lock() c.mu.Lock()
delete(c.headers, id) delete(c.headers, id)
delete(c.sites, siteID)
c.siteListValid = false
c.mu.Unlock() c.mu.Unlock()
return nil return nil
} }
+25 -18
View File
@@ -97,7 +97,7 @@ func (h *SiteHandler) GetSiteForwardRules(c fiber.Ctx) error {
// @Failure 500 {object} models.APIError "Internal server error" // @Failure 500 {object} models.APIError "Internal server error"
// @Router /forward-rules/{id} [get] // @Router /forward-rules/{id} [get]
func (h *SiteHandler) GetForwardRule(c fiber.Ctx) error { func (h *SiteHandler) GetForwardRule(c fiber.Ctx) error {
id := c.Params("id") id := c.Params("ruleId")
rule, err := h.Repo.GetForwardRule(id) rule, err := h.Repo.GetForwardRule(id)
if err != nil { if err != nil {
@@ -169,7 +169,8 @@ func (h *SiteHandler) PostForwardRule(c fiber.Ctx) error {
// @Failure 500 {object} models.APIError "Internal server error" // @Failure 500 {object} models.APIError "Internal server error"
// @Router /forward-rules/{id} [put] // @Router /forward-rules/{id} [put]
func (h *SiteHandler) PutForwardRule(c fiber.Ctx) error { func (h *SiteHandler) PutForwardRule(c fiber.Ctx) error {
id := c.Params("id") siteID := c.Params("id")
id := c.Params("ruleId")
if _, err := h.Repo.GetForwardRule(id); err != nil { if _, err := h.Repo.GetForwardRule(id); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -190,7 +191,7 @@ func (h *SiteHandler) PutForwardRule(c fiber.Ctx) error {
} }
rule.ID = id rule.ID = id
if err := h.Repo.UpdateForwardRule(&rule); err != nil { if err := h.Repo.UpdateForwardRule(siteID, &rule); err != nil {
log.Println("Error updating forward rule: ", err) log.Println("Error updating forward rule: ", err)
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating forward rule"}) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating forward rule"})
} }
@@ -216,7 +217,8 @@ func (h *SiteHandler) PutForwardRule(c fiber.Ctx) error {
// @Failure 500 {object} models.APIError "Internal server error" // @Failure 500 {object} models.APIError "Internal server error"
// @Router /forward-rules/{id} [delete] // @Router /forward-rules/{id} [delete]
func (h *SiteHandler) DeleteForwardRule(c fiber.Ctx) error { func (h *SiteHandler) DeleteForwardRule(c fiber.Ctx) error {
id := c.Params("id") siteID := c.Params("id")
id := c.Params("ruleId")
if _, err := h.Repo.GetForwardRule(id); err != nil { if _, err := h.Repo.GetForwardRule(id); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -226,7 +228,7 @@ func (h *SiteHandler) DeleteForwardRule(c fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting forward rule"}) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting forward rule"})
} }
if err := h.Repo.DeleteForwardRule(id); err != nil { if err := h.Repo.DeleteForwardRule(siteID, id); err != nil {
log.Println("Error deleting forward rule: ", err) log.Println("Error deleting forward rule: ", err)
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting forward rule"}) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting forward rule"})
} }
@@ -275,7 +277,7 @@ func (h *SiteHandler) GetSiteCustomHeaders(c fiber.Ctx) error {
// @Failure 500 {object} models.APIError "Internal server error" // @Failure 500 {object} models.APIError "Internal server error"
// @Router /custom-headers/{id} [get] // @Router /custom-headers/{id} [get]
func (h *SiteHandler) GetCustomHeaders(c fiber.Ctx) error { func (h *SiteHandler) GetCustomHeaders(c fiber.Ctx) error {
id := c.Params("id") id := c.Params("customHeaderId")
customHeaders, err := h.Repo.GetCustomHeaders(id) customHeaders, err := h.Repo.GetCustomHeaders(id)
if err != nil { if err != nil {
@@ -347,7 +349,8 @@ func (h *SiteHandler) PostCustomHeaders(c fiber.Ctx) error {
// @Failure 500 {object} models.APIError "Internal server error" // @Failure 500 {object} models.APIError "Internal server error"
// @Router /custom-headers/{id} [put] // @Router /custom-headers/{id} [put]
func (h *SiteHandler) PutCustomHeaders(c fiber.Ctx) error { func (h *SiteHandler) PutCustomHeaders(c fiber.Ctx) error {
id := c.Params("id") siteID := c.Params("id")
id := c.Params("customHeaderId")
if _, err := h.Repo.GetCustomHeaders(id); err != nil { if _, err := h.Repo.GetCustomHeaders(id); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -368,7 +371,7 @@ func (h *SiteHandler) PutCustomHeaders(c fiber.Ctx) error {
} }
customHeaders.ID = id customHeaders.ID = id
if err := h.Repo.UpdateCustomHeaders(&customHeaders); err != nil { if err := h.Repo.UpdateCustomHeaders(siteID, &customHeaders); err != nil {
log.Println("Error updating custom headers: ", err) log.Println("Error updating custom headers: ", err)
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating custom headers"}) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating custom headers"})
} }
@@ -394,7 +397,8 @@ func (h *SiteHandler) PutCustomHeaders(c fiber.Ctx) error {
// @Failure 500 {object} models.APIError "Internal server error" // @Failure 500 {object} models.APIError "Internal server error"
// @Router /custom-headers/{id} [delete] // @Router /custom-headers/{id} [delete]
func (h *SiteHandler) DeleteCustomHeaders(c fiber.Ctx) error { func (h *SiteHandler) DeleteCustomHeaders(c fiber.Ctx) error {
id := c.Params("id") siteID := c.Params("id")
id := c.Params("customHeaderId")
if _, err := h.Repo.GetCustomHeaders(id); err != nil { if _, err := h.Repo.GetCustomHeaders(id); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -404,7 +408,7 @@ func (h *SiteHandler) DeleteCustomHeaders(c fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting custom headers"}) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting custom headers"})
} }
if err := h.Repo.DeleteCustomHeaders(id); err != nil { if err := h.Repo.DeleteCustomHeaders(siteID, id); err != nil {
log.Println("Error deleting custom headers: ", err) log.Println("Error deleting custom headers: ", err)
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting custom headers"}) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting custom headers"})
} }
@@ -424,7 +428,7 @@ func (h *SiteHandler) DeleteCustomHeaders(c fiber.Ctx) error {
// @Failure 500 {object} models.APIError "Internal server error" // @Failure 500 {object} models.APIError "Internal server error"
// @Router /custom-headers/{id}/headers [get] // @Router /custom-headers/{id}/headers [get]
func (h *SiteHandler) GetCustomHeaderHeaders(c fiber.Ctx) error { func (h *SiteHandler) GetCustomHeaderHeaders(c fiber.Ctx) error {
customHeaderID := c.Params("id") customHeaderID := c.Params("customHeaderId")
customHeaders, err := h.Repo.GetCustomHeaders(customHeaderID) customHeaders, err := h.Repo.GetCustomHeaders(customHeaderID)
if err != nil { if err != nil {
@@ -453,7 +457,7 @@ func (h *SiteHandler) GetCustomHeaderHeaders(c fiber.Ctx) error {
// @Failure 500 {object} models.APIError "Internal server error" // @Failure 500 {object} models.APIError "Internal server error"
// @Router /headers/{id} [get] // @Router /headers/{id} [get]
func (h *SiteHandler) GetHeader(c fiber.Ctx) error { func (h *SiteHandler) GetHeader(c fiber.Ctx) error {
id := c.Params("id") id := c.Params("headerId")
header, err := h.Repo.GetHeader(id) header, err := h.Repo.GetHeader(id)
if err != nil { if err != nil {
@@ -482,7 +486,8 @@ func (h *SiteHandler) GetHeader(c fiber.Ctx) error {
// @Failure 500 {object} models.APIError "Internal server error" // @Failure 500 {object} models.APIError "Internal server error"
// @Router /custom-headers/{id}/headers [post] // @Router /custom-headers/{id}/headers [post]
func (h *SiteHandler) PostHeader(c fiber.Ctx) error { func (h *SiteHandler) PostHeader(c fiber.Ctx) error {
customHeaderID := c.Params("id") siteID := c.Params("id")
customHeaderID := c.Params("customHeaderId")
if _, err := h.Repo.GetCustomHeaders(customHeaderID); err != nil { if _, err := h.Repo.GetCustomHeaders(customHeaderID); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -502,7 +507,7 @@ func (h *SiteHandler) PostHeader(c fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body: " + err.Error()}) return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body: " + err.Error()})
} }
if err := h.Repo.CreateHeader(customHeaderID, &header); err != nil { if err := h.Repo.CreateHeader(siteID, customHeaderID, &header); err != nil {
log.Println("Error creating header: ", err) log.Println("Error creating header: ", err)
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while creating header"}) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while creating header"})
} }
@@ -525,7 +530,8 @@ func (h *SiteHandler) PostHeader(c fiber.Ctx) error {
// @Failure 500 {object} models.APIError "Internal server error" // @Failure 500 {object} models.APIError "Internal server error"
// @Router /headers/{id} [put] // @Router /headers/{id} [put]
func (h *SiteHandler) PutHeader(c fiber.Ctx) error { func (h *SiteHandler) PutHeader(c fiber.Ctx) error {
id := c.Params("id") siteID := c.Params("id")
id := c.Params("headerId")
if _, err := h.Repo.GetHeader(id); err != nil { if _, err := h.Repo.GetHeader(id); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -546,7 +552,7 @@ func (h *SiteHandler) PutHeader(c fiber.Ctx) error {
} }
header.ID = id header.ID = id
if err := h.Repo.UpdateHeader(&header); err != nil { if err := h.Repo.UpdateHeader(siteID, &header); err != nil {
log.Println("Error updating header: ", err) log.Println("Error updating header: ", err)
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating header"}) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating header"})
} }
@@ -572,7 +578,8 @@ func (h *SiteHandler) PutHeader(c fiber.Ctx) error {
// @Failure 500 {object} models.APIError "Internal server error" // @Failure 500 {object} models.APIError "Internal server error"
// @Router /headers/{id} [delete] // @Router /headers/{id} [delete]
func (h *SiteHandler) DeleteHeader(c fiber.Ctx) error { func (h *SiteHandler) DeleteHeader(c fiber.Ctx) error {
id := c.Params("id") siteID := c.Params("id")
id := c.Params("headerId")
if _, err := h.Repo.GetHeader(id); err != nil { if _, err := h.Repo.GetHeader(id); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -582,7 +589,7 @@ func (h *SiteHandler) DeleteHeader(c fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting header"}) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting header"})
} }
if err := h.Repo.DeleteHeader(id); err != nil { if err := h.Repo.DeleteHeader(siteID, id); err != nil {
log.Println("Error deleting header: ", err) log.Println("Error deleting header: ", err)
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting header"}) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting header"})
} }
+7 -7
View File
@@ -12,14 +12,14 @@ type SiteRepository interface {
DeleteSite(id string) error DeleteSite(id string) error
GetForwardRule(id string) (*models.ForwardRule, error) GetForwardRule(id string) (*models.ForwardRule, error)
CreateForwardRule(siteID string, fr *models.ForwardRule) error CreateForwardRule(siteID string, fr *models.ForwardRule) error
UpdateForwardRule(fr *models.ForwardRule) error UpdateForwardRule(siteID string, fr *models.ForwardRule) error
DeleteForwardRule(id string) error DeleteForwardRule(siteID string, id string) error
GetCustomHeaders(id string) (*models.CustomHeaders, error) GetCustomHeaders(id string) (*models.CustomHeaders, error)
CreateCustomHeaders(siteID string, ch *models.CustomHeaders) error CreateCustomHeaders(siteID string, ch *models.CustomHeaders) error
UpdateCustomHeaders(ch *models.CustomHeaders) error UpdateCustomHeaders(siteID string, ch *models.CustomHeaders) error
DeleteCustomHeaders(id string) error DeleteCustomHeaders(siteID string, id string) error
GetHeader(id string) (*models.Header, error) GetHeader(id string) (*models.Header, error)
CreateHeader(customHeaderID string, h *models.Header) error CreateHeader(siteID string, customHeaderID string, h *models.Header) error
UpdateHeader(h *models.Header) error UpdateHeader(siteID string, h *models.Header) error
DeleteHeader(id string) error DeleteHeader(siteID string, id string) error
} }
+11 -11
View File
@@ -39,23 +39,23 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d
// Forward rules // 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("/sites/:id/forward-rules/:ruleId", siteHandler.GetForwardRule)
api.Put("/forward-rules/:id", siteHandler.PutForwardRule) api.Put("/sites/:id/forward-rules/:ruleId", siteHandler.PutForwardRule)
api.Delete("/forward-rules/:id", siteHandler.DeleteForwardRule) api.Delete("/sites/:id/forward-rules/:ruleId", siteHandler.DeleteForwardRule)
// Custom headers (header rules) // 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("/sites/:id/custom-headers/:customHeaderId", siteHandler.GetCustomHeaders)
api.Put("/custom-headers/:id", siteHandler.PutCustomHeaders) api.Put("/sites/:id/custom-headers/:customHeaderId", siteHandler.PutCustomHeaders)
api.Delete("/custom-headers/:id", siteHandler.DeleteCustomHeaders) api.Delete("/sites/:id/custom-headers/:customHeaderId", siteHandler.DeleteCustomHeaders)
// Headers // Headers
api.Get("/custom-headers/:id/headers", siteHandler.GetCustomHeaderHeaders) api.Get("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.GetCustomHeaderHeaders)
api.Post("/custom-headers/:id/headers", siteHandler.PostHeader) api.Post("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.PostHeader)
api.Get("/headers/:id", siteHandler.GetHeader) api.Get("/sites/:id/headers/:headerId", siteHandler.GetHeader)
api.Put("/headers/:id", siteHandler.PutHeader) api.Put("/sites/:id/headers/:headerId", siteHandler.PutHeader)
api.Delete("/headers/:id", siteHandler.DeleteHeader) api.Delete("/sites/:id/headers/:headerId", siteHandler.DeleteHeader)
// Deployments // Deployments
api.Get("/deployments/:id", deploymentsHandler.GetDeployment) api.Get("/deployments/:id", deploymentsHandler.GetDeployment)
+7 -7
View File
@@ -233,7 +233,7 @@ func (r *SQLiteSiteRepository) CreateForwardRule(siteID string, fr *models.Forwa
return tx.Commit() return tx.Commit()
} }
func (r *SQLiteSiteRepository) UpdateForwardRule(fr *models.ForwardRule) error { func (r *SQLiteSiteRepository) UpdateForwardRule(siteID string, fr *models.ForwardRule) error {
_, err := r.db.Exec(` _, err := r.db.Exec(`
UPDATE forward_rules SET source=?, destination=?, status_code=?, regex=? WHERE id=?`, UPDATE forward_rules SET source=?, destination=?, status_code=?, regex=? WHERE id=?`,
fr.Source, fr.Destination, fr.StatusCode, fr.Regex, fr.ID, fr.Source, fr.Destination, fr.StatusCode, fr.Regex, fr.ID,
@@ -244,7 +244,7 @@ func (r *SQLiteSiteRepository) UpdateForwardRule(fr *models.ForwardRule) error {
return nil return nil
} }
func (r *SQLiteSiteRepository) DeleteForwardRule(id string) error { func (r *SQLiteSiteRepository) DeleteForwardRule(_ string, id string) error {
_, err := r.db.Exec(`DELETE FROM forward_rules WHERE id = ?`, id) _, err := r.db.Exec(`DELETE FROM forward_rules WHERE id = ?`, id)
if err != nil { if err != nil {
return fmt.Errorf("delete forward rule: %w", err) return fmt.Errorf("delete forward rule: %w", err)
@@ -284,7 +284,7 @@ func (r *SQLiteSiteRepository) CreateCustomHeaders(siteID string, ch *models.Cus
return tx.Commit() return tx.Commit()
} }
func (r *SQLiteSiteRepository) UpdateCustomHeaders(ch *models.CustomHeaders) error { func (r *SQLiteSiteRepository) UpdateCustomHeaders(siteID string, ch *models.CustomHeaders) error {
tx, err := r.db.Begin() tx, err := r.db.Begin()
if err != nil { if err != nil {
return fmt.Errorf("update custom headers begin tx: %w", err) return fmt.Errorf("update custom headers begin tx: %w", err)
@@ -305,7 +305,7 @@ func (r *SQLiteSiteRepository) UpdateCustomHeaders(ch *models.CustomHeaders) err
return tx.Commit() return tx.Commit()
} }
func (r *SQLiteSiteRepository) DeleteCustomHeaders(id string) error { func (r *SQLiteSiteRepository) DeleteCustomHeaders(_ string, id string) error {
_, err := r.db.Exec(`DELETE FROM custom_headers WHERE id = ?`, id) _, err := r.db.Exec(`DELETE FROM custom_headers WHERE id = ?`, id)
if err != nil { if err != nil {
return fmt.Errorf("delete custom headers: %w", err) return fmt.Errorf("delete custom headers: %w", err)
@@ -325,7 +325,7 @@ func (r *SQLiteSiteRepository) GetHeader(id string) (*models.Header, error) {
return &h, nil return &h, nil
} }
func (r *SQLiteSiteRepository) CreateHeader(customHeaderID string, h *models.Header) error { func (r *SQLiteSiteRepository) CreateHeader(_ string, customHeaderID string, h *models.Header) error {
h.ID = uuid.NewString() h.ID = uuid.NewString()
_, err := r.db.Exec( _, err := r.db.Exec(
`INSERT INTO headers (id, custom_header_id, key, value) VALUES (?, ?, ?, ?)`, `INSERT INTO headers (id, custom_header_id, key, value) VALUES (?, ?, ?, ?)`,
@@ -337,7 +337,7 @@ func (r *SQLiteSiteRepository) CreateHeader(customHeaderID string, h *models.Hea
return nil return nil
} }
func (r *SQLiteSiteRepository) UpdateHeader(h *models.Header) error { func (r *SQLiteSiteRepository) UpdateHeader(_ string, h *models.Header) error {
_, err := r.db.Exec(`UPDATE headers SET key=?, value=? WHERE id=?`, h.Key, h.Value, h.ID) _, err := r.db.Exec(`UPDATE headers SET key=?, value=? WHERE id=?`, h.Key, h.Value, h.ID)
if err != nil { if err != nil {
return fmt.Errorf("update header: %w", err) return fmt.Errorf("update header: %w", err)
@@ -345,7 +345,7 @@ func (r *SQLiteSiteRepository) UpdateHeader(h *models.Header) error {
return nil return nil
} }
func (r *SQLiteSiteRepository) DeleteHeader(id string) error { func (r *SQLiteSiteRepository) DeleteHeader(_ string, id string) error {
_, err := r.db.Exec(`DELETE FROM headers WHERE id = ?`, id) _, err := r.db.Exec(`DELETE FROM headers WHERE id = ?`, id)
if err != nil { if err != nil {
return fmt.Errorf("delete header: %w", err) return fmt.Errorf("delete header: %w", err)
+106
View File
@@ -0,0 +1,106 @@
import { makeApiUrl } from '.';
import type {
CreateCustomHeadersRequest,
CreateHeaderRequest,
CustomHeaders,
Header,
} from './types/site';
export const getCustomHeaders = async (siteId: string): Promise<CustomHeaders[]> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers`), {
method: 'GET',
});
if (!response.ok) {
throw new Error('Failed to fetch custom headers');
}
return response.json();
};
export const createCustomHeaders = async (
siteId: string,
data: CreateCustomHeadersRequest
): Promise<CustomHeaders> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to create custom headers');
}
return response.json();
};
export const updateCustomHeaders = async (
siteId: string,
groupId: string,
data: CreateCustomHeadersRequest
): Promise<CustomHeaders> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to update custom headers');
}
return response.json();
};
export const deleteCustomHeaders = async (siteId: string, groupId: string): Promise<void> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}`), {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete custom headers');
}
};
export const createHeader = async (
siteId: string,
groupId: string,
data: CreateHeaderRequest
): Promise<Header> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}/headers`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to create header');
}
return response.json();
};
export const updateHeader = async (
siteId: string,
headerId: string,
data: CreateHeaderRequest
): Promise<Header> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/headers/${headerId}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to update header');
}
return response.json();
};
export const deleteHeader = async (siteId: string, headerId: string): Promise<void> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/headers/${headerId}`), {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete header');
}
};
+56
View File
@@ -0,0 +1,56 @@
import { makeApiUrl } from '.';
import type { CreateForwardRuleRequest, ForwardRule } from './types/site';
export const getForwardRules = async (siteId: string): Promise<ForwardRule[]> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules`), {
method: 'GET',
});
if (!response.ok) {
throw new Error('Failed to fetch forward rules');
}
return response.json();
};
export const createForwardRule = async (
siteId: string,
data: CreateForwardRuleRequest
): Promise<ForwardRule> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to create forward rule');
}
return response.json();
};
export const updateForwardRule = async (
siteId: string,
ruleId: string,
data: CreateForwardRuleRequest
): Promise<ForwardRule> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules/${ruleId}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to update forward rule');
}
return response.json();
};
export const deleteForwardRule = async (siteId: string, ruleId: string): Promise<void> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules/${ruleId}`), {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete forward rule');
}
};
+17
View File
@@ -64,3 +64,20 @@ export interface CreateSiteResponse {
export interface ToggleSiteEnabledResponse { export interface ToggleSiteEnabledResponse {
enabled: boolean; enabled: boolean;
} }
export interface CreateForwardRuleRequest {
source: string;
destination: string;
status_code: number;
regex: boolean;
}
export interface CreateCustomHeadersRequest {
source: string;
regex: boolean;
}
export interface CreateHeaderRequest {
key: string;
value: string;
}
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+197
View File
@@ -0,0 +1,197 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateForwardRuleRequest } from '../../../api/types/site';
import { createForwardRule } from '../../../api/forwardrules.api';
export function useCreateForwardRule(siteId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: CreateForwardRuleRequest) => createForwardRule(siteId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['forwardRules', siteId],
});
},
});
}
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteForwardRule } from '../../../api/forwardrules.api';
export function useDeleteForwardRule(siteId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (ruleId: string) => deleteForwardRule(siteId, ruleId),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['forwardRules', siteId],
});
},
});
}
@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { getForwardRules } from '../../../api/forwardrules.api';
export function useForwardRules(siteId: string) {
return useQuery({
queryKey: ['forwardRules', siteId],
queryFn: async () => getForwardRules(siteId),
enabled: !!siteId,
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateForwardRule } from '../../../api/forwardrules.api';
import type { CreateForwardRuleRequest } from '../../../api/types/site';
export function useUpdateForwardRule(siteId: string, ruleId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: CreateForwardRuleRequest) =>
updateForwardRule(siteId, ruleId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['forwardRules', siteId],
});
},
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateCustomHeadersRequest } from '../../api/types/site';
import { createCustomHeaders } from '../../api/customHeaders.api';
export function useCreateCustomHeaders(siteId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateCustomHeadersRequest) => createCustomHeaders(siteId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
+15
View File
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateHeaderRequest } from '../../api/types/site';
import { createHeader } from '../../api/customHeaders.api';
export function useCreateHeader(siteId: string, groupId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateHeaderRequest) => createHeader(siteId, groupId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { getCustomHeaders } from '../../api/customHeaders.api';
import type { CustomHeaders } from '../../api/types/site';
export function useCustomHeaders(siteId: string) {
return useQuery<CustomHeaders[]>({
queryKey: ['customHeaders', siteId],
queryFn: () => getCustomHeaders(siteId),
enabled: !!siteId,
});
}
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteCustomHeaders } from '../../api/customHeaders.api';
export function useDeleteCustomHeaders(groupId: string, siteId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => deleteCustomHeaders(siteId, groupId),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
+14
View File
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteHeader } from '../../api/customHeaders.api';
export function useDeleteHeader(siteId: string, headerId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => deleteHeader(siteId, headerId),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateCustomHeadersRequest } from '../../api/types/site';
import { updateCustomHeaders } from '../../api/customHeaders.api';
export function useUpdateCustomHeaders(siteId: string, groupId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateCustomHeadersRequest) =>
updateCustomHeaders(siteId, groupId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
+15
View File
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateHeaderRequest } from '../../api/types/site';
import { updateHeader } from '../../api/customHeaders.api';
export function useUpdateHeader(siteId: string, headerId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateHeaderRequest) => updateHeader(siteId, headerId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
@@ -0,0 +1,713 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Check, Pencil, Plus, Route, ShieldPlus, Trash2, X } from 'lucide-react';
import type { Site, ForwardRule, CustomHeaders, Header } from '../../api/types/site';
import { useForwardRules } from '@/hooks/api/forwardRules/useForwardRules';
import { useCreateForwardRule } from '@/hooks/api/forwardRules/useCreateForwardRule';
import { useUpdateForwardRule } from '@/hooks/api/forwardRules/useUpdateForwardRule';
import { useDeleteForwardRule } from '@/hooks/api/forwardRules/useDeleteForwardRule';
import { useCustomHeaders } from '@/hooks/api/useCustomHeaders';
import {
Empty,
EmptyDescription,
EmptyHeader as EmptyHdr,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { useCreateCustomHeaders } from '../../hooks/api/useCreateCustomHeaders';
import { useDeleteCustomHeaders } from '../../hooks/api/useDeleteCustomHeaders';
import { useCreateHeader } from '../../hooks/api/useCreateHeader';
import { useDeleteHeader } from '../../hooks/api/useDeleteHeader';
import { useUpdateHeader } from '../../hooks/api/useUpdateHeader';
interface ForwardRuleFormData {
source: string;
destination: string;
status_code: number;
regex: boolean;
}
const EMPTY_FORWARD_RULE: ForwardRuleFormData = {
source: '',
destination: '',
status_code: 301,
regex: false,
};
const STATUS_CODE_OPTIONS = [
{ value: 301, label: '301 — Permanent Redirect' },
{ value: 302, label: '302 — Temporary Redirect' },
{ value: 307, label: '307 — Temporary (preserve method)' },
{ value: 308, label: '308 — Permanent (preserve method)' },
];
function ForwardRuleDialog({
rule,
siteId,
trigger,
}: {
rule?: ForwardRule;
siteId: string;
trigger: React.ReactNode;
}) {
const isEdit = !!rule;
const [open, setOpen] = useState(false);
const [form, setForm] = useState<ForwardRuleFormData>(
rule
? {
source: rule.source,
destination: rule.destination,
status_code: rule.status_code,
regex: rule.regex,
}
: EMPTY_FORWARD_RULE
);
const createRule = useCreateForwardRule(siteId);
const updateRule = useUpdateForwardRule(siteId, rule?.id ?? '');
const handleSubmit = () => {
const mutation = isEdit ? updateRule : createRule;
mutation.mutate(form, {
onSuccess: () => {
setOpen(false);
if (!isEdit) setForm(EMPTY_FORWARD_RULE);
},
});
};
const isPending = createRule.isPending || updateRule.isPending;
const isValid = form.source.trim() !== '' && form.destination.trim() !== '';
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit' : 'New'} Forward Rule</DialogTitle>
<DialogDescription>
{isEdit
? 'Update the source, destination, and options for this rule.'
: 'Redirect requests from one path to another.'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="fr-source">Source path</Label>
<Input
id="fr-source"
placeholder="/old-page"
value={form.source}
onChange={(e) => setForm((f) => ({ ...f, source: e.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="fr-dest">Destination</Label>
<Input
id="fr-dest"
placeholder="/new-page or https://example.com"
value={form.destination}
onChange={(e) =>
setForm((f) => ({ ...f, destination: e.target.value }))
}
/>
</div>
<div className="grid gap-2">
<Label>Status Code</Label>
<Select
value={String(form.status_code)}
onValueChange={(v) =>
setForm((f) => ({ ...f, status_code: Number(v) }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_CODE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Label className="flex items-center gap-2">
<Switch
checked={form.regex}
onCheckedChange={(v) => setForm((f) => ({ ...f, regex: v }))}
/>
<span>Use regex matching</span>
</Label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isPending || !isValid}>
{isPending ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Rule'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ForwardRulesSection({ site }: { site: Site }) {
const { data: rules, isLoading } = useForwardRules(site.id);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">Forward Rules</CardTitle>
<CardDescription>Redirect or rewrite incoming request paths.</CardDescription>
</div>
<ForwardRuleDialog
siteId={site.id}
trigger={
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
Add Rule
</Button>
}
/>
</CardHeader>
<CardContent>
{isLoading && <p className="text-sm text-muted-foreground">Loading</p>}
{!isLoading && (!rules || rules.length === 0) && (
<Empty>
<EmptyHdr>
<EmptyMedia variant="icon">
<Route />
</EmptyMedia>
<EmptyTitle>No forward rules</EmptyTitle>
<EmptyDescription>
Add a rule to redirect or rewrite request paths.
</EmptyDescription>
</EmptyHdr>
</Empty>
)}
{rules && rules.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead className="hidden sm:table-cell">Status</TableHead>
<TableHead className="hidden sm:table-cell">Regex</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<ForwardRuleRow key={rule.id} rule={rule} siteId={site.id} />
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}
function ForwardRuleRow({ rule, siteId }: { rule: ForwardRule; siteId: string }) {
const deleteRule = useDeleteForwardRule(siteId);
return (
<TableRow>
<TableCell className="font-mono text-sm max-w-50 truncate">{rule.source}</TableCell>
<TableCell className="font-mono text-sm max-w-50 truncate">
{rule.destination}
</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="secondary">{rule.status_code}</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
{rule.regex ? <Check className="h-4 w-4" /> : <X className="h-4 w-4" />}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<ForwardRuleDialog
rule={rule}
siteId={siteId}
trigger={
<Button variant="ghost" size="icon" className="h-8 w-8">
<Pencil className="h-3.5 w-3.5" />
</Button>
}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete forward rule?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the redirect from{' '}
<code className="text-sm">{rule.source}</code>. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteRule.mutate(rule.id)}
disabled={deleteRule.isPending}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
);
}
interface HeaderGroupFormData {
source: string;
regex: boolean;
}
interface HeaderFormData {
key: string;
value: string;
}
function CustomHeadersSection({ site }: { site: Site }) {
const { data: headerGroups, isLoading } = useCustomHeaders(site.id);
const [groupOpen, setGroupOpen] = useState(false);
const [groupForm, setGroupForm] = useState<HeaderGroupFormData>({ source: '', regex: false });
const createGroup = useCreateCustomHeaders(site.id);
const handleCreateGroup = () => {
createGroup.mutate(groupForm, {
onSuccess: () => {
setGroupOpen(false);
setGroupForm({ source: '', regex: false });
},
});
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">Custom Headers</CardTitle>
<CardDescription>
Attach custom response headers to matching paths.
</CardDescription>
</div>
<Dialog open={groupOpen} onOpenChange={setGroupOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
Add Group
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Header Group</DialogTitle>
<DialogDescription>
Define a path pattern and then add headers to it.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="hg-source">Path pattern</Label>
<Input
id="hg-source"
placeholder="/*"
value={groupForm.source}
onChange={(e) =>
setGroupForm((f) => ({ ...f, source: e.target.value }))
}
/>
</div>
<Label className="flex items-center gap-2">
<Switch
checked={groupForm.regex}
onCheckedChange={(v) =>
setGroupForm((f) => ({ ...f, regex: v }))
}
/>
<span>Use regex matching</span>
</Label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setGroupOpen(false)}
disabled={createGroup.isPending}
>
Cancel
</Button>
<Button
onClick={handleCreateGroup}
disabled={createGroup.isPending || groupForm.source.trim() === ''}
>
{createGroup.isPending ? 'Creating…' : 'Create Group'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
{isLoading && <p className="text-sm text-muted-foreground">Loading</p>}
{!isLoading && (!headerGroups || headerGroups.length === 0) && (
<Empty>
<EmptyHdr>
<EmptyMedia variant="icon">
<ShieldPlus />
</EmptyMedia>
<EmptyTitle>No custom headers</EmptyTitle>
<EmptyDescription>
Add a header group to attach custom response headers to matching
paths.
</EmptyDescription>
</EmptyHdr>
</Empty>
)}
{headerGroups && headerGroups.length > 0 && (
<Accordion type="multiple" className="w-full">
{headerGroups.map((group) => (
<HeaderGroupItem key={group.id} group={group} siteId={site.id} />
))}
</Accordion>
)}
</CardContent>
</Card>
);
}
function HeaderGroupItem({ group, siteId }: { group: CustomHeaders; siteId: string }) {
const deleteGroup = useDeleteCustomHeaders(group.id, siteId);
const createHeader = useCreateHeader(siteId, group.id);
const [headerOpen, setHeaderOpen] = useState(false);
const [headerForm, setHeaderForm] = useState<HeaderFormData>({ key: '', value: '' });
const handleCreateHeader = () => {
createHeader.mutate(headerForm, {
onSuccess: () => {
setHeaderOpen(false);
setHeaderForm({ key: '', value: '' });
},
});
};
return (
<AccordionItem value={group.id}>
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2">
<code className="text-sm font-mono">{group.source}</code>
{group.regex && <Badge variant="outline">regex</Badge>}
<Badge variant="secondary">
{group.headers?.length ?? 0} header
{(group.headers?.length ?? 0) !== 1 && 's'}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3 pt-1">
{group.headers && group.headers.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Key</TableHead>
<TableHead>Value</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{group.headers.map((header) => (
<HeaderRow key={header.id} header={header} siteId={siteId} />
))}
</TableBody>
</Table>
)}
{(!group.headers || group.headers.length === 0) && (
<p className="text-sm text-muted-foreground py-2">
No headers yet. Add one below.
</p>
)}
<div className="flex items-center gap-2">
<Dialog open={headerOpen} onOpenChange={setHeaderOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="h-3.5 w-3.5 mr-1" />
Add Header
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Header</DialogTitle>
<DialogDescription>
Add a response header for{' '}
<code className="text-sm">{group.source}</code>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="h-key">Header name</Label>
<Input
id="h-key"
placeholder="X-Frame-Options"
value={headerForm.key}
onChange={(e) =>
setHeaderForm((f) => ({
...f,
key: e.target.value,
}))
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="h-value">Header value</Label>
<Input
id="h-value"
placeholder="DENY"
value={headerForm.value}
onChange={(e) =>
setHeaderForm((f) => ({
...f,
value: e.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setHeaderOpen(false)}
disabled={createHeader.isPending}
>
Cancel
</Button>
<Button
onClick={handleCreateHeader}
disabled={
createHeader.isPending ||
headerForm.key.trim() === '' ||
headerForm.value.trim() === ''
}
>
{createHeader.isPending ? 'Adding…' : 'Add Header'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete Group
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete header group?</AlertDialogTitle>
<AlertDialogDescription>
This will remove all headers for{' '}
<code className="text-sm">{group.source}</code>. This cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteGroup.mutate()}
disabled={deleteGroup.isPending}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
}
function HeaderRow({ header, siteId }: { header: Header; siteId: string }) {
const deleteHeader = useDeleteHeader(siteId, header.id);
const updateHeader = useUpdateHeader(siteId, header.id);
const [editOpen, setEditOpen] = useState(false);
const [form, setForm] = useState<HeaderFormData>({ key: header.key, value: header.value });
const handleUpdate = () => {
updateHeader.mutate(form, {
onSuccess: () => setEditOpen(false),
});
};
return (
<TableRow>
<TableCell className="font-mono text-sm">{header.key}</TableCell>
<TableCell className="font-mono text-sm max-w-62.5 truncate">{header.value}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Pencil className="h-3.5 w-3.5" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Header</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Header name</Label>
<Input
value={form.key}
onChange={(e) =>
setForm((f) => ({ ...f, key: e.target.value }))
}
/>
</div>
<div className="grid gap-2">
<Label>Header value</Label>
<Input
value={form.value}
onChange={(e) =>
setForm((f) => ({ ...f, value: e.target.value }))
}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditOpen(false)}
disabled={updateHeader.isPending}
>
Cancel
</Button>
<Button
onClick={handleUpdate}
disabled={
updateHeader.isPending ||
form.key.trim() === '' ||
form.value.trim() === ''
}
>
{updateHeader.isPending ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete header?</AlertDialogTitle>
<AlertDialogDescription>
Remove <code className="text-sm">{header.key}</code>? This
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteHeader.mutate()}
disabled={deleteHeader.isPending}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
);
}
export default function RulesTab({ site }: { site: Site }) {
return (
<div className="space-y-4">
<ForwardRulesSection site={site} />
<CustomHeadersSection site={site} />
</div>
);
}
@@ -5,15 +5,7 @@ import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { import { Globe, LayoutDashboard, Rocket, Settings, X, Route } from 'lucide-react';
ArrowRightLeft,
FileCode,
Globe,
LayoutDashboard,
Rocket,
Settings,
X,
} from 'lucide-react';
import { useSite } from '../../hooks/api/useSite'; import { useSite } from '../../hooks/api/useSite';
import { useToggleSiteEnabled } from '../../hooks/api/useToggleSiteEnabled'; import { useToggleSiteEnabled } from '../../hooks/api/useToggleSiteEnabled';
import SettingsTab from './SettingsTab'; import SettingsTab from './SettingsTab';
@@ -28,14 +20,9 @@ import {
EmptyTitle, EmptyTitle,
} from '../../components/ui/empty'; } from '../../components/ui/empty';
import { useDeploymentsForSite } from '../../hooks/api/useDeploymentsForSite'; import { useDeploymentsForSite } from '../../hooks/api/useDeploymentsForSite';
import RulesTab from './RulesTab';
const VALID_TABS = [ const VALID_TABS = ['overview', 'deployments', 'rules', 'settings'] as const;
'overview',
'deployments',
'forward-rules',
'custom-headers',
'settings',
] as const;
type TabValue = (typeof VALID_TABS)[number]; type TabValue = (typeof VALID_TABS)[number];
const DEFAULT_TAB: TabValue = 'overview'; const DEFAULT_TAB: TabValue = 'overview';
@@ -163,7 +150,7 @@ const SiteOverview = () => {
</div> </div>
<Tabs value={activeTab} onValueChange={handleTabChange}> <Tabs value={activeTab} onValueChange={handleTabChange}>
<div className="flex items-center"> <div className="flex items-center mb-4">
<TabsList> <TabsList>
<TabsTrigger value="overview"> <TabsTrigger value="overview">
<LayoutDashboard /> <LayoutDashboard />
@@ -173,13 +160,9 @@ const SiteOverview = () => {
<Rocket /> <Rocket />
Deployments Deployments
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="forward-rules"> <TabsTrigger value="rules">
<ArrowRightLeft /> <Route />
Forward Rules Rules
</TabsTrigger>
<TabsTrigger value="custom-headers">
<FileCode />
Custom Headers
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="settings"> <TabsTrigger value="settings">
<Settings /> <Settings />
@@ -206,6 +189,10 @@ const SiteOverview = () => {
<DeploymentsTab site={site} deployments={deployments} /> <DeploymentsTab site={site} deployments={deployments} />
</TabsContent> </TabsContent>
<TabsContent value="rules">
<RulesTab site={site} />
</TabsContent>
<TabsContent value="settings"> <TabsContent value="settings">
<SettingsTab site={site} /> <SettingsTab site={site} />
</TabsContent> </TabsContent>