feat(firewall): Inline-Note + Labels direkt in der Tabelle
- Migration 0035: note TEXT + labels TEXT[] für firewall_rules + nat_rules - PATCH /firewall/rules/:id + /nat-rules/:id für note/labels Updates - InlineNote: gold Tag mit MessageOutlined, Klick zum Bearbeiten (Enter/Blur speichert) - InlineLabels: geekblue Tags mit X-Button zum Entfernen, "+" zum Hinzufügen - Name-Spalte: Name + Labels + Note in Zeile 1, comment/auto-desc in Zeile 2 - Gleiche UX für NAT-Regeln Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
17
internal/database/migrations/0035_firewall_note_labels.sql
Normal file
17
internal/database/migrations/0035_firewall_note_labels.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE firewall_rules
|
||||||
|
ADD COLUMN IF NOT EXISTS note TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS labels TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
ALTER TABLE firewall_nat_rules
|
||||||
|
ADD COLUMN IF NOT EXISTS note TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS labels TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE firewall_rules
|
||||||
|
DROP COLUMN IF EXISTS note,
|
||||||
|
DROP COLUMN IF EXISTS labels;
|
||||||
|
|
||||||
|
ALTER TABLE firewall_nat_rules
|
||||||
|
DROP COLUMN IF EXISTS note,
|
||||||
|
DROP COLUMN IF EXISTS labels;
|
||||||
@@ -138,6 +138,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
|||||||
rl.POST("", h.CreateRule)
|
rl.POST("", h.CreateRule)
|
||||||
rl.GET("/:id", h.GetRule)
|
rl.GET("/:id", h.GetRule)
|
||||||
rl.PUT("/:id", h.UpdateRule)
|
rl.PUT("/:id", h.UpdateRule)
|
||||||
|
rl.PATCH("/:id", h.PatchRule)
|
||||||
rl.DELETE("/:id", h.DeleteRule)
|
rl.DELETE("/:id", h.DeleteRule)
|
||||||
|
|
||||||
nat := g.Group("/nat-rules")
|
nat := g.Group("/nat-rules")
|
||||||
@@ -145,6 +146,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
|||||||
nat.POST("", h.CreateNAT)
|
nat.POST("", h.CreateNAT)
|
||||||
nat.GET("/:id", h.GetNAT)
|
nat.GET("/:id", h.GetNAT)
|
||||||
nat.PUT("/:id", h.UpdateNAT)
|
nat.PUT("/:id", h.UpdateNAT)
|
||||||
|
nat.PATCH("/:id", h.PatchNAT)
|
||||||
nat.DELETE("/:id", h.DeleteNAT)
|
nat.DELETE("/:id", h.DeleteNAT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,6 +760,48 @@ func (h *FirewallHandler) DeleteRule(c *gin.Context) {
|
|||||||
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *FirewallHandler) PatchRule(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Note *string `json:"note"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if body.Note != nil {
|
||||||
|
if err := h.Rules.PatchNote(ctx, id, *body.Note); err != nil {
|
||||||
|
if errors.Is(err, firewall.ErrRuleNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Labels != nil {
|
||||||
|
if err := h.Rules.PatchLabels(ctx, id, body.Labels); err != nil {
|
||||||
|
if errors.Is(err, firewall.ErrRuleNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := h.Rules.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, out)
|
||||||
|
}
|
||||||
|
|
||||||
// ── NAT Rules ──────────────────────────────────────────────────────────
|
// ── NAT Rules ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *FirewallHandler) ListNAT(c *gin.Context) {
|
func (h *FirewallHandler) ListNAT(c *gin.Context) {
|
||||||
@@ -858,6 +902,48 @@ func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
|
|||||||
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *FirewallHandler) PatchNAT(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Note *string `json:"note"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if body.Note != nil {
|
||||||
|
if err := h.NATRules.PatchNote(ctx, id, *body.Note); err != nil {
|
||||||
|
if errors.Is(err, firewall.ErrNATRuleNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Labels != nil {
|
||||||
|
if err := h.NATRules.PatchLabels(ctx, id, body.Labels); err != nil {
|
||||||
|
if errors.Is(err, firewall.ErrNATRuleNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := h.NATRules.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, out)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Validators ─────────────────────────────────────────────────────────
|
// ── Validators ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func validateAddrObjValue(kind, value string) error {
|
func validateAddrObjValue(kind, value string) error {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type FirewallNATRule struct {
|
|||||||
TargetPortEnd *int `gorm:"column:target_port_end" json:"target_port_end,omitempty"`
|
TargetPortEnd *int `gorm:"column:target_port_end" json:"target_port_end,omitempty"`
|
||||||
|
|
||||||
Comment *string `gorm:"column:comment" json:"comment,omitempty"`
|
Comment *string `gorm:"column:comment" json:"comment,omitempty"`
|
||||||
|
Note *string `gorm:"column:note" json:"note,omitempty"`
|
||||||
|
Labels []string `gorm:"column:labels" json:"labels"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type FirewallRule struct {
|
|||||||
|
|
||||||
Log bool `gorm:"column:log" json:"log"`
|
Log bool `gorm:"column:log" json:"log"`
|
||||||
Comment *string `gorm:"column:comment" json:"comment,omitempty"`
|
Comment *string `gorm:"column:comment" json:"comment,omitempty"`
|
||||||
|
Note *string `gorm:"column:note" json:"note,omitempty"`
|
||||||
|
Labels []string `gorm:"column:labels;serializer:json" json:"labels"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ SELECT id, name, priority, enabled, kind,
|
|||||||
in_zone, out_zone, proto,
|
in_zone, out_zone, proto,
|
||||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
||||||
target_addr, target_port_start, target_port_end,
|
target_addr, target_port_start, target_port_end,
|
||||||
comment, created_at, updated_at
|
comment, note, labels, created_at, updated_at
|
||||||
FROM firewall_nat_rules
|
FROM firewall_nat_rules
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -57,52 +57,58 @@ func (r *NATRulesRepo) Get(ctx context.Context, id int64) (*models.FirewallNATRu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *NATRulesRepo) Create(ctx context.Context, x models.FirewallNATRule) (*models.FirewallNATRule, error) {
|
func (r *NATRulesRepo) Create(ctx context.Context, x models.FirewallNATRule) (*models.FirewallNATRule, error) {
|
||||||
|
if x.Labels == nil {
|
||||||
|
x.Labels = []string{}
|
||||||
|
}
|
||||||
row := r.Pool.QueryRow(ctx, `
|
row := r.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO firewall_nat_rules (
|
INSERT INTO firewall_nat_rules (
|
||||||
name, priority, enabled, kind,
|
name, priority, enabled, kind,
|
||||||
in_zone, out_zone, proto,
|
in_zone, out_zone, proto,
|
||||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
||||||
target_addr, target_port_start, target_port_end,
|
target_addr, target_port_start, target_port_end,
|
||||||
comment
|
comment, note, labels
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4,
|
$1, $2, $3, $4,
|
||||||
$5, $6, $7,
|
$5, $6, $7,
|
||||||
$8, $9, $10, $11,
|
$8, $9, $10, $11,
|
||||||
$12, $13, $14,
|
$12, $13, $14,
|
||||||
$15
|
$15, $16, $17
|
||||||
)
|
)
|
||||||
RETURNING id, name, priority, enabled, kind,
|
RETURNING id, name, priority, enabled, kind,
|
||||||
in_zone, out_zone, proto,
|
in_zone, out_zone, proto,
|
||||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
||||||
target_addr, target_port_start, target_port_end,
|
target_addr, target_port_start, target_port_end,
|
||||||
comment, created_at, updated_at`,
|
comment, note, labels, created_at, updated_at`,
|
||||||
x.Name, x.Priority, x.Enabled, x.Kind,
|
x.Name, x.Priority, x.Enabled, x.Kind,
|
||||||
x.InZone, x.OutZone, x.Proto,
|
x.InZone, x.OutZone, x.Proto,
|
||||||
x.MatchSrcCIDR, x.MatchDstCIDR, x.MatchDPortStart, x.MatchDPortEnd,
|
x.MatchSrcCIDR, x.MatchDstCIDR, x.MatchDPortStart, x.MatchDPortEnd,
|
||||||
x.TargetAddr, x.TargetPortStart, x.TargetPortEnd,
|
x.TargetAddr, x.TargetPortStart, x.TargetPortEnd,
|
||||||
x.Comment)
|
x.Comment, x.Note, x.Labels)
|
||||||
return scanNATRule(row)
|
return scanNATRule(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *NATRulesRepo) Update(ctx context.Context, id int64, x models.FirewallNATRule) (*models.FirewallNATRule, error) {
|
func (r *NATRulesRepo) Update(ctx context.Context, id int64, x models.FirewallNATRule) (*models.FirewallNATRule, error) {
|
||||||
|
if x.Labels == nil {
|
||||||
|
x.Labels = []string{}
|
||||||
|
}
|
||||||
row := r.Pool.QueryRow(ctx, `
|
row := r.Pool.QueryRow(ctx, `
|
||||||
UPDATE firewall_nat_rules SET
|
UPDATE firewall_nat_rules SET
|
||||||
name = $1, priority = $2, enabled = $3, kind = $4,
|
name = $1, priority = $2, enabled = $3, kind = $4,
|
||||||
in_zone = $5, out_zone = $6, proto = $7,
|
in_zone = $5, out_zone = $6, proto = $7,
|
||||||
match_src_cidr = $8, match_dst_cidr = $9, match_dport_start = $10, match_dport_end = $11,
|
match_src_cidr = $8, match_dst_cidr = $9, match_dport_start = $10, match_dport_end = $11,
|
||||||
target_addr = $12, target_port_start = $13, target_port_end = $14,
|
target_addr = $12, target_port_start = $13, target_port_end = $14,
|
||||||
comment = $15, updated_at = NOW()
|
comment = $15, note = $16, labels = $17, updated_at = NOW()
|
||||||
WHERE id = $16
|
WHERE id = $18
|
||||||
RETURNING id, name, priority, enabled, kind,
|
RETURNING id, name, priority, enabled, kind,
|
||||||
in_zone, out_zone, proto,
|
in_zone, out_zone, proto,
|
||||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
||||||
target_addr, target_port_start, target_port_end,
|
target_addr, target_port_start, target_port_end,
|
||||||
comment, created_at, updated_at`,
|
comment, note, labels, created_at, updated_at`,
|
||||||
x.Name, x.Priority, x.Enabled, x.Kind,
|
x.Name, x.Priority, x.Enabled, x.Kind,
|
||||||
x.InZone, x.OutZone, x.Proto,
|
x.InZone, x.OutZone, x.Proto,
|
||||||
x.MatchSrcCIDR, x.MatchDstCIDR, x.MatchDPortStart, x.MatchDPortEnd,
|
x.MatchSrcCIDR, x.MatchDstCIDR, x.MatchDPortStart, x.MatchDPortEnd,
|
||||||
x.TargetAddr, x.TargetPortStart, x.TargetPortEnd,
|
x.TargetAddr, x.TargetPortStart, x.TargetPortEnd,
|
||||||
x.Comment, id)
|
x.Comment, x.Note, x.Labels, id)
|
||||||
out, err := scanNATRule(row)
|
out, err := scanNATRule(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -124,6 +130,37 @@ func (r *NATRulesRepo) Delete(ctx context.Context, id int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PatchNote updates only the note field of a NAT rule.
|
||||||
|
func (r *NATRulesRepo) PatchNote(ctx context.Context, id int64, note string) error {
|
||||||
|
var n *string
|
||||||
|
if note != "" {
|
||||||
|
n = ¬e
|
||||||
|
}
|
||||||
|
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_nat_rules SET note = $1, updated_at = NOW() WHERE id = $2`, n, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNATRuleNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatchLabels replaces the labels array of a NAT rule.
|
||||||
|
func (r *NATRulesRepo) PatchLabels(ctx context.Context, id int64, labels []string) error {
|
||||||
|
if labels == nil {
|
||||||
|
labels = []string{}
|
||||||
|
}
|
||||||
|
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_nat_rules SET labels = $1, updated_at = NOW() WHERE id = $2`, labels, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNATRuleNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func scanNATRule(row interface{ Scan(...any) error }) (*models.FirewallNATRule, error) {
|
func scanNATRule(row interface{ Scan(...any) error }) (*models.FirewallNATRule, error) {
|
||||||
var x models.FirewallNATRule
|
var x models.FirewallNATRule
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
@@ -131,9 +168,12 @@ func scanNATRule(row interface{ Scan(...any) error }) (*models.FirewallNATRule,
|
|||||||
&x.InZone, &x.OutZone, &x.Proto,
|
&x.InZone, &x.OutZone, &x.Proto,
|
||||||
&x.MatchSrcCIDR, &x.MatchDstCIDR, &x.MatchDPortStart, &x.MatchDPortEnd,
|
&x.MatchSrcCIDR, &x.MatchDstCIDR, &x.MatchDPortStart, &x.MatchDPortEnd,
|
||||||
&x.TargetAddr, &x.TargetPortStart, &x.TargetPortEnd,
|
&x.TargetAddr, &x.TargetPortStart, &x.TargetPortEnd,
|
||||||
&x.Comment, &x.CreatedAt, &x.UpdatedAt,
|
&x.Comment, &x.Note, &x.Labels, &x.CreatedAt, &x.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if x.Labels == nil {
|
||||||
|
x.Labels = []string{}
|
||||||
|
}
|
||||||
return &x, nil
|
return &x, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ SELECT id, name, priority, enabled, action,
|
|||||||
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
||||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||||
service_object_id, service_group_id,
|
service_object_id, service_group_id,
|
||||||
log, comment, created_at, updated_at
|
log, comment, note, labels, created_at, updated_at
|
||||||
FROM firewall_rules
|
FROM firewall_rules
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -57,52 +57,58 @@ func (r *RulesRepo) Get(ctx context.Context, id int64) (*models.FirewallRule, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *RulesRepo) Create(ctx context.Context, x models.FirewallRule) (*models.FirewallRule, error) {
|
func (r *RulesRepo) Create(ctx context.Context, x models.FirewallRule) (*models.FirewallRule, error) {
|
||||||
|
if x.Labels == nil {
|
||||||
|
x.Labels = []string{}
|
||||||
|
}
|
||||||
row := r.Pool.QueryRow(ctx, `
|
row := r.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO firewall_rules (
|
INSERT INTO firewall_rules (
|
||||||
name, priority, enabled, action,
|
name, priority, enabled, action,
|
||||||
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
||||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||||
service_object_id, service_group_id,
|
service_object_id, service_group_id,
|
||||||
log, comment
|
log, comment, note, labels
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4,
|
$1, $2, $3, $4,
|
||||||
$5, $6, $7, $8,
|
$5, $6, $7, $8,
|
||||||
$9, $10, $11, $12,
|
$9, $10, $11, $12,
|
||||||
$13, $14,
|
$13, $14,
|
||||||
$15, $16
|
$15, $16, $17, $18
|
||||||
)
|
)
|
||||||
RETURNING id, name, priority, enabled, action,
|
RETURNING id, name, priority, enabled, action,
|
||||||
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
||||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||||
service_object_id, service_group_id,
|
service_object_id, service_group_id,
|
||||||
log, comment, created_at, updated_at`,
|
log, comment, note, labels, created_at, updated_at`,
|
||||||
x.Name, x.Priority, x.Enabled, x.Action,
|
x.Name, x.Priority, x.Enabled, x.Action,
|
||||||
x.SrcZone, x.SrcAddressObjectID, x.SrcAddressGroupID, x.SrcCIDR,
|
x.SrcZone, x.SrcAddressObjectID, x.SrcAddressGroupID, x.SrcCIDR,
|
||||||
x.DstZone, x.DstAddressObjectID, x.DstAddressGroupID, x.DstCIDR,
|
x.DstZone, x.DstAddressObjectID, x.DstAddressGroupID, x.DstCIDR,
|
||||||
x.ServiceObjectID, x.ServiceGroupID,
|
x.ServiceObjectID, x.ServiceGroupID,
|
||||||
x.Log, x.Comment)
|
x.Log, x.Comment, x.Note, x.Labels)
|
||||||
return scanRule(row)
|
return scanRule(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RulesRepo) Update(ctx context.Context, id int64, x models.FirewallRule) (*models.FirewallRule, error) {
|
func (r *RulesRepo) Update(ctx context.Context, id int64, x models.FirewallRule) (*models.FirewallRule, error) {
|
||||||
|
if x.Labels == nil {
|
||||||
|
x.Labels = []string{}
|
||||||
|
}
|
||||||
row := r.Pool.QueryRow(ctx, `
|
row := r.Pool.QueryRow(ctx, `
|
||||||
UPDATE firewall_rules SET
|
UPDATE firewall_rules SET
|
||||||
name = $1, priority = $2, enabled = $3, action = $4,
|
name = $1, priority = $2, enabled = $3, action = $4,
|
||||||
src_zone = $5, src_address_object_id = $6, src_address_group_id = $7, src_cidr = $8,
|
src_zone = $5, src_address_object_id = $6, src_address_group_id = $7, src_cidr = $8,
|
||||||
dst_zone = $9, dst_address_object_id = $10, dst_address_group_id = $11, dst_cidr = $12,
|
dst_zone = $9, dst_address_object_id = $10, dst_address_group_id = $11, dst_cidr = $12,
|
||||||
service_object_id = $13, service_group_id = $14,
|
service_object_id = $13, service_group_id = $14,
|
||||||
log = $15, comment = $16, updated_at = NOW()
|
log = $15, comment = $16, note = $17, labels = $18, updated_at = NOW()
|
||||||
WHERE id = $17
|
WHERE id = $19
|
||||||
RETURNING id, name, priority, enabled, action,
|
RETURNING id, name, priority, enabled, action,
|
||||||
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
||||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||||
service_object_id, service_group_id,
|
service_object_id, service_group_id,
|
||||||
log, comment, created_at, updated_at`,
|
log, comment, note, labels, created_at, updated_at`,
|
||||||
x.Name, x.Priority, x.Enabled, x.Action,
|
x.Name, x.Priority, x.Enabled, x.Action,
|
||||||
x.SrcZone, x.SrcAddressObjectID, x.SrcAddressGroupID, x.SrcCIDR,
|
x.SrcZone, x.SrcAddressObjectID, x.SrcAddressGroupID, x.SrcCIDR,
|
||||||
x.DstZone, x.DstAddressObjectID, x.DstAddressGroupID, x.DstCIDR,
|
x.DstZone, x.DstAddressObjectID, x.DstAddressGroupID, x.DstCIDR,
|
||||||
x.ServiceObjectID, x.ServiceGroupID,
|
x.ServiceObjectID, x.ServiceGroupID,
|
||||||
x.Log, x.Comment, id)
|
x.Log, x.Comment, x.Note, x.Labels, id)
|
||||||
out, err := scanRule(row)
|
out, err := scanRule(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -124,6 +130,37 @@ func (r *RulesRepo) Delete(ctx context.Context, id int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PatchNote updates only the note field of a rule.
|
||||||
|
func (r *RulesRepo) PatchNote(ctx context.Context, id int64, note string) error {
|
||||||
|
var n *string
|
||||||
|
if note != "" {
|
||||||
|
n = ¬e
|
||||||
|
}
|
||||||
|
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_rules SET note = $1, updated_at = NOW() WHERE id = $2`, n, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrRuleNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatchLabels replaces the labels array of a rule.
|
||||||
|
func (r *RulesRepo) PatchLabels(ctx context.Context, id int64, labels []string) error {
|
||||||
|
if labels == nil {
|
||||||
|
labels = []string{}
|
||||||
|
}
|
||||||
|
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_rules SET labels = $1, updated_at = NOW() WHERE id = $2`, labels, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrRuleNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func scanRule(row interface{ Scan(...any) error }) (*models.FirewallRule, error) {
|
func scanRule(row interface{ Scan(...any) error }) (*models.FirewallRule, error) {
|
||||||
var x models.FirewallRule
|
var x models.FirewallRule
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
@@ -131,9 +168,12 @@ func scanRule(row interface{ Scan(...any) error }) (*models.FirewallRule, error)
|
|||||||
&x.SrcZone, &x.SrcAddressObjectID, &x.SrcAddressGroupID, &x.SrcCIDR,
|
&x.SrcZone, &x.SrcAddressObjectID, &x.SrcAddressGroupID, &x.SrcCIDR,
|
||||||
&x.DstZone, &x.DstAddressObjectID, &x.DstAddressGroupID, &x.DstCIDR,
|
&x.DstZone, &x.DstAddressObjectID, &x.DstAddressGroupID, &x.DstCIDR,
|
||||||
&x.ServiceObjectID, &x.ServiceGroupID,
|
&x.ServiceObjectID, &x.ServiceGroupID,
|
||||||
&x.Log, &x.Comment, &x.CreatedAt, &x.UpdatedAt,
|
&x.Log, &x.Comment, &x.Note, &x.Labels, &x.CreatedAt, &x.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if x.Labels == nil {
|
||||||
|
x.Labels = []string{}
|
||||||
|
}
|
||||||
return &x, nil
|
return &x, nil
|
||||||
}
|
}
|
||||||
|
|||||||
145
management-ui/src/pages/Firewall/InlineEditors.tsx
Normal file
145
management-ui/src/pages/Firewall/InlineEditors.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Input, Tag, Tooltip } from 'antd'
|
||||||
|
import { MessageOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
// ── InlineNote ────────────────────────────────────────────────────────
|
||||||
|
// Click the grey icon to add a note, click the gold tag to edit it.
|
||||||
|
// Saves on Enter/blur, discards on Escape. No modal needed.
|
||||||
|
|
||||||
|
interface InlineNoteProps {
|
||||||
|
value?: string | null
|
||||||
|
disabled?: boolean
|
||||||
|
onSave: (note: string) => void
|
||||||
|
addTitle?: string
|
||||||
|
editTitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineNote({ value, disabled, onSave, addTitle = 'Notiz hinzufügen', editTitle = 'Klicken zum Bearbeiten' }: InlineNoteProps) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [text, setText] = useState(value ?? '')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const originalRef = useRef(value ?? '')
|
||||||
|
|
||||||
|
useEffect(() => { setText(value ?? ''); originalRef.current = value ?? '' }, [value])
|
||||||
|
useEffect(() => { if (editing) inputRef.current?.focus() }, [editing])
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
setEditing(false)
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (trimmed !== originalRef.current) onSave(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef as never}
|
||||||
|
size="small"
|
||||||
|
value={text}
|
||||||
|
onChange={e => setText(e.target.value)}
|
||||||
|
onPressEnter={save}
|
||||||
|
onBlur={save}
|
||||||
|
onKeyDown={e => { if (e.key === 'Escape') { setText(originalRef.current); setEditing(false) } }}
|
||||||
|
placeholder="Notiz…"
|
||||||
|
style={{ fontSize: 11, maxWidth: 260 }}
|
||||||
|
allowClear
|
||||||
|
suffix={<span style={{ fontSize: 9, color: '#94A3B8' }}>Enter ⏎</span>}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={disabled ? undefined : editTitle}>
|
||||||
|
<Tag
|
||||||
|
color="gold"
|
||||||
|
style={{ fontSize: 10, cursor: disabled ? 'default' : 'pointer', maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', margin: 0 }}
|
||||||
|
onClick={e => { if (disabled) return; e.stopPropagation(); setEditing(true) }}
|
||||||
|
>
|
||||||
|
<MessageOutlined style={{ marginRight: 3 }} />{value}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disabled) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={addTitle}>
|
||||||
|
<MessageOutlined
|
||||||
|
style={{ color: '#CBD5E1', cursor: 'pointer', fontSize: 12 }}
|
||||||
|
onClick={e => { e.stopPropagation(); setEditing(true) }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── InlineLabels ──────────────────────────────────────────────────────
|
||||||
|
// Shows labels as closable geekblue tags. A small "+" button appends a
|
||||||
|
// new label. Saves each change immediately via onSave callback.
|
||||||
|
|
||||||
|
interface InlineLabelsProps {
|
||||||
|
labels: string[]
|
||||||
|
disabled?: boolean
|
||||||
|
onSave: (labels: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineLabels({ labels, disabled, onSave }: InlineLabelsProps) {
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [inputVal, setInputVal] = useState('')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => { if (adding) inputRef.current?.focus() }, [adding])
|
||||||
|
|
||||||
|
const addLabel = () => {
|
||||||
|
const v = inputVal.trim()
|
||||||
|
setAdding(false); setInputVal('')
|
||||||
|
if (v && !labels.includes(v)) onSave([...labels, v])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeLabel = (label: string) => {
|
||||||
|
onSave(labels.filter(l => l !== label))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||||
|
{labels.map(l => (
|
||||||
|
<Tag
|
||||||
|
key={l}
|
||||||
|
color="geekblue"
|
||||||
|
closable={!disabled}
|
||||||
|
onClose={e => { e.preventDefault(); removeLabel(l) }}
|
||||||
|
style={{ fontSize: 10, margin: 0 }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{!disabled && (
|
||||||
|
adding ? (
|
||||||
|
<Input
|
||||||
|
ref={inputRef as never}
|
||||||
|
size="small"
|
||||||
|
value={inputVal}
|
||||||
|
onChange={e => setInputVal(e.target.value)}
|
||||||
|
onPressEnter={addLabel}
|
||||||
|
onBlur={addLabel}
|
||||||
|
onKeyDown={e => { if (e.key === 'Escape') { setAdding(false); setInputVal('') } }}
|
||||||
|
placeholder="Label…"
|
||||||
|
style={{ fontSize: 11, width: 90 }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tooltip title="Label hinzufügen">
|
||||||
|
<Tag
|
||||||
|
style={{ fontSize: 10, cursor: 'pointer', borderStyle: 'dashed', margin: 0, color: '#64748B', borderColor: '#CBD5E1', background: 'transparent' }}
|
||||||
|
onClick={e => { e.stopPropagation(); setAdding(true) }}
|
||||||
|
>
|
||||||
|
<PlusOutlined style={{ fontSize: 9 }} />
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ArrowDownOutlined, ArrowUpOutlined, BranchesOutlined, CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
import { ArrowDownOutlined, ArrowUpOutlined, BranchesOutlined, CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||||
|
import { InlineNote, InlineLabels } from './InlineEditors'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@@ -89,6 +90,22 @@ export default function NATRulesTab() {
|
|||||||
onError: (e: Error) => message.error(e.message),
|
onError: (e: Error) => message.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const patchNote = useMutation({
|
||||||
|
mutationFn: async ({ id, note }: { id: number; note: string }) => {
|
||||||
|
await apiClient.patch(`/firewall/nat-rules/${id}`, { note })
|
||||||
|
},
|
||||||
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const patchLabels = useMutation({
|
||||||
|
mutationFn: async ({ id, labels }: { id: number; labels: string[] }) => {
|
||||||
|
await apiClient.patch(`/firewall/nat-rules/${id}`, { labels })
|
||||||
|
},
|
||||||
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
const duplicate = useMutation({
|
const duplicate = useMutation({
|
||||||
mutationFn: async (r: NATRule) => {
|
mutationFn: async (r: NATRule) => {
|
||||||
const { id: _id, created_at: _ca, updated_at: _ua, ...rest } = r as NATRule & { created_at?: unknown; updated_at?: unknown }
|
const { id: _id, created_at: _ca, updated_at: _ua, ...rest } = r as NATRule & { created_at?: unknown; updated_at?: unknown }
|
||||||
@@ -174,11 +191,23 @@ export default function NATRulesTab() {
|
|||||||
title: t('fw.nat.name'), key: 'name',
|
title: t('fw.nat.name'), key: 'name',
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div>
|
<div>
|
||||||
{r.name
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||||
? <div className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>
|
{r.name
|
||||||
: <div className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>{t('fw.rule.unnamed')}</div>
|
? <span className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</span>
|
||||||
}
|
: <span className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>{t('fw.rule.unnamed')}</span>
|
||||||
{r.comment && <div style={{ fontSize: 11, color: '#64748B', marginTop: 1 }}>{r.comment}</div>}
|
}
|
||||||
|
<InlineLabels
|
||||||
|
labels={r.labels ?? []}
|
||||||
|
disabled={isViewer}
|
||||||
|
onSave={labels => patchLabels.mutate({ id: r.id, labels })}
|
||||||
|
/>
|
||||||
|
<InlineNote
|
||||||
|
value={r.note}
|
||||||
|
disabled={isViewer}
|
||||||
|
onSave={note => patchNote.mutate({ id: r.id, note })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{r.comment && <div style={{ fontSize: 11, color: '#64748B', marginTop: 2 }}>{r.comment}</div>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
|
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
|
||||||
EyeOutlined, FireOutlined, PlusOutlined, WarningOutlined,
|
EyeOutlined, FireOutlined, PlusOutlined, WarningOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { InlineNote, InlineLabels } from './InlineEditors'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@@ -251,6 +252,22 @@ export default function RulesTab() {
|
|||||||
onError: (e: Error) => message.error(e.message),
|
onError: (e: Error) => message.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const patchNote = useMutation({
|
||||||
|
mutationFn: async ({ id, note }: { id: number; note: string }) => {
|
||||||
|
await apiClient.patch(`/firewall/rules/${id}`, { note })
|
||||||
|
},
|
||||||
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) },
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const patchLabels = useMutation({
|
||||||
|
mutationFn: async ({ id, labels }: { id: number; labels: string[] }) => {
|
||||||
|
await apiClient.patch(`/firewall/rules/${id}`, { labels })
|
||||||
|
},
|
||||||
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) },
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
const duplicate = useMutation({
|
const duplicate = useMutation({
|
||||||
mutationFn: async (r: FwRule) => {
|
mutationFn: async (r: FwRule) => {
|
||||||
const { id: _id, created_at: _ca, updated_at: _ua, ...rest } = r as FwRule & { created_at?: unknown; updated_at?: unknown }
|
const { id: _id, created_at: _ca, updated_at: _ua, ...rest } = r as FwRule & { created_at?: unknown; updated_at?: unknown }
|
||||||
@@ -337,14 +354,24 @@ export default function RulesTab() {
|
|||||||
title: t('fw.rule.name'), key: 'name', ellipsis: true,
|
title: t('fw.rule.name'), key: 'name', ellipsis: true,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div>
|
<div>
|
||||||
{r.name
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||||
? <div className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>
|
{r.name
|
||||||
: <div className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>
|
? <span className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</span>
|
||||||
{t('fw.rule.unnamed')}
|
: <span className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>{t('fw.rule.unnamed')}</span>
|
||||||
</div>
|
}
|
||||||
}
|
<InlineLabels
|
||||||
|
labels={r.labels ?? []}
|
||||||
|
disabled={isViewer}
|
||||||
|
onSave={labels => patchLabels.mutate({ id: r.id, labels })}
|
||||||
|
/>
|
||||||
|
<InlineNote
|
||||||
|
value={r.note}
|
||||||
|
disabled={isViewer}
|
||||||
|
onSave={note => patchNote.mutate({ id: r.id, note })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{r.comment
|
{r.comment
|
||||||
? <div style={{ fontSize: 11, color: '#64748B', marginTop: 1 }}>{r.comment}</div>
|
? <div style={{ fontSize: 11, color: '#64748B', marginTop: 2 }}>{r.comment}</div>
|
||||||
: <div className="fw-rule-desc">{autoDescription(r)}</div>
|
: <div className="fw-rule-desc">{autoDescription(r)}</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export interface FwRule {
|
|||||||
service_group_id?: number | null
|
service_group_id?: number | null
|
||||||
log: boolean
|
log: boolean
|
||||||
comment?: string | null
|
comment?: string | null
|
||||||
|
note?: string | null
|
||||||
|
labels: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NATRule {
|
export interface NATRule {
|
||||||
@@ -88,6 +90,8 @@ export interface NATRule {
|
|||||||
target_port_start?: number | null
|
target_port_start?: number | null
|
||||||
target_port_end?: number | null
|
target_port_end?: number | null
|
||||||
comment?: string | null
|
comment?: string | null
|
||||||
|
note?: string | null
|
||||||
|
labels: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback list — used only while /firewall/zones hasn't loaded
|
// Fallback list — used only while /firewall/zones hasn't loaded
|
||||||
|
|||||||
Reference in New Issue
Block a user