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:
Debian
2026-05-31 18:46:54 +02:00
parent 13cb9a8fc4
commit b48ba65ce3
11 changed files with 425 additions and 33 deletions

View File

@@ -1 +1 @@
1.2.48 1.2.49

View 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;

View File

@@ -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 {

View File

@@ -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"`
} }

View File

@@ -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"`
} }

View File

@@ -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 = &note
}
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
} }

View File

@@ -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 = &note
}
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
} }

View 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>
)
}

View File

@@ -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>
), ),
}, },

View File

@@ -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>

View File

@@ -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