diff --git a/crates/defguard_core/src/enterprise/handlers/acl.rs b/crates/defguard_core/src/enterprise/handlers/acl.rs index 192e43ddd..ff7e8c2cf 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl.rs @@ -9,6 +9,7 @@ use axum::{ use chrono::NaiveDateTime; use defguard_common::db::Id; use serde_json::{Value, json}; +use sqlx::query_as; use utoipa::ToSchema; use super::LicenseInfo; @@ -191,6 +192,12 @@ pub(crate) struct ApplyAclAliasesData { aliases: Vec, } +#[derive(Debug, Serialize, ToSchema, sqlx::FromRow)] +pub struct AclStateCount { + pub applied: i64, + pub pending: i64, +} + /// List all ACL rules. #[utoipa::path( get, @@ -221,6 +228,33 @@ pub(crate) async fn list_acl_rules( Ok(ApiResponse::json(api_rules, StatusCode::OK)) } +/// Count ACL rules by state. +#[utoipa::path( + get, + path = "/api/v1/acl/rule/count", + tag = "ACL", + responses( + (status = OK, description = "ACL rule state counts", body = AclStateCount), + ), +)] +pub(crate) async fn count_acl_rules( + _admin: AdminRole, + State(appstate): State, +) -> ApiResult { + let counts = query_as::<_, AclStateCount>( + "SELECT \ + COUNT(*) FILTER (WHERE state = 'applied'::aclrule_state) AS applied, \ + COUNT(*) FILTER ( \ + WHERE state IN ('new'::aclrule_state, 'modified'::aclrule_state, 'deleted'::aclrule_state) \ + ) AS pending \ + FROM aclrule", + ) + .fetch_one(&appstate.pool) + .await?; + + Ok(ApiResponse::json(counts, StatusCode::OK)) +} + /// Get ACL rule. #[utoipa::path( get, diff --git a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs index f13c38602..73a9e86f4 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs @@ -5,10 +5,10 @@ use axum::{ }; use defguard_common::db::{Id, NoId}; use serde_json::{Value, json}; -use sqlx::{PgConnection, PgPool, query}; +use sqlx::{PgConnection, PgPool, query, query_as}; use utoipa::ToSchema; -use super::LicenseInfo; +use super::{AclStateCount, LicenseInfo}; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, @@ -206,6 +206,33 @@ pub(crate) async fn list_acl_aliases( Ok(ApiResponse::json(api_aliases, StatusCode::OK)) } +/// Count ACL aliases by state. +#[utoipa::path( + get, + path = "/api/v1/acl/alias/count", + tag = "ACL", + responses( + (status = OK, description = "ACL alias state counts", body = AclStateCount), + ), +)] +pub(crate) async fn count_acl_aliases( + _admin: AdminRole, + State(appstate): State, +) -> ApiResult { + let counts = query_as::<_, AclStateCount>( + "SELECT \ + COUNT(*) FILTER (WHERE state = 'applied'::aclalias_state) AS applied, \ + COUNT(*) FILTER (WHERE state = 'modified'::aclalias_state) AS pending \ + FROM aclalias \ + WHERE kind = $1", + ) + .bind(AliasKind::Component) + .fetch_one(&appstate.pool) + .await?; + + Ok(ApiResponse::json(counts, StatusCode::OK)) +} + /// Get ACL alias. #[utoipa::path( get, diff --git a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs index 7814e8b2c..1df5d43c7 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs @@ -5,10 +5,10 @@ use axum::{ use defguard_common::db::{Id, NoId}; use reqwest::StatusCode; use serde_json::{Value, json}; -use sqlx::{PgConnection, PgPool, query}; +use sqlx::{PgConnection, PgPool, query, query_as}; use utoipa::ToSchema; -use super::LicenseInfo; +use super::{AclStateCount, LicenseInfo}; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, @@ -215,6 +215,33 @@ pub(crate) async fn list_acl_destinations( Ok(ApiResponse::json(api_aliases, StatusCode::OK)) } +/// Count ACL destinations by state. +#[utoipa::path( + get, + path = "/api/v1/acl/destination/count", + tag = "ACL", + responses( + (status = OK, description = "ACL destination state counts", body = AclStateCount), + ) +)] +pub(crate) async fn count_acl_destinations( + _admin: AdminRole, + State(appstate): State, +) -> ApiResult { + let counts = query_as::<_, AclStateCount>( + "SELECT \ + COUNT(*) FILTER (WHERE state = 'applied'::aclalias_state) AS applied, \ + COUNT(*) FILTER (WHERE state = 'modified'::aclalias_state) AS pending \ + FROM aclalias \ + WHERE kind = $1", + ) + .bind(AliasKind::Destination) + .fetch_one(&appstate.pool) + .await?; + + Ok(ApiResponse::json(counts, StatusCode::OK)) +} + /// Get ACL destination. #[utoipa::path( get, diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 568571f48..bc6ae24ca 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -79,13 +79,14 @@ use crate::{ handlers::{ acl::{ alias::{ - create_acl_alias, delete_acl_alias, get_acl_alias, list_acl_aliases, - update_acl_alias, + count_acl_aliases, create_acl_alias, delete_acl_alias, get_acl_alias, + list_acl_aliases, update_acl_alias, }, - apply_acl_aliases, apply_acl_rules, create_acl_rule, delete_acl_rule, + apply_acl_aliases, apply_acl_rules, count_acl_rules, create_acl_rule, + delete_acl_rule, destination::{ - create_acl_destination, delete_acl_destination, get_acl_destination, - list_acl_destinations, update_acl_destination, + count_acl_destinations, create_acl_destination, delete_acl_destination, + get_acl_destination, list_acl_destinations, update_acl_destination, }, get_acl_rule, list_acl_rules, update_acl_rule, }, @@ -444,6 +445,7 @@ pub fn build_webapp( "/api/v1/acl", Router::new() .route("/rule", get(list_acl_rules).post(create_acl_rule)) + .route("/rule/count", get(count_acl_rules)) .route("/rule/apply", put(apply_acl_rules)) .route( "/rule/{id}", @@ -452,6 +454,7 @@ pub fn build_webapp( .delete(delete_acl_rule), ) .route("/alias", get(list_acl_aliases).post(create_acl_alias)) + .route("/alias/count", get(count_acl_aliases)) .route( "/alias/{id}", get(get_acl_alias) @@ -463,6 +466,7 @@ pub fn build_webapp( "/destination", get(list_acl_destinations).post(create_acl_destination), ) + .route("/destination/count", get(count_acl_destinations)) .route( "/destination/{id}", get(get_acl_destination) diff --git a/crates/defguard_core/src/openapi.rs b/crates/defguard_core/src/openapi.rs index 2016b1f6a..f38234204 100644 --- a/crates/defguard_core/src/openapi.rs +++ b/crates/defguard_core/src/openapi.rs @@ -84,6 +84,7 @@ use super::{ openid_providers::list_openid_providers, // /acl/rule acl::list_acl_rules, + acl::count_acl_rules, acl::create_acl_rule, acl::apply_acl_rules, acl::get_acl_rule, @@ -91,6 +92,7 @@ use super::{ acl::delete_acl_rule, // /acl/alias acl::alias::list_acl_aliases, + acl::alias::count_acl_aliases, acl::alias::create_acl_alias, acl::alias::get_acl_alias, acl::alias::update_acl_alias, @@ -98,6 +100,7 @@ use super::{ acl::apply_acl_aliases, // /acl/destination acl::destination::list_acl_destinations, + acl::destination::count_acl_destinations, acl::destination::create_acl_destination, acl::destination::get_acl_destination, acl::destination::update_acl_destination, diff --git a/crates/defguard_core/tests/integration/api/acl.rs b/crates/defguard_core/tests/integration/api/acl.rs index 0f0593f50..36f913ebd 100644 --- a/crates/defguard_core/tests/integration/api/acl.rs +++ b/crates/defguard_core/tests/integration/api/acl.rs @@ -94,6 +94,18 @@ fn make_alias() -> EditAclAlias { } } +fn make_destination() -> Value { + json!({ + "name": "destination", + "addresses": "10.20.30.40, 10.0.0.1/24, 10.0.10.1-10.0.20.1", + "ports": "1, 2, 3, 10-20, 30-40", + "protocols": [6, 17], + "any_address": false, + "any_port": false, + "any_protocol": false + }) +} + fn edit_rule_data_into_api_response( data: &EditAclRule, id: Id, @@ -1266,3 +1278,89 @@ async fn test_multiple_aliases_application(_: PgPoolOptions, options: PgConnectO let alias: ApiAclAlias = client.get("/api/v1/acl/alias/6").send().await.json().await; assert_eq!(alias.state, AliasState::Applied); } + +#[sqlx::test] +async fn test_acl_count_endpoints(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool.clone()).await; + authenticate_admin(&mut client).await; + + // rules: 1 applied, 1 pending (new) + let rule = make_rule(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + set_rule_state(&pool, 1, RuleState::Applied, None).await; + + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // aliases: 2 applied, 1 pending (modified) + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let mut alias_to_update: ApiAclAlias = + client.get("/api/v1/acl/alias/2").send().await.json().await; + alias_to_update.name = "updated alias".to_string(); + let response = client + .put("/api/v1/acl/alias/2") + .json(&alias_to_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // destinations: 2 applied, 1 pending (modified) + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let destination_1: Value = response.json().await; + let destination_1_id = destination_1["id"].as_i64().unwrap(); + + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut destination_to_update = destination_1; + destination_to_update["name"] = json!("updated destination"); + let response = client + .put(format!("/api/v1/acl/destination/{destination_1_id}")) + .json(&destination_to_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let counts: Value = client + .get("/api/v1/acl/rule/count") + .send() + .await + .json() + .await; + assert_eq!(counts["applied"], json!(1)); + assert_eq!(counts["pending"], json!(1)); + + let counts: Value = client + .get("/api/v1/acl/alias/count") + .send() + .await + .json() + .await; + assert_eq!(counts["applied"], json!(2)); + assert_eq!(counts["pending"], json!(1)); + + let counts: Value = client + .get("/api/v1/acl/destination/count") + .send() + .await + .json() + .await; + assert_eq!(counts["applied"], json!(2)); + assert_eq!(counts["pending"], json!(1)); +} diff --git a/tools/defguard_generator/src/vpn_session_stats.rs b/tools/defguard_generator/src/vpn_session_stats.rs index bfb4ca136..a134f6b04 100644 --- a/tools/defguard_generator/src/vpn_session_stats.rs +++ b/tools/defguard_generator/src/vpn_session_stats.rs @@ -162,7 +162,7 @@ async fn prepare_gateway(pool: &PgPool, location_id: Id) -> Result> match existing_gateways.into_iter().next() { Some(gateway) => Ok(gateway), None => { - let gateway = Gateway::new(location_id, "http://localhost:50055", "gateway") + let gateway = Gateway::new(location_id, "test", "localhost", 50055, 1) .save(pool) .await?; Ok(gateway) diff --git a/web/src/main.tsx b/web/src/main.tsx index af59167f2..89a42d763 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,7 +1,8 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import './shared/defguard-ui/scss/index.scss'; import 'react-loading-skeleton/dist/skeleton.css'; +// keep this as last style import +import './shared/defguard-ui/scss/index.scss'; import { App } from './app/App.tsx'; // biome-ignore lint/style/noNonNullAssertion: always there diff --git a/web/src/pages/AliasesPage/AliasesPage.tsx b/web/src/pages/AliasesPage/AliasesPage.tsx index adcc398f5..4f0480a87 100644 --- a/web/src/pages/AliasesPage/AliasesPage.tsx +++ b/web/src/pages/AliasesPage/AliasesPage.tsx @@ -1,20 +1,19 @@ import './style.scss'; import { useQuery } from '@tanstack/react-query'; -import { useMemo, useState } from 'react'; -import api from '../../shared/api/api'; -import { - AclAliasStatus, - AclDeploymentState, - type AclDeploymentStateValue, -} from '../../shared/api/types'; +import { Suspense, useMemo, useState } from 'react'; +import { AclDeploymentState, type AclDeploymentStateValue } from '../../shared/api/types'; import { Page } from '../../shared/components/Page/Page'; +import { TableSkeleton } from '../../shared/components/skeleton/TableSkeleton/TableSkeleton'; import { Tabs } from '../../shared/defguard-ui/components/Tabs/Tabs'; import type { TabsItem } from '../../shared/defguard-ui/components/Tabs/types'; -import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; +import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; +import { getAliasesCountQueryOptions } from '../../shared/query'; import { AliasesDeployedTab } from './tabs/AliasesDeployedTab'; import { AliasesPendingTab } from './tabs/AliasesPendingTab'; export const AliasesPage = () => { + const { data: aliasesCount } = useQuery(getAliasesCountQueryOptions); + const [activeTab, setActiveTab] = useState( AclDeploymentState.Applied, ); @@ -33,39 +32,21 @@ export const AliasesPage = () => { onClick: () => { setActiveTab(AclDeploymentState.Modified); }, - title: 'Pending', + title: aliasesCount?.pending ? `Pending (${aliasesCount.pending})` : 'Pending', }, ], - [activeTab], + [activeTab, aliasesCount], ); - const { data: aliases } = useQuery({ - queryFn: api.acl.alias.getAliases, - queryKey: ['acl', 'alias'], - select: (resp) => resp.data, - }); - - const deployedAliases = useMemo(() => { - if (isPresent(aliases)) { - return aliases.filter((alias) => alias.state === AclAliasStatus.Applied); - } - }, [aliases]); - - const pendingAliases = useMemo(() => { - if (isPresent(aliases)) { - return aliases.filter((alias) => alias.state === AclAliasStatus.Modified); - } - }, [aliases]); - return ( - - {isPresent(deployedAliases) && activeTab === AclDeploymentState.Applied && ( - - )} - {isPresent(pendingAliases) && activeTab === AclDeploymentState.Modified && ( - - )} + + + }> + {activeTab === AclDeploymentState.Applied && } + {activeTab === AclDeploymentState.Modified && } + + ); }; diff --git a/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx b/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx index 4fe95af9d..bb37dfc79 100644 --- a/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx +++ b/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx @@ -1,22 +1,25 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useMemo, useState } from 'react'; import { m } from '../../../paraglide/messages'; -import type { AclAlias } from '../../../shared/api/types'; +import { AclStatus } from '../../../shared/api/types'; import { Button } from '../../../shared/defguard-ui/components/Button/Button'; import type { ButtonProps } from '../../../shared/defguard-ui/components/Button/types'; import { EmptyStateFlexible } from '../../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; import { Search } from '../../../shared/defguard-ui/components/Search/Search'; import { TableTop } from '../../../shared/defguard-ui/components/table/TableTop/TableTop'; -import { getLicenseInfoQueryOptions } from '../../../shared/query'; +import { + getAliasesQueryOptions, + getLicenseInfoQueryOptions, +} from '../../../shared/query'; import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; import { AliasTable } from '../AliasTable'; -type Props = { - aliases: AclAlias[]; -}; - -export const AliasesDeployedTab = ({ aliases }: Props) => { +export const AliasesDeployedTab = () => { + const { data: aliases } = useSuspenseQuery({ + ...getAliasesQueryOptions, + select: (resp) => resp.data.filter((alias) => alias.state === AclStatus.Applied), + }); const isEmpty = aliases.length === 0; const navigate = useNavigate(); const [search, setSearch] = useState(''); diff --git a/web/src/pages/AliasesPage/tabs/AliasesPendingTab.tsx b/web/src/pages/AliasesPage/tabs/AliasesPendingTab.tsx index 7ca5af1f4..2300a87c8 100644 --- a/web/src/pages/AliasesPage/tabs/AliasesPendingTab.tsx +++ b/web/src/pages/AliasesPage/tabs/AliasesPendingTab.tsx @@ -1,16 +1,17 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import api from '../../../shared/api/api'; -import type { AclAlias } from '../../../shared/api/types'; +import { AclStatus } from '../../../shared/api/types'; import { Button } from '../../../shared/defguard-ui/components/Button/Button'; import { EmptyStateFlexible } from '../../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; import { TableTop } from '../../../shared/defguard-ui/components/table/TableTop/TableTop'; +import { getAliasesQueryOptions } from '../../../shared/query'; import { AliasTable } from '../AliasTable'; -type Props = { - aliases: AclAlias[]; -}; - -export const AliasesPendingTab = ({ aliases }: Props) => { +export const AliasesPendingTab = () => { + const { data: aliases } = useSuspenseQuery({ + ...getAliasesQueryOptions, + select: (resp) => resp.data.filter((alias) => alias.state !== AclStatus.Applied), + }); const isEmpty = aliases.length === 0; const { mutate: applyAliases, isPending } = useMutation({ mutationFn: api.acl.alias.applyAliases, diff --git a/web/src/pages/DestinationsPage/DestinationsPage.tsx b/web/src/pages/DestinationsPage/DestinationsPage.tsx index 6972d1d8f..fc6ccbfd7 100644 --- a/web/src/pages/DestinationsPage/DestinationsPage.tsx +++ b/web/src/pages/DestinationsPage/DestinationsPage.tsx @@ -1,16 +1,18 @@ import { useQuery } from '@tanstack/react-query'; -import { useMemo, useState } from 'react'; -import api from '../../shared/api/api'; +import { Suspense, useMemo, useState } from 'react'; import { AclDeploymentState, type AclDeploymentStateValue } from '../../shared/api/types'; import { Page } from '../../shared/components/Page/Page'; +import { TableSkeleton } from '../../shared/components/skeleton/TableSkeleton/TableSkeleton'; import { Tabs } from '../../shared/defguard-ui/components/Tabs/Tabs'; import type { TabsItem } from '../../shared/defguard-ui/components/Tabs/types'; -import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; +import { getDestinationsCountQueryOptions } from '../../shared/query'; import { DestinationDeployedTab } from './tabs/DestinationDeployedTab/DestinationDeployedTab'; import { DestinationPendingTab } from './tabs/DestinationPendingTab/DestinationPendingTab'; export const DestinationsPage = () => { + const { data: destinationsCount } = useQuery(getDestinationsCountQueryOptions); + const [activeTab, setActiveTab] = useState( AclDeploymentState.Applied, ); @@ -29,42 +31,22 @@ export const DestinationsPage = () => { onClick: () => { setActiveTab(AclDeploymentState.Modified); }, - title: 'Pending', + title: destinationsCount?.pending + ? `Pending (${destinationsCount.pending})` + : 'Pending', }, ], - [activeTab], - ); - - const { data: destinationsData } = useQuery({ - queryFn: api.acl.destination.getDestinations, - queryKey: ['acl', 'destination'], - select: (resp) => resp.data, - }); - - const applied = useMemo( - () => destinationsData?.filter((item) => item.state === AclDeploymentState.Applied), - [destinationsData], - ); - - const pending = useMemo( - () => destinationsData?.filter((item) => item.state === AclDeploymentState.Modified), - [destinationsData], + [activeTab, destinationsCount], ); return ( - {isPresent(pending) && isPresent(applied) && ( - <> - {activeTab === AclDeploymentState.Applied && ( - - )} - {activeTab === AclDeploymentState.Modified && ( - - )} - - )} + }> + {activeTab === AclDeploymentState.Applied && } + {activeTab === AclDeploymentState.Modified && } + ); diff --git a/web/src/pages/DestinationsPage/tabs/DestinationDeployedTab/DestinationDeployedTab.tsx b/web/src/pages/DestinationsPage/tabs/DestinationDeployedTab/DestinationDeployedTab.tsx index f2af8fdaf..d46197f6b 100644 --- a/web/src/pages/DestinationsPage/tabs/DestinationDeployedTab/DestinationDeployedTab.tsx +++ b/web/src/pages/DestinationsPage/tabs/DestinationDeployedTab/DestinationDeployedTab.tsx @@ -1,21 +1,25 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useMemo } from 'react'; -import type { AclDestination } from '../../../../shared/api/types'; +import { AclStatus } from '../../../../shared/api/types'; import type { ButtonProps } from '../../../../shared/defguard-ui/components/Button/types'; import { EmptyStateFlexible } from '../../../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; -import { getLicenseInfoQueryOptions } from '../../../../shared/query'; +import { + getDestinationsQueryOptions, + getLicenseInfoQueryOptions, +} from '../../../../shared/query'; import { canUseBusinessFeature, licenseActionCheck, } from '../../../../shared/utils/license'; import { DestinationsTable } from '../../components/DestinationsTable'; -type Props = { - destinations: AclDestination[]; -}; - -export const DestinationDeployedTab = ({ destinations }: Props) => { +export const DestinationDeployedTab = () => { + const { data: destinations } = useSuspenseQuery({ + ...getDestinationsQueryOptions, + select: (resp) => + resp.data.filter((destination) => destination.state === AclStatus.Applied), + }); const navigate = useNavigate(); const { data: licenseInfo, isFetching } = useQuery(getLicenseInfoQueryOptions); diff --git a/web/src/pages/DestinationsPage/tabs/DestinationPendingTab/DestinationPendingTab.tsx b/web/src/pages/DestinationsPage/tabs/DestinationPendingTab/DestinationPendingTab.tsx index 438c92f9a..3efa1f58b 100644 --- a/web/src/pages/DestinationsPage/tabs/DestinationPendingTab/DestinationPendingTab.tsx +++ b/web/src/pages/DestinationsPage/tabs/DestinationPendingTab/DestinationPendingTab.tsx @@ -1,16 +1,19 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import api from '../../../../shared/api/api'; -import type { AclDestination } from '../../../../shared/api/types'; +import { AclStatus } from '../../../../shared/api/types'; import type { ButtonProps } from '../../../../shared/defguard-ui/components/Button/types'; import { EmptyStateFlexible } from '../../../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; +import { getDestinationsQueryOptions } from '../../../../shared/query'; import { DestinationsTable } from '../../components/DestinationsTable'; -type Props = { - destinations: AclDestination[]; -}; +export const DestinationPendingTab = () => { + const { data: destinations } = useSuspenseQuery({ + ...getDestinationsQueryOptions, + select: (resp) => + resp.data.filter((destination) => destination.state !== AclStatus.Applied), + }); -export const DestinationPendingTab = ({ destinations }: Props) => { const { mutate, isPending } = useMutation({ mutationFn: api.acl.destination.applyDestinations, meta: { diff --git a/web/src/pages/PlaygroundPage/PlaygroundPage.tsx b/web/src/pages/PlaygroundPage/PlaygroundPage.tsx index 0dbff1632..fcc148e4e 100644 --- a/web/src/pages/PlaygroundPage/PlaygroundPage.tsx +++ b/web/src/pages/PlaygroundPage/PlaygroundPage.tsx @@ -7,6 +7,7 @@ import './style.scss'; import { useQuery } from '@tanstack/react-query'; import clsx from 'clsx'; import { useMemo, useState } from 'react'; +import Skeleton from 'react-loading-skeleton'; import z from 'zod'; import { CodeSnippet } from '../../shared/components/CodeSnippet/CodeSnippet'; import { Controls } from '../../shared/components/Controls/Controls'; @@ -52,6 +53,11 @@ import testIconSrc from './assets/actionable-test1.png'; export const PlaygroundPage = () => { return (
+ + + + +
diff --git a/web/src/pages/RulesPage/RulesPage.tsx b/web/src/pages/RulesPage/RulesPage.tsx index 4e5c9fab8..a4024ac88 100644 --- a/web/src/pages/RulesPage/RulesPage.tsx +++ b/web/src/pages/RulesPage/RulesPage.tsx @@ -1,40 +1,27 @@ import { useQuery } from '@tanstack/react-query'; -import { useMemo, useState } from 'react'; -import { AclStatus } from '../../shared/api/types'; +import { Suspense, useMemo, useState } from 'react'; import { Page } from '../../shared/components/Page/Page'; +import { TableSkeleton } from '../../shared/components/skeleton/TableSkeleton/TableSkeleton'; import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; import { Tabs } from '../../shared/defguard-ui/components/Tabs/Tabs'; import type { TabsItem } from '../../shared/defguard-ui/components/Tabs/types'; import { ThemeSpacing } from '../../shared/defguard-ui/types'; -import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; -import { getRulesQueryOptions } from '../../shared/query'; +import { getRulesCountQueryOptions } from '../../shared/query'; import { RulesDeployedTab } from './tabs/RulesDeployedTab'; import { RulesPendingTab } from './tabs/RulesPendingTab'; import { RulesPageTab, type RulesPageTabValue } from './types'; export const RulesPage = () => { - // FIXME: split into separate queries - const { data: rules } = useQuery(getRulesQueryOptions); - - const deployed = useMemo(() => { - if (isPresent(rules)) { - return rules.filter((rule) => rule.state === AclStatus.Applied); - } - }, [rules]); - - const pending = useMemo(() => { - if (isPresent(rules)) { - return rules.filter((rule) => rule.state !== AclStatus.Applied); - } - }, [rules]); - const [activeTab, setActiveTab] = useState(RulesPageTab.Deployed); + const { data: rulesCount } = useQuery(getRulesCountQueryOptions); + const pendingTabTitle = useMemo( - () => `Pending${pending?.length ? ` (${pending.length})` : ''}`, - [pending], + () => `Pending${rulesCount?.pending ? ` (${rulesCount.pending})` : ''}`, + [rulesCount], ); + const tabs = useMemo( (): TabsItem[] => [ { @@ -60,14 +47,12 @@ export const RulesPage = () => { - - {activeTab === RulesPageTab.Deployed && isPresent(deployed) && ( - - )} - {activeTab === RulesPageTab.Pending && isPresent(pending) && ( - - )} - + }> + + {activeTab === RulesPageTab.Deployed && } + {activeTab === RulesPageTab.Pending && } + + ); }; diff --git a/web/src/pages/RulesPage/tabs/RulesDeployedTab.tsx b/web/src/pages/RulesPage/tabs/RulesDeployedTab.tsx index b18a68792..c0d3c70d5 100644 --- a/web/src/pages/RulesPage/tabs/RulesDeployedTab.tsx +++ b/web/src/pages/RulesPage/tabs/RulesDeployedTab.tsx @@ -1,21 +1,22 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useMemo } from 'react'; -import type { AclRule } from '../../../shared/api/types'; +import { AclStatus } from '../../../shared/api/types'; import { TableSkeleton } from '../../../shared/components/skeleton/TableSkeleton/TableSkeleton'; import type { ButtonProps } from '../../../shared/defguard-ui/components/Button/types'; import { EmptyStateFlexible } from '../../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; -import { getLicenseInfoQueryOptions } from '../../../shared/query'; +import { getLicenseInfoQueryOptions, getRulesQueryOptions } from '../../../shared/query'; import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; import { RulesTable } from '../RulesTable'; import { useRuleDeps } from '../useRuleDeps'; -type Props = { - rules: AclRule[]; -}; +export const RulesDeployedTab = () => { + const { data: rules } = useSuspenseQuery({ + ...getRulesQueryOptions, + select: (resp) => resp.data.filter((rule) => rule.state === AclStatus.Applied), + }); -export const RulesDeployedTab = ({ rules }: Props) => { const isEmpty = rules.length === 0; const navigate = useNavigate(); @@ -60,7 +61,7 @@ export const RulesDeployedTab = ({ rules }: Props) => { isPresent(locations) && isPresent(users) && isPresent(devices) && - isPresent(license) && ( + license !== undefined && ( { +export const RulesPendingTab = () => { + const { data: rules } = useSuspenseQuery({ + ...getRulesQueryOptions, + select: (resp) => resp.data.filter((rule) => rule.state !== AclStatus.Applied), + }); const isEmpty = rules.length === 0; const { mutate, isPending } = useMutation({ @@ -60,7 +61,7 @@ export const RulesPendingTab = ({ rules }: Props) => { isPresent(locations) && isPresent(users) && isPresent(devices) && - isPresent(license) && ( + license !== undefined && ( client.get('acl/destination/count'), getDestinations: () => client.get('/acl/destination'), getDestination: (destinationId: number | string) => client.get(`/acl/destination/${destinationId}`), @@ -463,6 +465,7 @@ const api = { }), }, alias: { + getCount: () => client.get('acl/alias/count'), getAliases: () => client.get('/acl/alias'), getAlias: (aliasId: number | string) => client.get(`/acl/alias/${aliasId}`), @@ -475,6 +478,7 @@ const api = { }), }, rule: { + getCount: () => client.get('acl/rule/count'), getRules: () => client.get(`/acl/rule`), getRule: (ruleId: number | string) => client.get(`/acl/rule/${ruleId}`), addRule: (data: AddAclRuleRequest) => client.post(`/acl/rule`, data), diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 28d881898..1a68b47d6 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -923,6 +923,11 @@ export const AclProtocolName: Record = { '17': 'UDP', }; +export interface AclCount { + applied: number; + pending: number; +} + export interface AclDestination { id: number; name: string; diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index 0b4543ffc..469cde2a3 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit 0b4543ffcd6e775a21699f4fa15f32fb18e5b89a +Subproject commit 469cde2a3ed4d9489f9f0104b5df2853c5043e20 diff --git a/web/src/shared/query.ts b/web/src/shared/query.ts index 76e1743af..5f3d087ea 100644 --- a/web/src/shared/query.ts +++ b/web/src/shared/query.ts @@ -163,6 +163,23 @@ export const getOpenIdProvidersQueryOptions = queryOptions({ select: (resp) => resp.data, }); +export const getAliasesCountQueryOptions = queryOptions({ + queryFn: api.acl.alias.getCount, + queryKey: ['acl', 'alias', 'count'], + select: (resp) => resp.data, +}); +export const getDestinationsCountQueryOptions = queryOptions({ + queryFn: api.acl.destination.getCount, + queryKey: ['acl', 'destination', 'count'], + select: (resp) => resp.data, +}); + +export const getRulesCountQueryOptions = queryOptions({ + queryFn: api.acl.rule.getCount, + queryKey: ['acl', 'rule', 'count'], + select: (resp) => resp.data, +}); + export const getRulesQueryOptions = queryOptions({ queryFn: api.acl.rule.getRules, queryKey: ['acl', 'rule'],