From 32f27faa5ba2d9f3534ba651e281e21699bcea7c Mon Sep 17 00:00:00 2001 From: fedepini Date: Thu, 7 May 2026 22:06:09 +0200 Subject: [PATCH 1/7] feat: added Notifications widget --- .../WidgetRenderer/WidgetRenderer.tsx | 4 + .../Notifications/Notifications.example.yaml | 9 + .../Notifications/Notifications.menu.yaml | 77 +++++++ .../Notifications/Notifications.module.css | 78 +++++++ .../Notifications/Notifications.schema.json | 136 ++++++++++++ src/widgets/Notifications/Notifications.tsx | 202 ++++++++++++++++++ .../Notifications/Notifications.type.d.ts | 58 +++++ src/widgets/Notifications/index.ts | 3 + 8 files changed, 567 insertions(+) create mode 100644 src/examples/widgets/Notifications/Notifications.example.yaml create mode 100644 src/examples/widgets/Notifications/Notifications.menu.yaml create mode 100644 src/widgets/Notifications/Notifications.module.css create mode 100644 src/widgets/Notifications/Notifications.schema.json create mode 100644 src/widgets/Notifications/Notifications.tsx create mode 100644 src/widgets/Notifications/Notifications.type.d.ts create mode 100644 src/widgets/Notifications/index.ts diff --git a/src/components/WidgetRenderer/WidgetRenderer.tsx b/src/components/WidgetRenderer/WidgetRenderer.tsx index 18ee51ab..e1cbe449 100644 --- a/src/components/WidgetRenderer/WidgetRenderer.tsx +++ b/src/components/WidgetRenderer/WidgetRenderer.tsx @@ -27,6 +27,8 @@ import Markdown from '../../widgets/Markdown' import type { MarkdownWidgetData } from '../../widgets/Markdown/Markdown' import type { NavMenuWidgetData } from '../../widgets/NavMenu/NavMenu' import { NavMenu } from '../../widgets/NavMenu/NavMenu' +import type { NotificationsWidgetData } from '../../widgets/Notifications/Notifications' +import Notifications from '../../widgets/Notifications/Notifications' import { Page, type PageWidgetData } from '../../widgets/Page/Page' import type { PanelWidgetData } from '../../widgets/Panel/Panel' import Panel from '../../widgets/Panel/Panel' @@ -131,6 +133,8 @@ const parseWidget = ( return case 'NavMenu': return + case 'Notifications': + return case 'Page': return case 'Panel': diff --git a/src/examples/widgets/Notifications/Notifications.example.yaml b/src/examples/widgets/Notifications/Notifications.example.yaml new file mode 100644 index 00000000..3e4dd8b1 --- /dev/null +++ b/src/examples/widgets/Notifications/Notifications.example.yaml @@ -0,0 +1,9 @@ +# Basic Notifications +# Expected behavior: Displays a Notifications component +kind: Notifications +apiVersion: widgets.templates.krateo.io/v1beta1 +metadata: + name: example-notifications-basic + namespace: krateo-system +spec: + widgetData: {} \ No newline at end of file diff --git a/src/examples/widgets/Notifications/Notifications.menu.yaml b/src/examples/widgets/Notifications/Notifications.menu.yaml new file mode 100644 index 00000000..c026ba48 --- /dev/null +++ b/src/examples/widgets/Notifications/Notifications.menu.yaml @@ -0,0 +1,77 @@ +kind: NavMenuItem +apiVersion: widgets.templates.krateo.io/v1beta1 +metadata: + name: example-notifications-navmenuitem + namespace: krateo-system +spec: + widgetData: + allowedResources: + - pages + resourceRefId: example-notifications-page + label: Notifications + icon: fa-bell + path: /notifications + resourcesRefs: + items: + - id: example-notifications-page + apiVersion: widgets.templates.krateo.io/v1beta1 + name: example-notifications-page + namespace: krateo-system + resource: pages + verb: GET +--- +kind: Page +apiVersion: widgets.templates.krateo.io/v1beta1 +metadata: + name: example-notifications-page + namespace: krateo-system +spec: + widgetData: + allowedResources: + - eventlists + - buttons + - columns + - datagrids + - eventlists + - filters + - flowcharts + - forms + - linecharts + - markdowns + - panels + - paragraphs + - piecharts + - rows + - tables + - tablists + - yamlviewers + items: + - resourceRefId: example-notifications-basic-panel + resourcesRefs: + items: + - id: example-notifications-basic-panel + apiVersion: widgets.templates.krateo.io/v1beta1 + name: example-notifications-basic-panel + namespace: krateo-system + resource: panels + verb: GET +--- +kind: Panel +apiVersion: widgets.templates.krateo.io/v1beta1 +metadata: + name: example-notifications-basic-panel + namespace: krateo-system +spec: + widgetData: + actions: {} + title: 'Basic Notifications' + items: + - resourceRefId: example-notifications-basic + resourcesRefs: + items: + - id: example-notifications-basic + apiVersion: widgets.templates.krateo.io/v1beta1 + name: example-notifications-basic + namespace: krateo-system + resource: notifications + verb: GET \ No newline at end of file diff --git a/src/widgets/Notifications/Notifications.module.css b/src/widgets/Notifications/Notifications.module.css new file mode 100644 index 00000000..58dd8c2f --- /dev/null +++ b/src/widgets/Notifications/Notifications.module.css @@ -0,0 +1,78 @@ +.icon { + svg { + path { + fill: var(--primary-color); + } + } + + font-size: 22px; +} + +.drawer { + & .container { + height: 100%; + overflow-y: hidden; + + & .loading { + align-items: center; + display: flex; + height: 100px; + justify-content: center; + } + + .list { + .listItem { + width: 100%; + + &:hover { + background-color: var(--gray-color); + } + + .notificationElement { + height: auto; + padding: 0; + text-align: left; + text-wrap: wrap; + width: 100%; + + .space { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--spacing-xs); + width: 100%; + + .description { + max-width: 100%; + white-space: initial; + } + + .details { + color: var(--dark-gray-color); + font-size: var(--font-size-xxs); + margin: 0; + } + + .titleWrapper { + display: flex; + gap: var(--spacing-sm); + justify-content: space-between; + width: 100%; + + .timestamp { + color: var(--dark-gray-color); + font-size: var(--font-size-xxs); + margin: 0; + text-align: right; + } + + .title { + text-wrap: wrap; + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/widgets/Notifications/Notifications.schema.json b/src/widgets/Notifications/Notifications.schema.json new file mode 100644 index 00000000..3f6320c2 --- /dev/null +++ b/src/widgets/Notifications/Notifications.schema.json @@ -0,0 +1,136 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "version": { + "description": "widget version", + "type": "string", + "default": "v1beta1" + }, + "kind": { + "default": "Notifications", + "description": "Notifications renders messages coming from a Kubernetes cluster", + "type": "string" + }, + "spec": { + "type": "object", + "properties": { + "widgetData": { + "type": "object", + "properties": { + "queryParams": { + "description": "list of query parameters to add to the notifications call", + "type": "array", + "items": { + "description": "key-value definition of a specific query parameter", + "type": "object", + "properties": { + "name": { + "description": "the name of the query parameter", + "type": "string" + }, + "value": { + "description": "the value of the query parameter", + "type": "string" + } + }, + "required": ["name", "value"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "resourcesRefs": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "allowed": { + "type": "boolean" + }, + "apiVersion": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "payload": { + "type": "object" + }, + "resource": { + "type": "string" + }, + "verb": { + "type": "string", + "enum": ["POST", "PUT", "PATCH", "DELETE", "GET"] + }, + "slice": { + "type": "object", + "properties": { + "offset": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "perPage": { + "type": "integer" + }, + "continue": { + "type": "boolean" + } + }, + "required": ["perPage"] + } + }, + "required": ["id"] + } + } + }, + "required": ["allowed", "id"] + }, + "apiRef": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": ["name", "namespace"], + "additionalProperties": false + }, + "widgetDataTemplate": { + "type": "array", + "items": { + "type": "object", + "properties": { + "forPath": { + "type": "string" + }, + "expression": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "required": ["widgetData"], + "additionalProperties": false + } + }, + "required": ["kind", "spec", "version"] +} diff --git a/src/widgets/Notifications/Notifications.tsx b/src/widgets/Notifications/Notifications.tsx new file mode 100644 index 00000000..9d90f933 --- /dev/null +++ b/src/widgets/Notifications/Notifications.tsx @@ -0,0 +1,202 @@ +import { BellFilled, LoadingOutlined } from '@ant-design/icons' +import { useQueryClient } from '@tanstack/react-query' +import { Badge, Button, Drawer, List, Skeleton, Spin, Typography } from 'antd' +import VirtualList from 'rc-virtual-list' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + +import { useGetEvents } from '../../hooks/useGetEvents' +import type { WidgetProps } from '../../types/Widget' +import type { EventsApiResource } from '../../utils/types' +import { formatISODate } from '../../utils/utils' + +import styles from './Notifications.module.css' +import type { Notifications as WidgetType } from './Notifications.type' + +export type NotificationsWidgetData = WidgetType['spec']['widgetData'] + +type LoaderItem = { + __isLoader: true + __loaderId: string +} + +type NotificationItem = EventsApiResource | LoaderItem + +const isLoaderItem = (item: unknown): item is LoaderItem => + (!!item && typeof item === 'object' && '__isLoader' in item && (item as LoaderItem).__isLoader === true) + +const Notifications = ({ uid }: WidgetProps) => { + const [drawerVisible, setDrawerVisible] = useState(false) + const queryClient = useQueryClient() + const containerRef = useRef(null) + const [listHeight, setListHeight] = useState(0) + + const { + data: notifications = [], + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useGetEvents({ + enabled: drawerVisible, + registerToSSE: drawerVisible, + topic: 'krateo', + }) + + const virtualData: NotificationItem[] = useMemo(() => { + const safeNotifications = notifications.filter((notification): notification is EventsApiResource => notification !== null) + + if (hasNextPage || isFetchingNextPage) { + return [ + ...safeNotifications, + { __isLoader: true, __loaderId: '__loader__' }, + ] + } + + return safeNotifications + }, [notifications, hasNextPage, isFetchingNextPage]) + + const handleVirtualScroll = useCallback((event: React.UIEvent) => { + const target = event.currentTarget + const { clientHeight, scrollHeight, scrollTop } = target + + const nearBottom = scrollHeight - scrollTop - clientHeight < 120 + + if (nearBottom && hasNextPage && !isFetchingNextPage) { + fetchNextPage().catch(console.error) + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]) + + useEffect(() => { + if (drawerVisible) { + queryClient.setQueryData(['events-unread', 'krateo', undefined], 0) + } + }, [drawerVisible, queryClient]) + + useLayoutEffect(() => { + if (!drawerVisible) { + setListHeight(0) + return + } + + const measureHeight = () => { + const drawerBody = document.querySelector('.ant-drawer-body') + if (drawerBody) { + setListHeight(drawerBody.clientHeight) + } + } + + requestAnimationFrame(measureHeight) + }, [drawerVisible]) + + const renderVirtualItem = useCallback((item: NotificationItem, index: number) => { + if (!item) { + return
+ } + + if (isLoaderItem(item)) { + return ( +
+ } spinning /> +
+ ) + } + + const { + created_at: creationTimestamp, + event_type: type, + global_uid: globalUid, + message, + namespace, + reason, + resource_kind: kind, + resource_name: name, + } = item + + const isFirst = index === 0 + const isLast = !hasNextPage && !isFetchingNextPage && index === virtualData.length - 1 + + return ( + + + + ) + }, [hasNextPage, isFetchingNextPage, notifications, virtualData]) + + const notificationList = useMemo(() => ( +
+ {listHeight > 0 && ( + + { + if (!item) { + return '__null__' + } + + if (isLoaderItem(item)) { + return item.__loaderId + } + + return (item).global_uid + }} + onScroll={handleVirtualScroll} + > + {(item: NotificationItem, index: number) => renderVirtualItem(item, index)} + + + )} +
+ ), [handleVirtualScroll, listHeight, renderVirtualItem, virtualData]) + + return ( + <> +
diff --git a/src/context/ConfigContext.tsx b/src/context/ConfigContext.tsx index 744b1a9d..04b99a58 100644 --- a/src/context/ConfigContext.tsx +++ b/src/context/ConfigContext.tsx @@ -7,6 +7,7 @@ export interface Config { SNOWPLOW_API_BASE_URL: string EVENTS_API_BASE_URL: string EVENTS_PUSH_API_BASE_URL: string + NOTIFICATIONS_WIDGET: string ROUTES_LOADER: string INIT: string THEME?: string From 0ac15773b3e640d86e9b2fb16805a183c55e4f8f Mon Sep 17 00:00:00 2001 From: fedepini Date: Fri, 8 May 2026 16:54:39 +0200 Subject: [PATCH 4/7] refactor: removed Notifications component --- .../Notifications/Notifications.module.css | 78 ------- .../Notifications/Notifications.tsx | 197 ------------------ src/components/Notifications/index.ts | 3 - 3 files changed, 278 deletions(-) delete mode 100644 src/components/Notifications/Notifications.module.css delete mode 100644 src/components/Notifications/Notifications.tsx delete mode 100644 src/components/Notifications/index.ts diff --git a/src/components/Notifications/Notifications.module.css b/src/components/Notifications/Notifications.module.css deleted file mode 100644 index 58dd8c2f..00000000 --- a/src/components/Notifications/Notifications.module.css +++ /dev/null @@ -1,78 +0,0 @@ -.icon { - svg { - path { - fill: var(--primary-color); - } - } - - font-size: 22px; -} - -.drawer { - & .container { - height: 100%; - overflow-y: hidden; - - & .loading { - align-items: center; - display: flex; - height: 100px; - justify-content: center; - } - - .list { - .listItem { - width: 100%; - - &:hover { - background-color: var(--gray-color); - } - - .notificationElement { - height: auto; - padding: 0; - text-align: left; - text-wrap: wrap; - width: 100%; - - .space { - display: flex; - flex: 1; - flex-direction: column; - gap: var(--spacing-xs); - width: 100%; - - .description { - max-width: 100%; - white-space: initial; - } - - .details { - color: var(--dark-gray-color); - font-size: var(--font-size-xxs); - margin: 0; - } - - .titleWrapper { - display: flex; - gap: var(--spacing-sm); - justify-content: space-between; - width: 100%; - - .timestamp { - color: var(--dark-gray-color); - font-size: var(--font-size-xxs); - margin: 0; - text-align: right; - } - - .title { - text-wrap: wrap; - } - } - } - } - } - } - } -} \ No newline at end of file diff --git a/src/components/Notifications/Notifications.tsx b/src/components/Notifications/Notifications.tsx deleted file mode 100644 index a6ef08f5..00000000 --- a/src/components/Notifications/Notifications.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { BellFilled, LoadingOutlined } from '@ant-design/icons' -import { useQueryClient } from '@tanstack/react-query' -import { Badge, Button, Drawer, List, Skeleton, Spin, Typography } from 'antd' -import VirtualList from 'rc-virtual-list' -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' - -import { useGetEvents } from '../../hooks/useGetEvents' -import type { EventsApiResource } from '../../utils/types' -import { formatISODate } from '../../utils/utils' - -import styles from './Notifications.module.css' - -type LoaderItem = { - __isLoader: true - __loaderId: string -} - -type NotificationItem = EventsApiResource | LoaderItem - -const isLoaderItem = (item: unknown): item is LoaderItem => - (!!item && typeof item === 'object' && '__isLoader' in item && (item as LoaderItem).__isLoader === true) - -const Notifications: React.FC = () => { - const [drawerVisible, setDrawerVisible] = useState(false) - const queryClient = useQueryClient() - const containerRef = useRef(null) - const [listHeight, setListHeight] = useState(0) - - const { - data: notifications = [], - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - } = useGetEvents({ - enabled: drawerVisible, - registerToSSE: drawerVisible, - topic: 'krateo', - }) - - const virtualData: NotificationItem[] = useMemo(() => { - const safeNotifications = notifications.filter((notification): notification is EventsApiResource => notification !== null) - - if (hasNextPage || isFetchingNextPage) { - return [ - ...safeNotifications, - { __isLoader: true, __loaderId: '__loader__' }, - ] - } - - return safeNotifications - }, [notifications, hasNextPage, isFetchingNextPage]) - - const handleVirtualScroll = useCallback((event: React.UIEvent) => { - const target = event.currentTarget - const { clientHeight, scrollHeight, scrollTop } = target - - const nearBottom = scrollHeight - scrollTop - clientHeight < 120 - - if (nearBottom && hasNextPage && !isFetchingNextPage) { - fetchNextPage().catch(console.error) - } - }, [fetchNextPage, hasNextPage, isFetchingNextPage]) - - useEffect(() => { - if (drawerVisible) { - queryClient.setQueryData(['events-unread', 'krateo', undefined], 0) - } - }, [drawerVisible, queryClient]) - - useLayoutEffect(() => { - if (!drawerVisible) { - setListHeight(0) - return - } - - const measureHeight = () => { - const drawerBody = document.querySelector('.ant-drawer-body') - if (drawerBody) { - setListHeight(drawerBody.clientHeight) - } - } - - requestAnimationFrame(measureHeight) - }, [drawerVisible]) - - const renderVirtualItem = useCallback((item: NotificationItem, index: number) => { - if (!item) { - return
- } - - if (isLoaderItem(item)) { - return ( -
- } spinning /> -
- ) - } - - const { - created_at: creationTimestamp, - event_type: type, - global_uid: uid, - message, - namespace, - reason, - resource_kind: kind, - resource_name: name, - } = item - - const isFirst = index === 0 - const isLast = !hasNextPage && !isFetchingNextPage && index === virtualData.length - 1 - - return ( - - - - ) - }, [hasNextPage, isFetchingNextPage, notifications, virtualData]) - - const notificationList = useMemo(() => ( -
- {listHeight > 0 && ( - - { - if (!item) { - return '__null__' - } - - if (isLoaderItem(item)) { - return item.__loaderId - } - - return (item).global_uid - }} - onScroll={handleVirtualScroll} - > - {(item: NotificationItem, index: number) => renderVirtualItem(item, index)} - - - )} -
- ), [handleVirtualScroll, listHeight, renderVirtualItem, virtualData]) - - return ( - <> -