diff --git a/backend/app/cachedrepo/cached_site_repository.go b/backend/app/cachedrepo/cached_site_repository.go index 7e01c6a..9c4a642 100644 --- a/backend/app/cachedrepo/cached_site_repository.go +++ b/backend/app/cachedrepo/cached_site_repository.go @@ -175,22 +175,26 @@ func (c *CachedSiteRepository) CreateForwardRule(siteID string, fr *models.Forwa return nil } -func (c *CachedSiteRepository) UpdateForwardRule(fr *models.ForwardRule) error { - if err := c.inner.UpdateForwardRule(fr); err != nil { +func (c *CachedSiteRepository) UpdateForwardRule(siteID string, fr *models.ForwardRule) error { + if err := c.inner.UpdateForwardRule(siteID, fr); err != nil { return err } c.mu.Lock() c.forwardRules[fr.ID] = fr + delete(c.sites, siteID) + c.siteListValid = false c.mu.Unlock() return nil } -func (c *CachedSiteRepository) DeleteForwardRule(id string) error { - if err := c.inner.DeleteForwardRule(id); err != nil { +func (c *CachedSiteRepository) DeleteForwardRule(siteID string, id string) error { + if err := c.inner.DeleteForwardRule(siteID, id); err != nil { return err } c.mu.Lock() delete(c.forwardRules, id) + delete(c.sites, siteID) + c.siteListValid = false c.mu.Unlock() return nil } @@ -228,22 +232,26 @@ func (c *CachedSiteRepository) CreateCustomHeaders(siteID string, ch *models.Cus return nil } -func (c *CachedSiteRepository) UpdateCustomHeaders(ch *models.CustomHeaders) error { - if err := c.inner.UpdateCustomHeaders(ch); err != nil { +func (c *CachedSiteRepository) UpdateCustomHeaders(siteID string, ch *models.CustomHeaders) error { + if err := c.inner.UpdateCustomHeaders(siteID, ch); err != nil { return err } c.mu.Lock() c.customHeaders[ch.ID] = ch + delete(c.sites, siteID) + c.siteListValid = false c.mu.Unlock() return nil } -func (c *CachedSiteRepository) DeleteCustomHeaders(id string) error { - if err := c.inner.DeleteCustomHeaders(id); err != nil { +func (c *CachedSiteRepository) DeleteCustomHeaders(siteID string, id string) error { + if err := c.inner.DeleteCustomHeaders(siteID, id); err != nil { return err } c.mu.Lock() delete(c.customHeaders, id) + delete(c.sites, siteID) + c.siteListValid = false c.mu.Unlock() return nil } @@ -269,33 +277,39 @@ func (c *CachedSiteRepository) GetHeader(id string) (*models.Header, error) { return h, nil } -func (c *CachedSiteRepository) CreateHeader(customHeaderID string, h *models.Header) error { - if err := c.inner.CreateHeader(customHeaderID, h); err != nil { +func (c *CachedSiteRepository) CreateHeader(siteID string, customHeaderID string, h *models.Header) error { + if err := c.inner.CreateHeader(siteID, customHeaderID, h); err != nil { return err } c.mu.Lock() c.headers[h.ID] = h delete(c.customHeaders, customHeaderID) + delete(c.sites, siteID) + c.siteListValid = false c.mu.Unlock() return nil } -func (c *CachedSiteRepository) UpdateHeader(h *models.Header) error { - if err := c.inner.UpdateHeader(h); err != nil { +func (c *CachedSiteRepository) UpdateHeader(siteID string, h *models.Header) error { + if err := c.inner.UpdateHeader(siteID, h); err != nil { return err } c.mu.Lock() c.headers[h.ID] = h + delete(c.sites, siteID) + c.siteListValid = false c.mu.Unlock() return nil } -func (c *CachedSiteRepository) DeleteHeader(id string) error { - if err := c.inner.DeleteHeader(id); err != nil { +func (c *CachedSiteRepository) DeleteHeader(siteID string, id string) error { + if err := c.inner.DeleteHeader(siteID, id); err != nil { return err } c.mu.Lock() delete(c.headers, id) + delete(c.sites, siteID) + c.siteListValid = false c.mu.Unlock() return nil } diff --git a/backend/app/handlers/site_relations.go b/backend/app/handlers/site_relations.go index 4d609cc..8de8b2b 100644 --- a/backend/app/handlers/site_relations.go +++ b/backend/app/handlers/site_relations.go @@ -97,7 +97,7 @@ func (h *SiteHandler) GetSiteForwardRules(c fiber.Ctx) error { // @Failure 500 {object} models.APIError "Internal server error" // @Router /forward-rules/{id} [get] func (h *SiteHandler) GetForwardRule(c fiber.Ctx) error { - id := c.Params("id") + id := c.Params("ruleId") rule, err := h.Repo.GetForwardRule(id) if err != nil { @@ -169,7 +169,8 @@ func (h *SiteHandler) PostForwardRule(c fiber.Ctx) error { // @Failure 500 {object} models.APIError "Internal server error" // @Router /forward-rules/{id} [put] 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 errors.Is(err, sql.ErrNoRows) { @@ -190,7 +191,7 @@ func (h *SiteHandler) PutForwardRule(c fiber.Ctx) error { } 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) 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" // @Router /forward-rules/{id} [delete] 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 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"}) } - if err := h.Repo.DeleteForwardRule(id); err != nil { + if err := h.Repo.DeleteForwardRule(siteID, id); err != nil { log.Println("Error deleting forward rule: ", err) 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" // @Router /custom-headers/{id} [get] func (h *SiteHandler) GetCustomHeaders(c fiber.Ctx) error { - id := c.Params("id") + id := c.Params("customHeaderId") customHeaders, err := h.Repo.GetCustomHeaders(id) if err != nil { @@ -347,7 +349,8 @@ func (h *SiteHandler) PostCustomHeaders(c fiber.Ctx) error { // @Failure 500 {object} models.APIError "Internal server error" // @Router /custom-headers/{id} [put] 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 errors.Is(err, sql.ErrNoRows) { @@ -368,7 +371,7 @@ func (h *SiteHandler) PutCustomHeaders(c fiber.Ctx) error { } 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) 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" // @Router /custom-headers/{id} [delete] 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 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"}) } - if err := h.Repo.DeleteCustomHeaders(id); err != nil { + if err := h.Repo.DeleteCustomHeaders(siteID, id); err != nil { log.Println("Error deleting custom headers: ", err) 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" // @Router /custom-headers/{id}/headers [get] func (h *SiteHandler) GetCustomHeaderHeaders(c fiber.Ctx) error { - customHeaderID := c.Params("id") + customHeaderID := c.Params("customHeaderId") customHeaders, err := h.Repo.GetCustomHeaders(customHeaderID) if err != nil { @@ -453,7 +457,7 @@ func (h *SiteHandler) GetCustomHeaderHeaders(c fiber.Ctx) error { // @Failure 500 {object} models.APIError "Internal server error" // @Router /headers/{id} [get] func (h *SiteHandler) GetHeader(c fiber.Ctx) error { - id := c.Params("id") + id := c.Params("headerId") header, err := h.Repo.GetHeader(id) if err != nil { @@ -482,7 +486,8 @@ func (h *SiteHandler) GetHeader(c fiber.Ctx) error { // @Failure 500 {object} models.APIError "Internal server error" // @Router /custom-headers/{id}/headers [post] 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 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()}) } - 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) 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" // @Router /headers/{id} [put] 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 errors.Is(err, sql.ErrNoRows) { @@ -546,7 +552,7 @@ func (h *SiteHandler) PutHeader(c fiber.Ctx) error { } 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) 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" // @Router /headers/{id} [delete] 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 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"}) } - if err := h.Repo.DeleteHeader(id); err != nil { + if err := h.Repo.DeleteHeader(siteID, id); err != nil { log.Println("Error deleting header: ", err) return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting header"}) } diff --git a/backend/app/repository/site_repository.go b/backend/app/repository/site_repository.go index 0d3d8be..2f70ef6 100644 --- a/backend/app/repository/site_repository.go +++ b/backend/app/repository/site_repository.go @@ -12,14 +12,14 @@ type SiteRepository interface { DeleteSite(id string) error GetForwardRule(id string) (*models.ForwardRule, error) CreateForwardRule(siteID string, fr *models.ForwardRule) error - UpdateForwardRule(fr *models.ForwardRule) error - DeleteForwardRule(id string) error + UpdateForwardRule(siteID string, fr *models.ForwardRule) error + DeleteForwardRule(siteID string, id string) error GetCustomHeaders(id string) (*models.CustomHeaders, error) CreateCustomHeaders(siteID string, ch *models.CustomHeaders) error - UpdateCustomHeaders(ch *models.CustomHeaders) error - DeleteCustomHeaders(id string) error + UpdateCustomHeaders(siteID string, ch *models.CustomHeaders) error + DeleteCustomHeaders(siteID string, id string) error GetHeader(id string) (*models.Header, error) - CreateHeader(customHeaderID string, h *models.Header) error - UpdateHeader(h *models.Header) error - DeleteHeader(id string) error + CreateHeader(siteID string, customHeaderID string, h *models.Header) error + UpdateHeader(siteID string, h *models.Header) error + DeleteHeader(siteID string, id string) error } diff --git a/backend/app/routes/routes.go b/backend/app/routes/routes.go index 4a4730c..362525a 100644 --- a/backend/app/routes/routes.go +++ b/backend/app/routes/routes.go @@ -39,23 +39,23 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d // 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) + api.Get("/sites/:id/forward-rules/:ruleId", siteHandler.GetForwardRule) + api.Put("/sites/:id/forward-rules/:ruleId", siteHandler.PutForwardRule) + api.Delete("/sites/:id/forward-rules/:ruleId", 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) + api.Get("/sites/:id/custom-headers/:customHeaderId", siteHandler.GetCustomHeaders) + api.Put("/sites/:id/custom-headers/:customHeaderId", siteHandler.PutCustomHeaders) + api.Delete("/sites/:id/custom-headers/:customHeaderId", 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) + api.Get("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.GetCustomHeaderHeaders) + api.Post("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.PostHeader) + api.Get("/sites/:id/headers/:headerId", siteHandler.GetHeader) + api.Put("/sites/:id/headers/:headerId", siteHandler.PutHeader) + api.Delete("/sites/:id/headers/:headerId", siteHandler.DeleteHeader) // Deployments api.Get("/deployments/:id", deploymentsHandler.GetDeployment) diff --git a/backend/internal/database/site_sqlite.go b/backend/internal/database/site_sqlite.go index 22105d7..2a40c07 100644 --- a/backend/internal/database/site_sqlite.go +++ b/backend/internal/database/site_sqlite.go @@ -233,7 +233,7 @@ func (r *SQLiteSiteRepository) CreateForwardRule(siteID string, fr *models.Forwa 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(` UPDATE forward_rules SET source=?, destination=?, status_code=?, regex=? WHERE 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 } -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) if err != nil { return fmt.Errorf("delete forward rule: %w", err) @@ -284,7 +284,7 @@ func (r *SQLiteSiteRepository) CreateCustomHeaders(siteID string, ch *models.Cus 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() if err != nil { 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() } -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) if err != nil { return fmt.Errorf("delete custom headers: %w", err) @@ -325,7 +325,7 @@ func (r *SQLiteSiteRepository) GetHeader(id string) (*models.Header, error) { 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() _, err := r.db.Exec( `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 } -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) if err != nil { return fmt.Errorf("update header: %w", err) @@ -345,7 +345,7 @@ func (r *SQLiteSiteRepository) UpdateHeader(h *models.Header) error { 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) if err != nil { return fmt.Errorf("delete header: %w", err) diff --git a/frontend/src/api/customHeaders.api.ts b/frontend/src/api/customHeaders.api.ts new file mode 100644 index 0000000..24c70ec --- /dev/null +++ b/frontend/src/api/customHeaders.api.ts @@ -0,0 +1,106 @@ +import { makeApiUrl } from '.'; +import type { + CreateCustomHeadersRequest, + CreateHeaderRequest, + CustomHeaders, + Header, +} from './types/site'; + +export const getCustomHeaders = async (siteId: string): Promise => { + 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 => { + 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 => { + 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 => { + 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
=> { + 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
=> { + 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 => { + const response = await fetch(makeApiUrl(`/sites/${siteId}/headers/${headerId}`), { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error('Failed to delete header'); + } +}; diff --git a/frontend/src/api/forwardrules.api.ts b/frontend/src/api/forwardrules.api.ts new file mode 100644 index 0000000..f48580c --- /dev/null +++ b/frontend/src/api/forwardrules.api.ts @@ -0,0 +1,56 @@ +import { makeApiUrl } from '.'; +import type { CreateForwardRuleRequest, ForwardRule } from './types/site'; + +export const getForwardRules = async (siteId: string): Promise => { + 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 => { + 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 => { + 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 => { + const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules/${ruleId}`), { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error('Failed to delete forward rule'); + } +}; diff --git a/frontend/src/api/types/site.ts b/frontend/src/api/types/site.ts index 29d350e..5ee8bfe 100644 --- a/frontend/src/api/types/site.ts +++ b/frontend/src/api/types/site.ts @@ -64,3 +64,20 @@ export interface CreateSiteResponse { export interface ToggleSiteEnabledResponse { 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; +} diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx new file mode 100644 index 0000000..fcfee5c --- /dev/null +++ b/frontend/src/components/ui/accordion.tsx @@ -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) { + return ( + + ) +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
+ {children} +
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..186efdb --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -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) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/frontend/src/hooks/api/forwardRules/useCreateForwardRule.ts b/frontend/src/hooks/api/forwardRules/useCreateForwardRule.ts new file mode 100644 index 0000000..b4fd261 --- /dev/null +++ b/frontend/src/hooks/api/forwardRules/useCreateForwardRule.ts @@ -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], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/forwardRules/useDeleteForwardRule.ts b/frontend/src/hooks/api/forwardRules/useDeleteForwardRule.ts new file mode 100644 index 0000000..916baa4 --- /dev/null +++ b/frontend/src/hooks/api/forwardRules/useDeleteForwardRule.ts @@ -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], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/forwardRules/useForwardRules.ts b/frontend/src/hooks/api/forwardRules/useForwardRules.ts new file mode 100644 index 0000000..bfaae12 --- /dev/null +++ b/frontend/src/hooks/api/forwardRules/useForwardRules.ts @@ -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, + }); +} diff --git a/frontend/src/hooks/api/forwardRules/useUpdateForwardRule.ts b/frontend/src/hooks/api/forwardRules/useUpdateForwardRule.ts new file mode 100644 index 0000000..c62a74d --- /dev/null +++ b/frontend/src/hooks/api/forwardRules/useUpdateForwardRule.ts @@ -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], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/useCreateCustomHeaders.ts b/frontend/src/hooks/api/useCreateCustomHeaders.ts new file mode 100644 index 0000000..9a0e1c3 --- /dev/null +++ b/frontend/src/hooks/api/useCreateCustomHeaders.ts @@ -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], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/useCreateHeader.ts b/frontend/src/hooks/api/useCreateHeader.ts new file mode 100644 index 0000000..0bf81c4 --- /dev/null +++ b/frontend/src/hooks/api/useCreateHeader.ts @@ -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], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/useCustomHeaders.ts b/frontend/src/hooks/api/useCustomHeaders.ts new file mode 100644 index 0000000..82130be --- /dev/null +++ b/frontend/src/hooks/api/useCustomHeaders.ts @@ -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({ + queryKey: ['customHeaders', siteId], + queryFn: () => getCustomHeaders(siteId), + enabled: !!siteId, + }); +} diff --git a/frontend/src/hooks/api/useDeleteCustomHeaders.ts b/frontend/src/hooks/api/useDeleteCustomHeaders.ts new file mode 100644 index 0000000..eb8e04a --- /dev/null +++ b/frontend/src/hooks/api/useDeleteCustomHeaders.ts @@ -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], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/useDeleteHeader.ts b/frontend/src/hooks/api/useDeleteHeader.ts new file mode 100644 index 0000000..cb80bd7 --- /dev/null +++ b/frontend/src/hooks/api/useDeleteHeader.ts @@ -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], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/useUpdateCustomHeaders.ts b/frontend/src/hooks/api/useUpdateCustomHeaders.ts new file mode 100644 index 0000000..f569581 --- /dev/null +++ b/frontend/src/hooks/api/useUpdateCustomHeaders.ts @@ -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], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/useUpdateHeader.ts b/frontend/src/hooks/api/useUpdateHeader.ts new file mode 100644 index 0000000..72382ed --- /dev/null +++ b/frontend/src/hooks/api/useUpdateHeader.ts @@ -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], + }); + }, + }); +} diff --git a/frontend/src/pages/SiteOverview/RulesTab.tsx b/frontend/src/pages/SiteOverview/RulesTab.tsx new file mode 100644 index 0000000..f32b7d2 --- /dev/null +++ b/frontend/src/pages/SiteOverview/RulesTab.tsx @@ -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( + 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 ( + + {trigger} + + + {isEdit ? 'Edit' : 'New'} Forward Rule + + {isEdit + ? 'Update the source, destination, and options for this rule.' + : 'Redirect requests from one path to another.'} + + + +
+
+ + setForm((f) => ({ ...f, source: e.target.value }))} + /> +
+ +
+ + + setForm((f) => ({ ...f, destination: e.target.value })) + } + /> +
+ +
+ + +
+ + +
+ + + + + +
+
+ ); +} + +function ForwardRulesSection({ site }: { site: Site }) { + const { data: rules, isLoading } = useForwardRules(site.id); + + return ( + + +
+ Forward Rules + Redirect or rewrite incoming request paths. +
+ + + Add Rule + + } + /> +
+ + {isLoading &&

Loading…

} + + {!isLoading && (!rules || rules.length === 0) && ( + + + + + + No forward rules + + Add a rule to redirect or rewrite request paths. + + + + )} + + {rules && rules.length > 0 && ( + + + + Source + Destination + Status + Regex + + + + + {rules.map((rule) => ( + + ))} + +
+ )} +
+
+ ); +} + +function ForwardRuleRow({ rule, siteId }: { rule: ForwardRule; siteId: string }) { + const deleteRule = useDeleteForwardRule(siteId); + + return ( + + {rule.source} + + {rule.destination} + + + {rule.status_code} + + + {rule.regex ? : } + + +
+ + + + } + /> + + + + + + + Delete forward rule? + + This will remove the redirect from{' '} + {rule.source}. This action + cannot be undone. + + + + Cancel + deleteRule.mutate(rule.id)} + disabled={deleteRule.isPending} + > + Delete + + + + +
+
+
+ ); +} + +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({ source: '', regex: false }); + const createGroup = useCreateCustomHeaders(site.id); + + const handleCreateGroup = () => { + createGroup.mutate(groupForm, { + onSuccess: () => { + setGroupOpen(false); + setGroupForm({ source: '', regex: false }); + }, + }); + }; + + return ( + + +
+ Custom Headers + + Attach custom response headers to matching paths. + +
+ + + + + + + New Header Group + + Define a path pattern and then add headers to it. + + +
+
+ + + setGroupForm((f) => ({ ...f, source: e.target.value })) + } + /> +
+ +
+ + + + +
+
+
+ + {isLoading &&

Loading…

} + + {!isLoading && (!headerGroups || headerGroups.length === 0) && ( + + + + + + No custom headers + + Add a header group to attach custom response headers to matching + paths. + + + + )} + + {headerGroups && headerGroups.length > 0 && ( + + {headerGroups.map((group) => ( + + ))} + + )} +
+
+ ); +} + +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({ key: '', value: '' }); + + const handleCreateHeader = () => { + createHeader.mutate(headerForm, { + onSuccess: () => { + setHeaderOpen(false); + setHeaderForm({ key: '', value: '' }); + }, + }); + }; + + return ( + + +
+ {group.source} + {group.regex && regex} + + {group.headers?.length ?? 0} header + {(group.headers?.length ?? 0) !== 1 && 's'} + +
+
+ +
+ {group.headers && group.headers.length > 0 && ( + + + + Key + Value + + + + + {group.headers.map((header) => ( + + ))} + +
+ )} + + {(!group.headers || group.headers.length === 0) && ( +

+ No headers yet. Add one below. +

+ )} + +
+ + + + + + + New Header + + Add a response header for{' '} + {group.source} + + +
+
+ + + setHeaderForm((f) => ({ + ...f, + key: e.target.value, + })) + } + /> +
+
+ + + setHeaderForm((f) => ({ + ...f, + value: e.target.value, + })) + } + /> +
+
+ + + + +
+
+ + + + + + + + Delete header group? + + This will remove all headers for{' '} + {group.source}. This cannot + be undone. + + + + Cancel + deleteGroup.mutate()} + disabled={deleteGroup.isPending} + > + Delete + + + + +
+
+
+
+ ); +} + +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({ key: header.key, value: header.value }); + + const handleUpdate = () => { + updateHeader.mutate(form, { + onSuccess: () => setEditOpen(false), + }); + }; + + return ( + + {header.key} + {header.value} + +
+ + + + + + + Edit Header + +
+
+ + + setForm((f) => ({ ...f, key: e.target.value })) + } + /> +
+
+ + + setForm((f) => ({ ...f, value: e.target.value })) + } + /> +
+
+ + + + +
+
+ + + + + + + + Delete header? + + Remove {header.key}? This + cannot be undone. + + + + Cancel + deleteHeader.mutate()} + disabled={deleteHeader.isPending} + > + Delete + + + + +
+
+
+ ); +} + +export default function RulesTab({ site }: { site: Site }) { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/pages/SiteOverview/SiteOverview.tsx b/frontend/src/pages/SiteOverview/SiteOverview.tsx index bd229b4..d3449a5 100644 --- a/frontend/src/pages/SiteOverview/SiteOverview.tsx +++ b/frontend/src/pages/SiteOverview/SiteOverview.tsx @@ -5,15 +5,7 @@ import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { - ArrowRightLeft, - FileCode, - Globe, - LayoutDashboard, - Rocket, - Settings, - X, -} from 'lucide-react'; +import { Globe, LayoutDashboard, Rocket, Settings, X, Route } from 'lucide-react'; import { useSite } from '../../hooks/api/useSite'; import { useToggleSiteEnabled } from '../../hooks/api/useToggleSiteEnabled'; import SettingsTab from './SettingsTab'; @@ -28,14 +20,9 @@ import { EmptyTitle, } from '../../components/ui/empty'; import { useDeploymentsForSite } from '../../hooks/api/useDeploymentsForSite'; +import RulesTab from './RulesTab'; -const VALID_TABS = [ - 'overview', - 'deployments', - 'forward-rules', - 'custom-headers', - 'settings', -] as const; +const VALID_TABS = ['overview', 'deployments', 'rules', 'settings'] as const; type TabValue = (typeof VALID_TABS)[number]; const DEFAULT_TAB: TabValue = 'overview'; @@ -163,7 +150,7 @@ const SiteOverview = () => {
-
+
@@ -173,13 +160,9 @@ const SiteOverview = () => { Deployments - - - Forward Rules - - - - Custom Headers + + + Rules @@ -206,6 +189,10 @@ const SiteOverview = () => { + + + +