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

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

View File

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

View File

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