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.GET("/:id", h.GetRule)
|
||||
rl.PUT("/:id", h.UpdateRule)
|
||||
rl.PATCH("/:id", h.PatchRule)
|
||||
rl.DELETE("/:id", h.DeleteRule)
|
||||
|
||||
nat := g.Group("/nat-rules")
|
||||
@@ -145,6 +146,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
||||
nat.POST("", h.CreateNAT)
|
||||
nat.GET("/:id", h.GetNAT)
|
||||
nat.PUT("/:id", h.UpdateNAT)
|
||||
nat.PATCH("/:id", h.PatchNAT)
|
||||
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")
|
||||
}
|
||||
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
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"`
|
||||
|
||||
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"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ type FirewallRule struct {
|
||||
|
||||
Log bool `gorm:"column:log" json:"log"`
|
||||
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"`
|
||||
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,
|
||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_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
|
||||
`
|
||||
|
||||
@@ -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) {
|
||||
if x.Labels == nil {
|
||||
x.Labels = []string{}
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO firewall_nat_rules (
|
||||
name, priority, enabled, kind,
|
||||
in_zone, out_zone, proto,
|
||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
||||
target_addr, target_port_start, target_port_end,
|
||||
comment
|
||||
comment, note, labels
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7,
|
||||
$8, $9, $10, $11,
|
||||
$12, $13, $14,
|
||||
$15
|
||||
$15, $16, $17
|
||||
)
|
||||
RETURNING id, name, priority, enabled, kind,
|
||||
in_zone, out_zone, proto,
|
||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_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.InZone, x.OutZone, x.Proto,
|
||||
x.MatchSrcCIDR, x.MatchDstCIDR, x.MatchDPortStart, x.MatchDPortEnd,
|
||||
x.TargetAddr, x.TargetPortStart, x.TargetPortEnd,
|
||||
x.Comment)
|
||||
x.Comment, x.Note, x.Labels)
|
||||
return scanNATRule(row)
|
||||
}
|
||||
|
||||
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, `
|
||||
UPDATE firewall_nat_rules SET
|
||||
name = $1, priority = $2, enabled = $3, kind = $4,
|
||||
in_zone = $5, out_zone = $6, proto = $7,
|
||||
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,
|
||||
comment = $15, updated_at = NOW()
|
||||
WHERE id = $16
|
||||
comment = $15, note = $16, labels = $17, updated_at = NOW()
|
||||
WHERE id = $18
|
||||
RETURNING id, name, priority, enabled, kind,
|
||||
in_zone, out_zone, proto,
|
||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_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.InZone, x.OutZone, x.Proto,
|
||||
x.MatchSrcCIDR, x.MatchDstCIDR, x.MatchDPortStart, x.MatchDPortEnd,
|
||||
x.TargetAddr, x.TargetPortStart, x.TargetPortEnd,
|
||||
x.Comment, id)
|
||||
x.Comment, x.Note, x.Labels, id)
|
||||
out, err := scanNATRule(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -124,6 +130,37 @@ func (r *NATRulesRepo) Delete(ctx context.Context, id int64) error {
|
||||
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) {
|
||||
var x models.FirewallNATRule
|
||||
if err := row.Scan(
|
||||
@@ -131,9 +168,12 @@ func scanNATRule(row interface{ Scan(...any) error }) (*models.FirewallNATRule,
|
||||
&x.InZone, &x.OutZone, &x.Proto,
|
||||
&x.MatchSrcCIDR, &x.MatchDstCIDR, &x.MatchDPortStart, &x.MatchDPortEnd,
|
||||
&x.TargetAddr, &x.TargetPortStart, &x.TargetPortEnd,
|
||||
&x.Comment, &x.CreatedAt, &x.UpdatedAt,
|
||||
&x.Comment, &x.Note, &x.Labels, &x.CreatedAt, &x.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if x.Labels == nil {
|
||||
x.Labels = []string{}
|
||||
}
|
||||
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,
|
||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||
service_object_id, service_group_id,
|
||||
log, comment, created_at, updated_at
|
||||
log, comment, note, labels, created_at, updated_at
|
||||
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) {
|
||||
if x.Labels == nil {
|
||||
x.Labels = []string{}
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO firewall_rules (
|
||||
name, priority, enabled, action,
|
||||
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||
service_object_id, service_group_id,
|
||||
log, comment
|
||||
log, comment, note, labels
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7, $8,
|
||||
$9, $10, $11, $12,
|
||||
$13, $14,
|
||||
$15, $16
|
||||
$15, $16, $17, $18
|
||||
)
|
||||
RETURNING id, name, priority, enabled, action,
|
||||
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||
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.SrcZone, x.SrcAddressObjectID, x.SrcAddressGroupID, x.SrcCIDR,
|
||||
x.DstZone, x.DstAddressObjectID, x.DstAddressGroupID, x.DstCIDR,
|
||||
x.ServiceObjectID, x.ServiceGroupID,
|
||||
x.Log, x.Comment)
|
||||
x.Log, x.Comment, x.Note, x.Labels)
|
||||
return scanRule(row)
|
||||
}
|
||||
|
||||
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, `
|
||||
UPDATE firewall_rules SET
|
||||
name = $1, priority = $2, enabled = $3, action = $4,
|
||||
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,
|
||||
service_object_id = $13, service_group_id = $14,
|
||||
log = $15, comment = $16, updated_at = NOW()
|
||||
WHERE id = $17
|
||||
log = $15, comment = $16, note = $17, labels = $18, updated_at = NOW()
|
||||
WHERE id = $19
|
||||
RETURNING id, name, priority, enabled, action,
|
||||
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||
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.SrcZone, x.SrcAddressObjectID, x.SrcAddressGroupID, x.SrcCIDR,
|
||||
x.DstZone, x.DstAddressObjectID, x.DstAddressGroupID, x.DstCIDR,
|
||||
x.ServiceObjectID, x.ServiceGroupID,
|
||||
x.Log, x.Comment, id)
|
||||
x.Log, x.Comment, x.Note, x.Labels, id)
|
||||
out, err := scanRule(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -124,6 +130,37 @@ func (r *RulesRepo) Delete(ctx context.Context, id int64) error {
|
||||
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) {
|
||||
var x models.FirewallRule
|
||||
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.DstZone, &x.DstAddressObjectID, &x.DstAddressGroupID, &x.DstCIDR,
|
||||
&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 {
|
||||
return nil, err
|
||||
}
|
||||
if x.Labels == nil {
|
||||
x.Labels = []string{}
|
||||
}
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, BranchesOutlined, CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { InlineNote, InlineLabels } from './InlineEditors'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -89,6 +90,22 @@ export default function NATRulesTab() {
|
||||
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({
|
||||
mutationFn: async (r: NATRule) => {
|
||||
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',
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
{r.name
|
||||
? <div className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>
|
||||
: <div className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>{t('fw.rule.unnamed')}</div>
|
||||
}
|
||||
{r.comment && <div style={{ fontSize: 11, color: '#64748B', marginTop: 1 }}>{r.comment}</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{r.name
|
||||
? <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>
|
||||
}
|
||||
<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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
|
||||
EyeOutlined, FireOutlined, PlusOutlined, WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { InlineNote, InlineLabels } from './InlineEditors'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -251,6 +252,22 @@ export default function RulesTab() {
|
||||
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({
|
||||
mutationFn: async (r: FwRule) => {
|
||||
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,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
{r.name
|
||||
? <div className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>
|
||||
: <div className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>
|
||||
{t('fw.rule.unnamed')}
|
||||
</div>
|
||||
}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{r.name
|
||||
? <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>
|
||||
}
|
||||
<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: 1 }}>{r.comment}</div>
|
||||
? <div style={{ fontSize: 11, color: '#64748B', marginTop: 2 }}>{r.comment}</div>
|
||||
: <div className="fw-rule-desc">{autoDescription(r)}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface FwRule {
|
||||
service_group_id?: number | null
|
||||
log: boolean
|
||||
comment?: string | null
|
||||
note?: string | null
|
||||
labels: string[]
|
||||
}
|
||||
|
||||
export interface NATRule {
|
||||
@@ -88,6 +90,8 @@ export interface NATRule {
|
||||
target_port_start?: number | null
|
||||
target_port_end?: number | null
|
||||
comment?: string | null
|
||||
note?: string | null
|
||||
labels: string[]
|
||||
}
|
||||
|
||||
// Fallback list — used only while /firewall/zones hasn't loaded
|
||||
|
||||
Reference in New Issue
Block a user