From 79213debc2a67396befa4ef9fbce966f681f5311 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Tue, 3 Feb 2026 08:30:35 +0100 Subject: [PATCH 01/26] proxy list API --- crates/defguard_core/src/handlers/proxy.rs | 26 ++++++++++++++++++++++ crates/defguard_core/src/lib.rs | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/defguard_core/src/handlers/proxy.rs b/crates/defguard_core/src/handlers/proxy.rs index 6ae43d593d..2508268a39 100644 --- a/crates/defguard_core/src/handlers/proxy.rs +++ b/crates/defguard_core/src/handlers/proxy.rs @@ -19,6 +19,32 @@ pub struct ProxyUpdateData { pub name: String, } +#[utoipa::path( + get, + path = "/api/v1/proxy", + responses( + (status = 200, description = "Edge list", body = [Proxy]), + (status = 401, description = "Unauthorized to get edge list.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to get edge list.", body = ApiResponse, example = json!({"msg": "access denied"})), + (status = 500, description = "Unable to get edge list.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub(crate) async fn proxy_list( + _role: AdminRole, + session: SessionInfo, + State(appstate): State, +) -> ApiResult { + debug!("User {} displaying proxy list", session.user.username); + let proxies = Proxy::all(&appstate.pool).await?; + info!("User {} displayed proxy list", session.user.username); + + Ok(ApiResponse::json(proxies, StatusCode::OK)) +} + #[utoipa::path( get, path = "/api/v1/proxy/{proxy_id}", diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 4bcdf7d2a7..122b433afe 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -132,7 +132,7 @@ use crate::{ authorization, discovery_keys, openid_configuration, secure_authorization, token, userinfo, }, - proxy::{proxy_details, update_proxy}, + proxy::{proxy_details, proxy_list, update_proxy}, settings::{ get_settings, get_settings_essentials, patch_settings, set_default_branding, test_ldap_settings, update_settings, @@ -359,6 +359,7 @@ pub fn build_webapp( // Certificate authority .route("/ca", post(create_ca)) // Proxy routes + .route("/proxy", get(proxy_list)) .route("/proxy/{proxy_id}", get(proxy_details).put(update_proxy)) // Proxy setup with SSE .route("/proxy/setup/stream", get(setup_proxy_tls_stream)), From 3a880f9611cd6cca757f9a6917628fc02c114e4d Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Tue, 3 Feb 2026 09:42:05 +0100 Subject: [PATCH 02/26] initial edge list table --- web/messages/en/edge.json | 13 +- web/src/pages/EdgeListPage/EdgeListPage.tsx | 11 - web/src/pages/EdgesPage/EdgesPage.tsx | 17 + web/src/pages/EdgesPage/EdgesTable.tsx | 347 ++++++++++++++++++ web/src/pages/EditEdgePage/EditEdgePage.tsx | 4 +- web/src/routeTree.gen.ts | 43 ++- .../_authorized/_default/edge/index.tsx | 6 - web/src/routes/_authorized/_default/edges.tsx | 10 + web/src/shared/api/api.ts | 1 + web/src/shared/api/types.ts | 1 + web/src/shared/query.ts | 8 + 11 files changed, 419 insertions(+), 42 deletions(-) delete mode 100644 web/src/pages/EdgeListPage/EdgeListPage.tsx create mode 100644 web/src/pages/EdgesPage/EdgesPage.tsx create mode 100644 web/src/pages/EdgesPage/EdgesTable.tsx delete mode 100644 web/src/routes/_authorized/_default/edge/index.tsx create mode 100644 web/src/routes/_authorized/_default/edges.tsx diff --git a/web/messages/en/edge.json b/web/messages/en/edge.json index b58877883f..59d3733302 100644 --- a/web/messages/en/edge.json +++ b/web/messages/en/edge.json @@ -9,5 +9,16 @@ "edge_edit_public_address": "Public domain", "edge_edit_delete": "Delete", "edge_edit_success": "Edge component updated", - "edge_edit_failed": "Failed to update edge component" + "edge_edit_failed": "Failed to update edge component", + "edges_col_name": "Name", + "edges_col_address": "Address", + "edges_col_port": "gRPC port", + "edges_col_version": "Version", + "edges_col_last_modified": "Last modified", + "edges_col_modified_by": "Modified by", + "edges_col_status": "Status", + "edges_row_menu_edit": "Edit", + "edges_empty_title": "No edge components added yet.", + "edges_empty_subtitle": "Add edge components by clicking the button below.", + "edges_search_placeholder": "Search" } diff --git a/web/src/pages/EdgeListPage/EdgeListPage.tsx b/web/src/pages/EdgeListPage/EdgeListPage.tsx deleted file mode 100644 index b1302a6e51..0000000000 --- a/web/src/pages/EdgeListPage/EdgeListPage.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { m } from '../../paraglide/messages'; -import { Page } from '../../shared/components/Page/Page'; -import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; - -export const EdgeListPage = () => { - return ( - - TODO - - ); -}; diff --git a/web/src/pages/EdgesPage/EdgesPage.tsx b/web/src/pages/EdgesPage/EdgesPage.tsx new file mode 100644 index 0000000000..650de9f8ca --- /dev/null +++ b/web/src/pages/EdgesPage/EdgesPage.tsx @@ -0,0 +1,17 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { m } from '../../paraglide/messages'; +import { Page } from '../../shared/components/Page/Page'; +import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; +import { getEdgesQueryOptions } from '../../shared/query'; +import { EdgesTable } from './EdgesTable'; + +export const EdgesPage = () => { + const { data: edges } = useSuspenseQuery(getEdgesQueryOptions); + return ( + + + isPresent(edges) && + + + ); +}; diff --git a/web/src/pages/EdgesPage/EdgesTable.tsx b/web/src/pages/EdgesPage/EdgesTable.tsx new file mode 100644 index 0000000000..0b42c13a30 --- /dev/null +++ b/web/src/pages/EdgesPage/EdgesTable.tsx @@ -0,0 +1,347 @@ +import { useNavigate } from '@tanstack/react-router'; +import { + type ColumnFiltersState, + createColumnHelper, + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getSortedRowModel, + type RowSelectionState, + useReactTable, +} from '@tanstack/react-table'; +import { useMemo, useState } from 'react'; +import { m } from '../../paraglide/messages'; +import type { Edge } from '../../shared/api/types'; +import { Button } from '../../shared/defguard-ui/components/Button/Button'; +import type { ButtonProps } from '../../shared/defguard-ui/components/Button/types'; +import { EmptyState } from '../../shared/defguard-ui/components/EmptyState/EmptyState'; +import { EmptyStateFlexible } from '../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; +import { IconButtonMenu } from '../../shared/defguard-ui/components/IconButtonMenu/IconButtonMenu'; +import type { MenuItemsGroup } from '../../shared/defguard-ui/components/Menu/types'; +import { Search } from '../../shared/defguard-ui/components/Search/Search'; +import { tableEditColumnSize } from '../../shared/defguard-ui/components/table/consts'; +import { TableBody } from '../../shared/defguard-ui/components/table/TableBody/TableBody'; +import { TableCell } from '../../shared/defguard-ui/components/table/TableCell/TableCell'; +import { TableTop } from '../../shared/defguard-ui/components/table/TableTop/TableTop'; + +type Props = { + edges: Edge[]; +}; + +type RowData = Edge; + +const columnHelper = createColumnHelper(); + +export const EdgesTable = ({ edges }: Props) => { + // const appInfo = useApp((s) => s.appInfo); + const navigate = useNavigate(); + + const addButtonProps = useMemo( + (): ButtonProps => ({ + variant: 'primary', + text: 'Add Edge component', + // TODO(jck) + iconLeft: 'add-user', + testId: 'add-edge', + onClick: () => { + navigate({ to: '/edge-wizard' }); + }, + }), + [navigate], + ); + + const [search, setSearch] = useState(''); + const [columnFilters, setColumnFilters] = useState([]); + + // const { data: groups } = useQuery(getGroupsInfoQueryOptions); + + // const groupsOptions = useMemo( + // (): SelectionOption[] => + // groups?.map((g) => ({ + // id: g.name, + // label: g.name, + // })) ?? [], + // [groups?.map], + // ); + + // const { mutate: deleteUser } = useMutation({ + // mutationFn: api.user.deleteUser, + // meta: { + // invalidate: ['user'], + // }, + // }); + + // const { mutate: changeAccountActiveState } = useMutation({ + // mutationFn: api.user.activeStateChange, + // meta: { + // invalidate: ['user'], + // }, + // }); + + // const { mutate: editUser } = useMutation({ + // mutationFn: api.user.editUser, + // meta: { + // invalidate: ['user'], + // }, + // }); + + // const handleEditGroups = useCallback( + // async (user: RowData, groups: string[]) => { + // const freshUser = (await api.user.getUser(user.username)).data.user; + // freshUser.groups = groups; + // editUser({ + // username: freshUser.username, + // body: freshUser, + // }); + // }, + // [editUser], + // ); + + const [selected, setSelected] = useState({}); + + // const transformedData = useMemo(() => { + // let data = users; + // if (search.length) { + // data = data.filter( + // (u) => + // u.first_name.toLowerCase().includes(search.toLowerCase()) || + // u.last_name.toLowerCase().includes(search.toLowerCase()), + // ); + // } + // return data; + // }, [users, search.length, search.toLowerCase]); + + const columns = useMemo( + () => [ + columnHelper.accessor('name', { + header: m.edges_col_name(), + enableSorting: true, + sortingFn: 'text', + minSize: 250, + cell: (info) => ( + + {info.getValue()} + + ), + }), + columnHelper.accessor('address', { + header: m.edges_col_address(), + size: 100, + minSize: 100, + cell: (info) => ( + + {info.getValue()} + + ), + }), + columnHelper.accessor('port', { + header: m.edges_col_port(), + size: 170, + minSize: 100, + enableSorting: true, + sortingFn: 'text', + cell: (info) => ( + + {info.getValue()} + + ), + }), + columnHelper.accessor('version', { + size: 175, + minSize: 175, + header: m.edges_col_version(), + enableSorting: false, + cell: (info) => ( + + {info.getValue()} + + ), + }), + columnHelper.accessor('port', { + size: 175, + minSize: 175, + header: m.edges_col_last_modified(), + enableSorting: false, + cell: () => ( + + TODO + + ), + }), + columnHelper.accessor('port', { + size: 175, + minSize: 175, + header: m.edges_col_modified_by(), + enableSorting: false, + cell: () => ( + + TODO + + ), + }), + columnHelper.accessor('port', { + size: 175, + minSize: 175, + header: m.edges_col_status(), + enableSorting: false, + cell: () => ( + + TODO + + ), + }), + columnHelper.display({ + id: 'edit', + size: tableEditColumnSize, + header: '', + enableSorting: false, + enableResizing: false, + cell: (info) => { + const rowData = info.row.original; + + const menuItems: MenuItemsGroup[] = [ + { + items: [ + { + text: m.users_row_menu_edit(), + icon: 'edit', + onClick: () => { + navigate({ + to: `/edge/$edgeId/edit`, + params: { edgeId: rowData.id.toString() }, + }); + }, + }, + ], + }, + ]; + + return ( + + + + ); + }, + }), + ], + [navigate], + ); + + // const expandedHeader = useMemo( + // () => [ + // m.users_col_assigned(), + // '', + // m.users_col_ip(), + // m.users_col_connected_through(), + // m.users_col_connected_date(), + // '', + // '', + // ], + // [], + // ); + + // const renderExpanded = useCallback( + // (row: Row, isLast = false) => + // row.original.devices.map((device) => { + // const latestNetwork = orderBy( + // device.networks.filter((n) => isPresent(n.last_connected_at)), + // (d) => d.last_connected_at, + // ['desc'], + // )[0]; + // const neverConnected = m.profile_devices_col_never_connected(); + // const ip = latestNetwork?.last_connected_ip ?? neverConnected; + // const locationName = latestNetwork?.last_connected_at + // ? latestNetwork.network_name + // : neverConnected; + // const connectionDate = latestNetwork?.last_connected_at + // ? displayDate(latestNetwork.last_connected_at) + // : neverConnected; + // return ( + // + // + // + // + // + // + // + // {device.name} + // + // + // + // {ip} + // + // + // {locationName} + // + // + // {connectionDate} + // + // + // + // + // + // ); + // }), + // [], + // ); + + const table = useReactTable({ + initialState: { + sorting: [ + { + id: 'name', + desc: false, + }, + ], + }, + state: { + rowSelection: selected, + columnFilters: columnFilters, + }, + columns, + data: edges, + enableRowSelection: true, + enableExpanding: true, + columnResizeMode: 'onChange', + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: setSelected, + getSortedRowModel: getSortedRowModel(), + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + }); + + if (edges.length === 0) + return ( + + ); + + return ( + <> + + +