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