diff --git a/VERSION b/VERSION index 7ffb8be..44a6f3f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.48 +1.2.49 diff --git a/internal/database/migrations/0035_firewall_note_labels.sql b/internal/database/migrations/0035_firewall_note_labels.sql new file mode 100644 index 0000000..0acb2a9 --- /dev/null +++ b/internal/database/migrations/0035_firewall_note_labels.sql @@ -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; diff --git a/internal/handlers/firewall.go b/internal/handlers/firewall.go index 43afa6d..37edb97 100644 --- a/internal/handlers/firewall.go +++ b/internal/handlers/firewall.go @@ -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 { diff --git a/internal/models/firewall_nat_rule.go b/internal/models/firewall_nat_rule.go index 413f417..c28a35e 100644 --- a/internal/models/firewall_nat_rule.go +++ b/internal/models/firewall_nat_rule.go @@ -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"` } diff --git a/internal/models/firewall_rule.go b/internal/models/firewall_rule.go index 74154f3..a2aa567 100644 --- a/internal/models/firewall_rule.go +++ b/internal/models/firewall_rule.go @@ -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"` } diff --git a/internal/services/firewall/natrules.go b/internal/services/firewall/natrules.go index 0fdac76..e0e1073 100644 --- a/internal/services/firewall/natrules.go +++ b/internal/services/firewall/natrules.go @@ -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 } diff --git a/internal/services/firewall/rules.go b/internal/services/firewall/rules.go index fddafd3..11907e5 100644 --- a/internal/services/firewall/rules.go +++ b/internal/services/firewall/rules.go @@ -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 } diff --git a/management-ui/src/pages/Firewall/InlineEditors.tsx b/management-ui/src/pages/Firewall/InlineEditors.tsx new file mode 100644 index 0000000..32b1287 --- /dev/null +++ b/management-ui/src/pages/Firewall/InlineEditors.tsx @@ -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(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 ( + 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={Enter ⏎} + onClick={e => e.stopPropagation()} + /> + ) + } + + if (value) { + return ( + + { if (disabled) return; e.stopPropagation(); setEditing(true) }} + > + {value} + + + ) + } + + if (disabled) return null + + return ( + + { e.stopPropagation(); setEditing(true) }} + /> + + ) +} + +// ── 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(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 ( + + {labels.map(l => ( + { e.preventDefault(); removeLabel(l) }} + style={{ fontSize: 10, margin: 0 }} + onClick={e => e.stopPropagation()} + > + {l} + + ))} + {!disabled && ( + adding ? ( + 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()} + /> + ) : ( + + { e.stopPropagation(); setAdding(true) }} + > + + + + ) + )} + + ) +} diff --git a/management-ui/src/pages/Firewall/NATRules.tsx b/management-ui/src/pages/Firewall/NATRules.tsx index 6b58ffc..186ac3e 100644 --- a/management-ui/src/pages/Firewall/NATRules.tsx +++ b/management-ui/src/pages/Firewall/NATRules.tsx @@ -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) => (
- {r.name - ?
{r.name}
- :
{t('fw.rule.unnamed')}
- } - {r.comment &&
{r.comment}
} +
+ {r.name + ? {r.name} + : {t('fw.rule.unnamed')} + } + patchLabels.mutate({ id: r.id, labels })} + /> + patchNote.mutate({ id: r.id, note })} + /> +
+ {r.comment &&
{r.comment}
}
), }, diff --git a/management-ui/src/pages/Firewall/Rules.tsx b/management-ui/src/pages/Firewall/Rules.tsx index 3ab5afa..d4b77cf 100644 --- a/management-ui/src/pages/Firewall/Rules.tsx +++ b/management-ui/src/pages/Firewall/Rules.tsx @@ -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) => (
- {r.name - ?
{r.name}
- :
- {t('fw.rule.unnamed')} -
- } +
+ {r.name + ? {r.name} + : {t('fw.rule.unnamed')} + } + patchLabels.mutate({ id: r.id, labels })} + /> + patchNote.mutate({ id: r.id, note })} + /> +
{r.comment - ?
{r.comment}
+ ?
{r.comment}
:
{autoDescription(r)}
}
diff --git a/management-ui/src/pages/Firewall/types.ts b/management-ui/src/pages/Firewall/types.ts index 24d15a6..71d459a 100644 --- a/management-ui/src/pages/Firewall/types.ts +++ b/management-ui/src/pages/Firewall/types.ts @@ -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