From 0de0a1580a115ab4b7fd123295973dde71c27a5b Mon Sep 17 00:00:00 2001 From: Debian Date: Sun, 10 May 2026 13:48:27 +0200 Subject: [PATCH] feat(ui): generischer DataTable-Wrapper + Version 1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataTable (components/DataTable.tsx) gibt jeder CRUD-Tabelle drei Baseline-Features auf einmal: * Search-Input (Volltext über alle string-Felder, case-insensitive) * Pagination 25/Seite mit showSizeChanger * Auto-sorter pro Spalte mit dataIndex (string→localeCompare, number→subtract, boolean→bool→Number) — Spalten mit eigenem sorter behalten den. Sweep aller 13 CRUD-Pages auf : Domains, Backends, Routing-Rules, Networks, IP-Addresses, SSL, Cluster, sechs Firewall- Tabs. Kleine Sub-Tabellen (System-Discovered IP-Card) bleiben auf — read-only ohne CRUD braucht keine Pagination. i18n: common.search, common.totalRows. Version-Bump auf 1.0.0 (User-Direktive: ohne -dev): VERSION-Datei, Go-Literale in cmd/edgeguard-{api,ctl,scheduler}/main.go, package.json, Sidebar-Konstante. Live deployed auf 89.163.205.6 als edgeguard 1.0.0 (api + ui + meta). Memory: project_versioning.md hält die Patch-Bump-Konvention fest (Gitea Package Registry 409't bei Doppel-Upload — bei jedem Release zuerst die VERSION inkrementieren). Co-Authored-By: Claude Opus 4.7 (1M context) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- management-ui/package.json | 2 +- management-ui/src/components/DataTable.tsx | 123 ++++++++++++++++++ .../src/components/Layout/Sidebar.tsx | 2 +- management-ui/src/i18n/locales/de/common.json | 4 +- management-ui/src/i18n/locales/en/common.json | 4 +- management-ui/src/pages/Backends/index.tsx | 5 +- management-ui/src/pages/Cluster/index.tsx | 7 +- management-ui/src/pages/Domains/index.tsx | 7 +- .../src/pages/Firewall/AddressGroups.tsx | 5 +- .../src/pages/Firewall/AddressObjects.tsx | 5 +- management-ui/src/pages/Firewall/NATRules.tsx | 5 +- management-ui/src/pages/Firewall/Rules.tsx | 5 +- .../src/pages/Firewall/ServiceGroups.tsx | 5 +- management-ui/src/pages/Firewall/Services.tsx | 5 +- management-ui/src/pages/IPAddresses/index.tsx | 7 +- management-ui/src/pages/Networks/index.tsx | 5 +- .../src/pages/RoutingRules/index.tsx | 5 +- management-ui/src/pages/SSL/index.tsx | 5 +- 22 files changed, 177 insertions(+), 37 deletions(-) create mode 100644 management-ui/src/components/DataTable.tsx diff --git a/VERSION b/VERSION index c0ab82c..3eefcb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.1-dev +1.0.0 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 6082605..86ef771 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -35,7 +35,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "0.0.1-dev" +var version = "1.0.0" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index e1fcee5..21b73ce 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "0.0.1-dev" +var version = "1.0.0" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index e880fa0..126e79d 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -5,7 +5,7 @@ import ( "time" ) -var version = "0.0.1-dev" +var version = "1.0.0" func main() { log.Printf("edgeguard-scheduler %s starting", version) diff --git a/management-ui/package.json b/management-ui/package.json index c18e52a..9e0a650 100644 --- a/management-ui/package.json +++ b/management-ui/package.json @@ -1,7 +1,7 @@ { "name": "edgeguard-management-ui", "private": true, - "version": "0.0.1-dev", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/management-ui/src/components/DataTable.tsx b/management-ui/src/components/DataTable.tsx new file mode 100644 index 0000000..9a5a3e4 --- /dev/null +++ b/management-ui/src/components/DataTable.tsx @@ -0,0 +1,123 @@ +import { useMemo, useState } from 'react' +import { Input, Space, Table } from 'antd' +import type { ColumnsType, TableProps } from 'antd/es/table' +import { SearchOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' + +// DataTable wraps AntD's Table and gives every CRUD page the same +// baseline UX: +// +// * Search input above the table — filters across every string +// field of every row, case-insensitive. +// * Pagination on by default (25 / page, size-changer enabled). +// * Sortable columns out of the box — any column with `dataIndex` +// gets a default sorter inferred from the value type (string → +// localeCompare, number → subtract). Columns that already +// define `sorter` keep their custom one. +// +// Drop-in replacement: callers pass the same `columns` + `dataSource` +// props they would to `
`. Search visibility and page-size +// can be customised via the props. +interface DataTableProps extends Omit, 'pagination'> { + pageSize?: number + searchPlaceholder?: string + searchable?: boolean + // toolbar renders to the right of the search input, useful for + // "Add" buttons. + toolbar?: React.ReactNode +} + +function inferSorter(dataIndex: string | string[] | undefined) { + if (dataIndex == null) return undefined + const path = Array.isArray(dataIndex) ? dataIndex : [dataIndex] + const get = (row: T): unknown => path.reduce( + (acc, k) => (acc != null && typeof acc === 'object' ? (acc as Record)[k] : undefined), + row as unknown, + ) + return (a: T, b: T) => { + const va = get(a), vb = get(b) + if (typeof va === 'number' && typeof vb === 'number') return va - vb + if (typeof va === 'boolean' && typeof vb === 'boolean') return Number(va) - Number(vb) + const sa = String(va ?? '') + const sb = String(vb ?? '') + return sa.localeCompare(sb, undefined, { numeric: true, sensitivity: 'base' }) + } +} + +function flatStringValues(row: unknown): string[] { + if (row == null) return [] + if (typeof row === 'string') return [row] + if (typeof row === 'number' || typeof row === 'boolean') return [String(row)] + if (Array.isArray(row)) return row.flatMap(flatStringValues) + if (typeof row === 'object') { + return Object.values(row as Record).flatMap(flatStringValues) + } + return [] +} + +export default function DataTable( + props: DataTableProps, +) { + const { t } = useTranslation() + const { + dataSource, + columns, + pageSize = 25, + searchPlaceholder, + searchable = true, + toolbar, + ...rest + } = props + + const [search, setSearch] = useState('') + + const filtered = useMemo(() => { + if (!search || !dataSource) return dataSource + const q = search.toLowerCase() + return (dataSource as readonly T[]).filter((row) => + flatStringValues(row).some((s) => s.toLowerCase().includes(q)), + ) + }, [dataSource, search]) + + const enhancedCols: ColumnsType = useMemo(() => { + if (!columns) return [] + return (columns as ColumnsType).map((c) => { + // Skip if user already declared a sorter or this column has + // no dataIndex (e.g. action columns). + const col = c as Record + if (col.sorter || col.dataIndex == null) return c + const sorter = inferSorter(col.dataIndex as string | string[]) + if (!sorter) return c + return { ...c, sorter } + }) + }, [columns]) + + return ( + + {(searchable || toolbar) && ( +
+ {searchable && ( + } + placeholder={searchPlaceholder ?? t('common.search')} + allowClear + onChange={(e) => setSearch(e.target.value)} + style={{ maxWidth: 320 }} + /> + )} + {toolbar &&
{toolbar}
} +
+ )} + + {...rest} + dataSource={filtered} + columns={enhancedCols} + pagination={{ + pageSize, + showSizeChanger: true, + showTotal: (total) => t('common.totalRows', { count: total }), + }} + /> +
+ ) +} diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 5623ae3..c384b7e 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -68,7 +68,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '0.0.1-dev' +const VERSION = '1.0.0' export default function Sidebar({ isOpen, onClose }: SidebarProps) { const { t } = useTranslation() diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 678dced..72ccfe0 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -248,6 +248,8 @@ "loading": "Lädt …", "error": "Fehler", "edit": "Bearbeiten", - "delete": "Löschen" + "delete": "Löschen", + "search": "Suchen …", + "totalRows": "{{count}} Einträge" } } diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 20ab488..a862453 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -249,6 +249,8 @@ "loading": "Loading …", "error": "Error", "edit": "Edit", - "delete": "Delete" + "delete": "Delete", + "search": "Search …", + "totalRows": "{{count}} rows" } } diff --git a/management-ui/src/pages/Backends/index.tsx b/management-ui/src/pages/Backends/index.tsx index 43a1a5d..e2d94bd 100644 --- a/management-ui/src/pages/Backends/index.tsx +++ b/management-ui/src/pages/Backends/index.tsx @@ -1,8 +1,9 @@ import { useState } from 'react' -import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Typography, message } from 'antd' +import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Typography, message } from 'antd' import type { ColumnsType } from 'antd/es/table' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import DataTable from '../../components/DataTable' import apiClient, { isEnvelope } from '../../api/client' @@ -118,7 +119,7 @@ export default function BackendsPage() { }}> {t('backends.addBackend')} -
+ -
diff --git a/management-ui/src/pages/Domains/index.tsx b/management-ui/src/pages/Domains/index.tsx index 27304e3..0a112b5 100644 --- a/management-ui/src/pages/Domains/index.tsx +++ b/management-ui/src/pages/Domains/index.tsx @@ -1,8 +1,9 @@ import { useState } from 'react' -import { Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Typography, message } from 'antd' +import { Button, Form, Input, Modal, Popconfirm, Space, Switch, Typography, message } from 'antd' import type { ColumnsType } from 'antd/es/table' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import DataTable from '../../components/DataTable' import apiClient, { isEnvelope } from '../../api/client' @@ -124,12 +125,12 @@ export default function DomainsPage() { }}> {t('domains.addDomain')} -
{t('fw.ag.add')} -
+ {t('fw.ao.add')} -
+ {t('fw.nat.add')} -
+ {t('fw.rule.add')} -
+ {t('fw.sg.add')} -
+ {t('fw.svc.add')} -
+ — : ( -
`${r.ifname}-${r.address}`} dataSource={sysAddrs ?? []} - pagination={false} + columns={[ { title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => {s} }, { title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => {row.address}/{row.prefix} }, @@ -189,7 +190,7 @@ export default function IPAddressesPage() { }}> {t('ips.addAddress')} -
+
-
+ {t('routing.addRule')} -
+ {t('ssl.installedTitle')} -
+ ) }