diff --git a/src/api-client/endpoints.ts b/src/api-client/endpoints.ts index 3cfa51e..d4e0c10 100644 --- a/src/api-client/endpoints.ts +++ b/src/api-client/endpoints.ts @@ -15,6 +15,7 @@ export const API_ENDPOINTS = { MCP_CLASSIFIER_PIPELINE_DEBUG: "/analyzeClassifierPipeline", EVENTS: "/events", EVENT: "/event", + EVENT_RESOURCES: "/eventResources", CLASSIFIERS: "/classifiers", CLASSIFIER: "/classifier", STATS: "/stats", diff --git a/src/assets/admiral-sveltopus.png b/src/assets/admiral-sveltopus.png new file mode 100644 index 0000000..48c66ca Binary files /dev/null and b/src/assets/admiral-sveltopus.png differ diff --git a/src/lib/components/ui/feedback/emptyData.tsx b/src/lib/components/ui/feedback/emptyData.tsx index 7abd0a3..e752c82 100644 --- a/src/lib/components/ui/feedback/emptyData.tsx +++ b/src/lib/components/ui/feedback/emptyData.tsx @@ -1,6 +1,7 @@ -import { CircleOff, FilterX } from "lucide-react"; +import { FilterX } from "lucide-react"; import { Button } from "@/lib/components/ui/inputs/button"; import { RefreshButton } from "@/modules/common/components/actions/RefreshButton"; +import mascot from "@/assets/admiral-sveltopus.png"; type EmptyDataProps = { name: string; @@ -17,9 +18,8 @@ export const EmptyData = ({ <>
-
- -
+ {/* Decorative: the text below says what is missing */} +

There’s no {name} found. diff --git a/src/lib/components/ui/layout/Sidebar.tsx b/src/lib/components/ui/layout/Sidebar.tsx index f971a23..0189aa1 100644 --- a/src/lib/components/ui/layout/Sidebar.tsx +++ b/src/lib/components/ui/layout/Sidebar.tsx @@ -7,6 +7,7 @@ import { Badge } from "@/lib/components/ui/data-display/badge"; import React from "react"; import { cn } from "@/lib/utils"; import { Book, LifeBuoy, ExternalLink } from "lucide-react"; +import mascot from "@/assets/admiral-sveltopus.png"; interface SidebarProps { isOpen: boolean; @@ -129,6 +130,15 @@ export function Sidebar({ isOpen }: SidebarProps) {

+ {/* Decorative. Only when expanded and the window is tall enough: the sidebar clips what does not fit, + and Docs and Support must stay visible */} + {isOpen && ( + + )} {/* Separator before Support/Docs */}
diff --git a/src/locales/ar.json b/src/locales/ar.json index 9149276..5172010 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -54,6 +54,10 @@ "trigger_event": "تشغيل الحدث", "resources": "الموارد", "no_resources": "لم يتم العثور على موارد", + "matching_resources": "الموارد المطابقة", + "matching_resources_count": "الموارد المطابقة: {{count}}", + "matching_resources_no_match": "لا توجد موارد مطابقة لهذه المرشحات.", + "matching_resources_error": "تعذّر تحميل الموارد المطابقة.", "running": "جاري التشغيل", "no_resource_selectors": "لم يتم تحديد محددات الموارد.", "resource_selector": "محدد الموارد", diff --git a/src/locales/en.json b/src/locales/en.json index b56c4c4..c63e566 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -54,6 +54,10 @@ "trigger_event": "Trigger Event", "resources": "Resources", "no_resources": "No resources found", + "matching_resources": "Matching resources", + "matching_resources_count": "Matching resources: {{count}}", + "matching_resources_no_match": "No matching resources for the filters.", + "matching_resources_error": "The matching resources could not be loaded.", "running": "Running", "no_resource_selectors": "No resource selectors defined.", "resource_selector": "Resource Selector", diff --git a/src/locales/fr.json b/src/locales/fr.json index e6c64a2..d0abe23 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -55,6 +55,10 @@ "trigger_event": "Déclencher l'événement", "resources": "Ressources", "no_resources": "Aucune ressource trouvée", + "matching_resources": "Ressources correspondantes", + "matching_resources_count": "Ressources correspondantes : {{count}}", + "matching_resources_no_match": "Aucune ressource correspondante pour ces filtres.", + "matching_resources_error": "Les ressources correspondantes n'ont pas pu être chargées.", "running": "En cours", "no_resource_selectors": "Aucun sélecteur de ressource défini.", "resource_selector": "Sélecteur de ressource", diff --git a/src/modules/enterprise/EnterprisePage.tsx b/src/modules/enterprise/EnterprisePage.tsx index 9ec57e0..e856065 100644 --- a/src/modules/enterprise/EnterprisePage.tsx +++ b/src/modules/enterprise/EnterprisePage.tsx @@ -8,6 +8,10 @@ import { CheckCircle2, Zap, CalendarDays, + HeartPulse, + Lock, + PackageCheck, + Compass, } from "lucide-react"; import { Button } from "@/lib/components/ui/inputs/button"; import { Logo } from "@/lib/components/assets/logo/logo"; @@ -59,6 +63,22 @@ const FEATURES: Feature[] = [ tag: "Deployment", href: "https://projectsveltos.io/latest/deployment_order/progressive_rollout/", }, + { + title: "Job-Based Health Validation", + description: + "Run a Kubernetes Job after a deployment, such as a smoke test or a connectivity probe, and use its outcome as the health check.", + icon: HeartPulse, + tag: "Health", + href: "https://projectsveltos.io/latest/addons/clusterprofile/#job-based-health-validation", + }, + { + title: "Least-Privilege Agents", + description: + "Limit drift detection and the agents to specific namespaces instead of cluster-wide access. One annotation configures both.", + icon: Lock, + tag: "Security", + href: "https://projectsveltos.io/latest/features/configuration_drift/#namespace-scoped-watch-mode", + }, { title: "AI-Powered Management", description: @@ -79,6 +99,22 @@ const FEATURES: Feature[] = [ tag: "Support", href: "https://website.projectsveltos.io/pricing/", }, + { + title: "Priority Patches & Feature Delivery", + description: + "CVE-patched images before the next release, with your bug fixes and feature requests prioritized.", + icon: PackageCheck, + tag: "Support", + href: "https://website.projectsveltos.io/pricing/", + }, + { + title: "Architectural Guidance", + description: + "Expert guidance on architecture and best practices for running Sveltos in production.", + icon: Compass, + tag: "Support", + href: "https://website.projectsveltos.io/pricing/", + }, ]; const TRUST_ITEMS = [ diff --git a/src/modules/events/event-details/components/ClusterMatchCard.tsx b/src/modules/events/event-details/components/ClusterMatchCard.tsx index 6fa65f0..f3f406c 100644 --- a/src/modules/events/event-details/components/ClusterMatchCard.tsx +++ b/src/modules/events/event-details/components/ClusterMatchCard.tsx @@ -1,13 +1,14 @@ import { useNavigate } from "react-router-dom"; import { ExternalLink } from "lucide-react"; import { Button } from "@/lib/components/ui/inputs/button"; -import { ClusterEventMatch, Resource } from "@/types/event.types"; +import { ClusterEventMatch } from "@/types/event.types"; import { Badge } from "@/lib/components/ui/data-display/badge"; import { FailedFlag } from "@/lib/components/ui/data-display/failed-flag"; import { ReadyFlag } from "@/lib/components/ui/data-display/ready-flag"; import { useTranslation } from "react-i18next"; import { McpButton } from "@/lib/components/ui/inputs/mcp-button"; import * as React from "react"; +import { EventResourcesDialog } from "@/modules/events/event-details/components/EventResourcesDialog"; import { useEventClusterDebugMcp } from "@/modules/events/hooks/debugMcp"; import { typeFromPath } from "@/utils/GetPathFromType"; import { getClusterInfoType } from "@/utils/GetClusterInfoType"; @@ -21,6 +22,7 @@ export const ClusterMatchCard = ({ }) => { const { t } = useTranslation(); const navigate = useNavigate(); + const [resourcesOpen, setResourcesOpen] = React.useState(false); const McpQuery = useEventClusterDebugMcp({ namespace: cluster.clusterNamespace, @@ -59,27 +61,31 @@ export const ClusterMatchCard = ({
-
- {t("common.resources")} -
-
- {cluster.resources?.map((res: Resource, idx: number) => ( -
0 ? ( + <> +
- ))} - {(!cluster.resources || cluster.resources.length === 0) && ( -
- {t("common.no_resources")} -
- )} -
+ {t("common.matching_resources_count", { + count: cluster.matchingResourcesCount, + })} + + + + ) : ( +
+ {t("common.no_resources")} +
+ )}
diff --git a/src/modules/events/event-details/components/EventResourcesDialog.tsx b/src/modules/events/event-details/components/EventResourcesDialog.tsx new file mode 100644 index 0000000..62e9c4b --- /dev/null +++ b/src/modules/events/event-details/components/EventResourcesDialog.tsx @@ -0,0 +1,184 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/lib/components/ui/data-display/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/lib/components/ui/data-display/table"; +import { LoadingTableRow } from "@/lib/components/ui/feedback/loadingTableRow"; +import { Input } from "@/lib/components/ui/inputs/input"; +import useDebounce from "@/hooks/useDebounce"; +import { usePagination } from "@/hooks/usePagination"; +import useEventResources from "@/modules/events/event-details/hooks/useEventResources"; +import { appConfig } from "@/config/app"; +import { ClusterType } from "@/types/cluster.types"; +import { + EVENT_RESOURCES_PAGE_SIZE, + EventResourcesFilters, + hasEventResourcesFilters, + noEventResourcesFilters, +} from "@/utils/eventResources"; + +interface EventResourcesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + eventName: string; + clusterNamespace: string; + clusterName: string; + clusterType: ClusterType; +} + +// Table wraps itself in a min-h-120 div: reset it so a short list neither +// leaves blank space nor shows a scrollbar +const tableContainerClassName = "max-h-96 overflow-y-auto [&>div]:min-h-0"; + +// TableCell truncates at 6rem by default, which cuts names +const cellClassName = "max-w-none break-words whitespace-normal"; + +const filterDelayMs = 300; + +// EventResourcesDialog lists, one page at a time, the resources matching the EventSource of an EventTrigger in +// a cluster. A cluster can match hundreds, so they are requested only once the dialog is opened. +export const EventResourcesDialog = ({ + open, + onOpenChange, + eventName, + clusterNamespace, + clusterName, + clusterType, +}: EventResourcesDialogProps) => { + const { t } = useTranslation(); + const [page, setPage] = useState(appConfig.defaultPage); + // What is typed, and what is asked for: the second follows the first after a short pause + const [typed, setTyped] = useState( + noEventResourcesFilters, + ); + const [filters, setFilters] = useState( + noEventResourcesFilters, + ); + + useDebounce( + () => { + setFilters(typed); + }, + [typed], + filterDelayMs, + ); + + // Another page of the previous filters would make no sense + useEffect(() => { + setPage(appConfig.defaultPage); + }, [filters]); + + const query = useEventResources( + { eventName, clusterNamespace, clusterName, clusterType }, + page, + filters, + open, + ); + const resources = query.data?.resources ?? []; + const total = query.data?.totalResources ?? 0; + + const [PaginationUI] = usePagination( + total, + page, + setPage, + EVENT_RESOURCES_PAGE_SIZE, + ); + + const handleFilterChange = + (key: keyof EventResourcesFilters) => + (event: React.ChangeEvent) => + setTyped((current) => ({ ...current, [key]: event.target.value })); + + return ( + + + + {t("common.matching_resources")} + + {`${eventName} · ${clusterNamespace}/${clusterName}`} + + +
+ + + +
+
+ + + + {t("common.kind")} + {t("common.namespace")} + {t("common.name")} + + + {query.isLoading ? ( + + ) : ( + + {resources.length > 0 ? ( + resources.map((resource, index) => ( + + + {resource.group + ? `${resource.kind}.${resource.group}` + : resource.kind} + + + {resource.namespace || "-"} + + + {resource.name} + + + )) + ) : ( + + + {query.isError + ? t("common.matching_resources_error") + : hasEventResourcesFilters(filters) + ? t("common.matching_resources_no_match") + : t("common.no_resources")} + + + )} + + )} +
+
+
+ +
+
+
+ ); +}; diff --git a/src/modules/events/event-details/hooks/useEventResources.ts b/src/modules/events/event-details/hooks/useEventResources.ts new file mode 100644 index 0000000..c61d840 --- /dev/null +++ b/src/modules/events/event-details/hooks/useEventResources.ts @@ -0,0 +1,45 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; + +import client from "@/api-client/apiClient"; +import { API_ENDPOINTS } from "@/api-client/endpoints"; +import { ClusterType } from "@/types/cluster.types"; +import { EventResourcesResponse } from "@/types/event.types"; +import { + buildEventResourcesParams, + EventResourcesFilters, +} from "@/utils/eventResources"; + +type EventResourcesTarget = { + eventName: string; + clusterNamespace: string; + clusterName: string; + clusterType: ClusterType; +}; + +const fetchEventResources = async ( + target: EventResourcesTarget, + page: number, + filters: EventResourcesFilters, +): Promise => { + const { data } = await client.get(API_ENDPOINTS.EVENT_RESOURCES, { + params: buildEventResourcesParams(target, page, filters), + }); + return data as EventResourcesResponse; +}; + +// useEventResources loads one page of the resources matching an EventTrigger in a cluster. Nothing is requested +// until enabled is true: the dialog showing them asks for them only once opened. +const useEventResources = ( + target: EventResourcesTarget, + page: number, + filters: EventResourcesFilters, + enabled: boolean, +): UseQueryResult => { + return useQuery({ + queryKey: ["eventResources", target, page, filters], + queryFn: () => fetchEventResources(target, page, filters), + enabled, + }); +}; + +export default useEventResources; diff --git a/src/types/event.types.ts b/src/types/event.types.ts index 38beba4..253847c 100644 --- a/src/types/event.types.ts +++ b/src/types/event.types.ts @@ -14,10 +14,17 @@ export type ClusterEventMatch = { clusterName: string; clusterKind: string; failureMessage?: string; - resources?: Resource[]; + // Number of resources matching the EventSource in the cluster. They are loaded, paginated, on demand. + matchingResourcesCount: number; paused?: boolean; }; +export type EventResourcesResponse = { + // Number of resources matching the filters, before pagination + totalResources: number; + resources: Resource[]; +}; + export type LabelFilter = { key: string; operation: string; diff --git a/src/utils/eventResources.test.ts b/src/utils/eventResources.test.ts new file mode 100644 index 0000000..7f426de --- /dev/null +++ b/src/utils/eventResources.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { + buildEventResourcesParams, + EVENT_RESOURCES_PAGE_SIZE, + hasEventResourcesFilters, + noEventResourcesFilters, +} from "@/utils/eventResources"; + +describe("buildEventResourcesParams", () => { + const target = { + eventName: "service-network-policy", + clusterNamespace: "default", + clusterName: "prod", + clusterType: "SveltosCluster" as const, + }; + + it("asks for the first page of a cluster without filters", () => { + expect( + buildEventResourcesParams(target, 1, noEventResourcesFilters), + ).toEqual({ + event_name: "service-network-policy", + namespace: "default", + name: "prod", + type: "sveltos", + skip: 0, + limit: EVENT_RESOURCES_PAGE_SIZE, + }); + }); + + it("skips the pages before the one asked for", () => { + const params = buildEventResourcesParams( + { ...target, clusterType: "ClusterAPI" }, + 3, + noEventResourcesFilters, + ); + + expect(params.type).toBe("capi"); + expect(params.skip).toBe(2 * EVENT_RESOURCES_PAGE_SIZE); + }); + + it("sends only the filters which are set, without surrounding spaces", () => { + const params = buildEventResourcesParams(target, 1, { + kind: " Service ", + namespace: " ", + name: "front", + }); + + expect(params.resource_kind).toBe("Service"); + expect(params.resource_name).toBe("front"); + expect(params).not.toHaveProperty("resource_namespace"); + }); +}); + +describe("hasEventResourcesFilters", () => { + it("is false when no filter is set, or only spaces are", () => { + expect(hasEventResourcesFilters(noEventResourcesFilters)).toBe(false); + expect( + hasEventResourcesFilters({ kind: " ", namespace: "", name: "" }), + ).toBe(false); + }); + + it("is true when a filter is set", () => { + expect( + hasEventResourcesFilters({ kind: "", namespace: "", name: "web" }), + ).toBe(true); + }); +}); diff --git a/src/utils/eventResources.ts b/src/utils/eventResources.ts new file mode 100644 index 0000000..ecbce05 --- /dev/null +++ b/src/utils/eventResources.ts @@ -0,0 +1,64 @@ +import { ClusterType } from "@/types/cluster.types"; +import { getClusterInfoType } from "@/utils/GetClusterInfoType"; +import { getItemsToSkip } from "@/utils/getItemsToSkip"; + +export const EVENT_RESOURCES_PAGE_SIZE = 10; + +export type EventResourcesFilters = { + kind: string; + namespace: string; + name: string; +}; + +export const noEventResourcesFilters: EventResourcesFilters = { + kind: "", + namespace: "", + name: "", +}; + +type EventResourcesTarget = { + eventName: string; + clusterNamespace: string; + clusterName: string; + clusterType: ClusterType; +}; + +// buildEventResourcesParams returns the query parameters of ui-backend /eventResources: the page asked for +// and the filters which are set. +export const buildEventResourcesParams = ( + target: EventResourcesTarget, + page: number, + filters: EventResourcesFilters, +): Record => { + const params: Record = { + event_name: target.eventName, + namespace: target.clusterNamespace, + name: target.clusterName, + type: getClusterInfoType(target.clusterType), + skip: getItemsToSkip(page, EVENT_RESOURCES_PAGE_SIZE), + limit: EVENT_RESOURCES_PAGE_SIZE, + }; + + const kind = filters.kind.trim(); + const namespace = filters.namespace.trim(); + const name = filters.name.trim(); + if (kind) { + params.resource_kind = kind; + } + if (namespace) { + params.resource_namespace = namespace; + } + if (name) { + params.resource_name = name; + } + + return params; +}; + +// hasEventResourcesFilters is true when at least one filter is set +export const hasEventResourcesFilters = ( + filters: EventResourcesFilters, +): boolean => + Boolean( + filters.kind.trim() || filters.namespace.trim() || filters.name.trim(), + );