From e27420a7fd5dfd4f1a816cac0a1001d52403af02 Mon Sep 17 00:00:00 2001 From: typescreep Date: Tue, 21 Oct 2025 19:06:23 +0300 Subject: [PATCH 01/33] form: refactor, additionalprops new handling, new yaml editor handling, inline yaml editor form item | additionalProperties: prepersist & preexpand --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7bcdf8c..875bd3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.137", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.138", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.137", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.137.tgz", - "integrity": "sha512-40wpiC8aRBmXZiNnkqYjqo15UEjF9mowoSGqe1/xzxFdjjeZtSBoDoGhLMFy/j6QBCvgaYnNH7mYqxZg5MS7rg==", + "version": "0.0.1-alpha.138", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.138.tgz", + "integrity": "sha512-TLBnnNUgEKHEhdEjvc7Xjtun27OX3p6A3PhIAFVAUcAPbzSd0kKLzh4hIncScMBLznMnlNhg7mLXfOTYMcBkBQ==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index bff473d..6d9290b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.137", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.138", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", From 196ff4acd403e0aa8f4c524add7ecec983ec6110 Mon Sep 17 00:00:00 2001 From: typescreep Date: Wed, 22 Oct 2025 03:48:42 +0300 Subject: [PATCH 02/33] wildcards | form fixes --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 875bd3a..4a0a0c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.138", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.139", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.138", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.138.tgz", - "integrity": "sha512-TLBnnNUgEKHEhdEjvc7Xjtun27OX3p6A3PhIAFVAUcAPbzSd0kKLzh4hIncScMBLznMnlNhg7mLXfOTYMcBkBQ==", + "version": "0.0.1-alpha.139", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.139.tgz", + "integrity": "sha512-9QktNfx+/HgzC6F1I+lO+FpfxegoLJTn3hp2JTHnST8qH4wchXJ6QXRjlKxlqrImpPfAXz8aa0lh6GsDMFwa9Q==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index 6d9290b..d510797 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.138", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.139", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", From 68c96d132f667902dceb10138011167319023637 Mon Sep 17 00:00:00 2001 From: typescreep Date: Wed, 22 Oct 2025 04:49:10 +0300 Subject: [PATCH 03/33] fix wildcards --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4a0a0c4..3e96d50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.139", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.142", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.139", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.139.tgz", - "integrity": "sha512-9QktNfx+/HgzC6F1I+lO+FpfxegoLJTn3hp2JTHnST8qH4wchXJ6QXRjlKxlqrImpPfAXz8aa0lh6GsDMFwa9Q==", + "version": "0.0.1-alpha.142", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.142.tgz", + "integrity": "sha512-QPx7WgYXPHRb8a9aNyMkhx43JT/cYUfyndr+mpzGmZUZzuQPjhqFIhhm8nKm/+0Lq8jaaPZ4zQj+DebnkW+/ew==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index d510797..65e81bd 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.139", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.142", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", From 370a81db73bc0bd5f1d9f38ca8cf387938f03b15 Mon Sep 17 00:00:00 2001 From: typescreep Date: Thu, 23 Oct 2025 01:15:26 +0300 Subject: [PATCH 04/33] new boolean render --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3e96d50..4a25715 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.142", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.143", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.142", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.142.tgz", - "integrity": "sha512-QPx7WgYXPHRb8a9aNyMkhx43JT/cYUfyndr+mpzGmZUZzuQPjhqFIhhm8nKm/+0Lq8jaaPZ4zQj+DebnkW+/ew==", + "version": "0.0.1-alpha.143", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.143.tgz", + "integrity": "sha512-qtwwXMov5yL70hzanIVffcQ5LykaZXWz9qkYXoNwA0/2+uBJN8zGCP1aqsfcGFMiFbIJNkzZZwdhUGIsfNrOTg==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index 65e81bd..6701e19 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.142", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.143", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", From 4296fbc65300c2a7f62d83b262013240c4c45b5e Mon Sep 17 00:00:00 2001 From: typescreep Date: Thu, 23 Oct 2025 14:36:21 +0300 Subject: [PATCH 05/33] fix sidebar els width --- src/components/molecules/ManageableSidebar/styled.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/molecules/ManageableSidebar/styled.ts b/src/components/molecules/ManageableSidebar/styled.ts index 8be95f6..7297439 100644 --- a/src/components/molecules/ManageableSidebar/styled.ts +++ b/src/components/molecules/ManageableSidebar/styled.ts @@ -71,7 +71,7 @@ const Container = styled.div` } && .ant-menu-sub .ant-menu-item.ant-menu-item-selected { - width: 225px; + width: 214px; margin-left: 25px !important; padding-left: 23px !important; transition: padding 0s; From 3a8b067442d4849241b6f5f5f8141cf285a2b395 Mon Sep 17 00:00:00 2001 From: typescreep Date: Thu, 23 Oct 2025 15:23:19 +0300 Subject: [PATCH 06/33] purge arr elements after deletion | relative path in lists --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4a25715..dfdfaf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.143", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.144", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.143", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.143.tgz", - "integrity": "sha512-qtwwXMov5yL70hzanIVffcQ5LykaZXWz9qkYXoNwA0/2+uBJN8zGCP1aqsfcGFMiFbIJNkzZZwdhUGIsfNrOTg==", + "version": "0.0.1-alpha.144", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.144.tgz", + "integrity": "sha512-RYh8989zopqnpOg+x4irRjzYaUvOKnjOEyG2b0LpRX3jRSnRAKtx/3VBhCQ+70ygFC1qCnuWfiqmjREpqI6x3w==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index 6701e19..e2da0c3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.143", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.144", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", From 679e4308bde7d65f9d1319a8d7c845d6e936f987 Mon Sep 17 00:00:00 2001 From: typescreep Date: Thu, 23 Oct 2025 15:28:16 +0300 Subject: [PATCH 07/33] fix --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index dfdfaf1..ab63f53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.144", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.145", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.144", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.144.tgz", - "integrity": "sha512-RYh8989zopqnpOg+x4irRjzYaUvOKnjOEyG2b0LpRX3jRSnRAKtx/3VBhCQ+70ygFC1qCnuWfiqmjREpqI6x3w==", + "version": "0.0.1-alpha.145", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.145.tgz", + "integrity": "sha512-Qx48E+bWQfa3ByFzMiYFzM6w+ZpNSjldTAMsyXxOiiOUoj/gL/PPwZM0l40GqtMGMPusaI2pAiXl7FxbyDVsYg==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index e2da0c3..f9f6bb6 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.144", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.145", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", From 0518f0ea0fb0b23b5035ee5071f7d2b296b1836a Mon Sep 17 00:00:00 2001 From: typescreep Date: Fri, 24 Oct 2025 00:55:07 +0300 Subject: [PATCH 08/33] trying to watch forms --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab63f53..626b82c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.145", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.147", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.145", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.145.tgz", - "integrity": "sha512-Qx48E+bWQfa3ByFzMiYFzM6w+ZpNSjldTAMsyXxOiiOUoj/gL/PPwZM0l40GqtMGMPusaI2pAiXl7FxbyDVsYg==", + "version": "0.0.1-alpha.147", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.147.tgz", + "integrity": "sha512-EFhX/3gKT337Y49xeWcL8ffS+P/MSLJ0rdlHcp7b2rtq1jbTbduERdvbbC3OQUpr3XsIi9Gag5wtfQah89AoxA==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index f9f6bb6..3acd636 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.145", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.147", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", From 70292be31f5687a14796aab6a1a1129c38e45357 Mon Sep 17 00:00:00 2001 From: typescreep Date: Fri, 24 Oct 2025 19:11:04 +0300 Subject: [PATCH 09/33] fix expanded --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 626b82c..79f7080 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.147", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.148", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.147", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.147.tgz", - "integrity": "sha512-EFhX/3gKT337Y49xeWcL8ffS+P/MSLJ0rdlHcp7b2rtq1jbTbduERdvbbC3OQUpr3XsIi9Gag5wtfQah89AoxA==", + "version": "0.0.1-alpha.148", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.148.tgz", + "integrity": "sha512-8AwZdoS4sp05dobcGIFs8YWd0gsbZLyWC7dJqFSG990B9z432Rfsay/yRfTKSJJgJbznUAZrj5Bir73zAsaW/g==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index 3acd636..618ade7 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.147", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.148", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", From 0012cdb45e182a2c491b9ce233ec0c1e6df9150b Mon Sep 17 00:00:00 2001 From: typescreep Date: Tue, 28 Oct 2025 18:38:44 +0300 Subject: [PATCH 10/33] events probe integration with bff --- src/App.tsx | 2 + src/components/organisms/Events/Events.tsx | 465 +++++++++++++++++++++ src/components/organisms/Events/index.ts | 1 + src/components/organisms/index.ts | 1 + src/pages/EventsPage/EventsPage.tsx | 42 ++ src/pages/EventsPage/index.ts | 1 + src/pages/index.ts | 2 + 7 files changed, 514 insertions(+) create mode 100644 src/components/organisms/Events/Events.tsx create mode 100644 src/components/organisms/Events/index.ts create mode 100644 src/pages/EventsPage/EventsPage.tsx create mode 100644 src/pages/EventsPage/index.ts diff --git a/src/App.tsx b/src/App.tsx index 6711b2b..34dedbc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import { FactoryPage, FactoryAdminPage, SearchPage, + EventsPage, } from 'pages' import { getBasePrefix } from 'utils/getBaseprefix' import { colorsLight, colorsDark, sizes } from 'constants/colors' @@ -124,6 +125,7 @@ export const App: FC = ({ isFederation, forcedTheme }) => { element={} /> } /> + } /> } /> diff --git a/src/components/organisms/Events/Events.tsx b/src/components/organisms/Events/Events.tsx new file mode 100644 index 0000000..93548b9 --- /dev/null +++ b/src/components/organisms/Events/Events.tsx @@ -0,0 +1,465 @@ +import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' + +// ------------------------------------------------------------ +// Simple, self-contained React component implementing: +// - WebSocket connection to your events endpoint +// - Handling of INITIAL, PAGE, ADDED, MODIFIED, DELETED, PAGE_ERROR +// - Infinite scroll via IntersectionObserver (sends { type: "SCROLL" }) +// - Lightweight CSS-in-JS styling +// - Minimal reconnection logic +// - Small initials avatar (derived from a name/kind) +// ------------------------------------------------------------ + +// Types for messages coming from the server +// (Kept permissive to avoid bringing in k8s types.) + +type TWatchPhase = 'ADDED' | 'MODIFIED' | 'DELETED' | 'BOOKMARK' + +type EventsV1Event = { + metadata?: { + name?: string + namespace?: string + resourceVersion?: string + creationTimestamp?: string + } + type?: string // Normal | Warning + reason?: string + note?: string // message text in events.k8s.io/v1 + message?: string // legacy fallback + reportingController?: string + reportingInstance?: string + deprecatedCount?: number + action?: string + eventTime?: string + regarding?: { + kind?: string + name?: string + namespace?: string + } + deprecatedSource?: { + component?: string + host?: string + } +} + +// Incoming frames from the server + +type InitialFrame = { + type: 'INITIAL' + items: EventsV1Event[] + continue?: string + remainingItemCount?: number + resourceVersion?: string +} + +type PageFrame = { + type: 'PAGE' + items: EventsV1Event[] + continue?: string + remainingItemCount?: number +} + +type PageErrorFrame = { + type: 'PAGE_ERROR' + error: string +} + +type DeltaFrame = { + type: TWatchPhase // ADDED | MODIFIED | DELETED + item: EventsV1Event +} + +type ServerFrame = InitialFrame | PageFrame | PageErrorFrame | DeltaFrame + +// Outgoing scroll request to server + +type ScrollMsg = { + type: 'SCROLL' + continue: string + limit?: number +} + +// ------------------------------------------------------------ +// Styling (CSS-in-JS) +// ------------------------------------------------------------ + +const styles = { + root: { + display: 'flex', + flexDirection: 'column' as const, + width: '100%', + height: '100%', + maxHeight: 640, + border: '1px solid #e5e7eb', + borderRadius: 12, + overflow: 'hidden', + fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif', + background: '#fff', + }, + header: { + padding: '12px 16px', + borderBottom: '1px solid #f0f2f5', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + background: '#fafafa', + }, + title: { fontSize: 14, fontWeight: 600, color: '#111827' }, + status: { fontSize: 12, color: '#6b7280' }, + list: { + flex: 1, + overflowY: 'auto' as const, + padding: 8, + }, + card: { + display: 'grid', + gridTemplateColumns: '40px 1fr', + gap: 12, + padding: 10, + margin: '6px 4px', + border: '1px solid #eef1f4', + borderRadius: 10, + background: '#ffffff', + boxShadow: '0 1px 2px rgba(16,24,40,0.04)', + }, + avatar: { + width: 36, + height: 36, + borderRadius: 999, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 12, + fontWeight: 700, + background: '#eef2ff', + color: '#3730a3', + userSelect: 'none' as const, + letterSpacing: 0.3, + }, + primary: { fontSize: 14, fontWeight: 600, color: '#0f172a' }, + secondary: { fontSize: 12, color: '#334155' }, + meta: { fontSize: 11, color: '#64748b' }, + sentinel: { height: 1 }, + footer: { + borderTop: '1px solid #f0f2f5', + padding: '8px 12px', + fontSize: 12, + color: '#6b7280', + display: 'flex', + alignItems: 'center', + gap: 8, + }, + badge: (tone: 'warning' | 'normal') => ({ + fontSize: 10, + padding: '2px 6px', + borderRadius: 6, + background: tone === 'warning' ? '#fef3c7' : '#e5f4ff', + color: tone === 'warning' ? '#92400e' : '#0b5394', + border: `1px solid ${tone === 'warning' ? '#fde68a' : '#cfe8ff'}`, + fontWeight: 600, + }), +} + +// ------------------------------------------------------------ +// Helpers +// ------------------------------------------------------------ + +const getInitials = (s?: string) => { + if (!s) return '?' + const parts = s + .replace(/[^A-Za-z0-9 ]/g, ' ') + .split(' ') + .filter(Boolean) + const first = parts[0]?.[0] ?? '' + const last = parts.length > 1 ? parts[parts.length - 1][0] : '' + return (first + last).toUpperCase() || s.slice(0, 2).toUpperCase() +} + +const eventKey = (e: EventsV1Event) => { + const n = e.metadata?.name ?? '' + const ns = e.metadata?.namespace ?? '' + return `${ns}/${n}` +} + +const eventText = (e: EventsV1Event) => e.note || e.message || '' + +const eventKindName = (e: EventsV1Event) => e.regarding?.kind || e.metadata?.name || e.regarding?.name || 'event' + +const timeAgo = (iso?: string) => { + if (!iso) return '' + const dt = new Date(iso).getTime() + const diff = Date.now() - dt + if (diff < 60_000) return `${Math.max(0, Math.floor(diff / 1000))}s ago` + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago` + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago` + return new Date(iso).toLocaleString() +} + +// ------------------------------------------------------------ +// Reducer to maintain a keyed list of events, supporting ADDED/MODIFIED/DELETED +// ------------------------------------------------------------ + +type State = { + order: string[] // list of keys (newest first) + byKey: Record +} + +type Action = + | { type: 'RESET'; items: EventsV1Event[] } + | { type: 'APPEND_PAGE'; items: EventsV1Event[] } // for older pages (append to end) + | { type: 'UPSERT'; item: EventsV1Event } // ADDED/MODIFIED + | { type: 'REMOVE'; key: string } // DELETED + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'RESET': { + const order = action.items.map(eventKey) + const byKey: State['byKey'] = {} + // eslint-disable-next-line no-return-assign + action.items.forEach(it => (byKey[eventKey(it)] = it)) + return { order, byKey } + } + case 'APPEND_PAGE': { + const next = { ...state.byKey } + const addKeys: string[] = [] + action.items.forEach(it => { + const k = eventKey(it) + if (!next[k]) addKeys.push(k) + next[k] = it + }) + return { order: [...state.order, ...addKeys], byKey: next } + } + case 'UPSERT': { + const k = eventKey(action.item) + const exists = Boolean(state.byKey[k]) + const byKey = { ...state.byKey, [k]: action.item } + const order = exists ? state.order : [k, ...state.order] + return { order, byKey } + } + case 'REMOVE': { + if (!state.byKey[action.key]) return state + const byKey = { ...state.byKey } + delete byKey[action.key] + return { order: state.order.filter(k => k !== action.key), byKey } + } + default: + return state + } +} + +// ------------------------------------------------------------ +// Component +// ------------------------------------------------------------ + +type Props = { + wsUrl: string // e.g. ws://localhost:3000/api/events?namespace=default&limit=40 + pageSize?: number // SCROLL page size (optional) + height?: number | string // optional override + title?: string +} + +const EventRow: React.FC<{ e: EventsV1Event }> = ({ e }) => { + const initials = useMemo(() => getInitials(eventKindName(e)), [e]) + const tone = (e.type || '').toLowerCase() === 'warning' ? 'warning' : 'normal' + return ( +
+
+ {initials} +
+
+
+
{e.reason || e.action || 'Event'}
+ {e.type || 'Normal'} + {e.regarding?.kind && ( + + {e.regarding.kind} + {e.regarding.name ? ` · ${e.regarding.name}` : ''} + + )} +
+ {eventText(e) &&
{eventText(e)}
} +
+ {e.metadata?.namespace ? `${e.metadata.namespace} · ` : ''} + {e.metadata?.name || ''} + {e.metadata?.creationTimestamp ? ` · ${timeAgo(e.metadata.creationTimestamp)}` : ''} +
+
+
+ ) +} + +export const Events: React.FC = ({ wsUrl, pageSize = 50, height, title = 'Cluster Events' }) => { + const [state, dispatch] = useReducer(reducer, { order: [], byKey: {} }) + const [contToken, setContToken] = useState(undefined) + const [hasMore, setHasMore] = useState(false) + const [connStatus, setConnStatus] = useState<'connecting' | 'open' | 'closed'>('connecting') + const [lastError, setLastError] = useState(undefined) + + const wsRef = useRef(null) + const listRef = useRef(null) + const sentinelRef = useRef(null) + const wantMoreRef = useRef(false) + const fetchingRef = useRef(false) + const backoffRef = useRef(750) + const urlRef = useRef(wsUrl) + + useEffect(() => { + urlRef.current = wsUrl + }, [wsUrl]) + + const closeWS = useCallback(() => { + try { + wsRef.current?.close() + } catch (e) { + // eslint-disable-next-line no-console + console.error(e) + } + wsRef.current = null + }, []) + + const sendScroll = useCallback(() => { + const token = contToken + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return + if (!token || fetchingRef.current) return + fetchingRef.current = true + const msg: ScrollMsg = { type: 'SCROLL', continue: token, limit: pageSize } + wsRef.current.send(JSON.stringify(msg)) + }, [contToken, pageSize]) + + const onMessage = useCallback((ev: MessageEvent) => { + let frame: ServerFrame | undefined + try { + frame = JSON.parse(String(ev.data)) + } catch { + return + } + if (!frame) return + + if (frame.type === 'INITIAL') { + dispatch({ type: 'RESET', items: frame.items }) + setContToken(frame.continue) + setHasMore(Boolean(frame.continue)) + setLastError(undefined) + return + } + + if (frame.type === 'PAGE') { + dispatch({ type: 'APPEND_PAGE', items: frame.items }) + setContToken(frame.continue) + setHasMore(Boolean(frame.continue)) + fetchingRef.current = false + return + } + + if (frame.type === 'PAGE_ERROR') { + setLastError(frame.error || 'Failed to load next page') + fetchingRef.current = false + return + } + + if (frame.type === 'ADDED' || frame.type === 'MODIFIED') { + dispatch({ type: 'UPSERT', item: frame.item }) + return + } + + if (frame.type === 'DELETED') { + dispatch({ type: 'REMOVE', key: eventKey(frame.item) }) + } + }, []) + + const connect = useCallback(() => { + setConnStatus('connecting') + setLastError(undefined) + + const buildWsUrl = (raw: string) => { + if (/^wss?:/i.test(raw)) return raw // already absolute ws(s) + const origin = window.location.origin.replace(/^http/i, 'ws') + if (raw.startsWith('/')) return `${origin}${raw}` + return `${origin}/${raw}` + } + + const ws = new WebSocket(buildWsUrl(urlRef.current)) + wsRef.current = ws + + ws.addEventListener('open', () => { + setConnStatus('open') + backoffRef.current = 750 // reset backoff on success + }) + + ws.addEventListener('message', onMessage) + + const scheduleReconnect = () => { + if (wsRef.current === ws) wsRef.current = null + setConnStatus('closed') + const wait = Math.min(backoffRef.current, 8000) + const next = Math.min(wait * 2, 12000) + backoffRef.current = next + // reconnection timer + setTimeout(() => { + connect() + }, wait) + } + + ws.addEventListener('close', scheduleReconnect) + ws.addEventListener('error', () => { + setLastError('WebSocket error') + scheduleReconnect() + }) + }, [onMessage]) + + useEffect(() => { + connect() + return () => closeWS() + }, [connect, closeWS]) + + // IntersectionObserver to trigger SCROLL when sentinel becomes visible + useEffect(() => { + const el = sentinelRef.current + if (!el) return undefined + + const io = new IntersectionObserver(entries => { + const visible = entries.some(e => e.isIntersecting) + wantMoreRef.current = visible + if (visible && hasMore) sendScroll() + }) + + io.observe(el) + return () => io.disconnect() + }, [hasMore, sendScroll]) + + // If user scrolls manually and hits bottom, attempt to fetch + const onScroll = useCallback(() => { + if (!listRef.current) return + const nearBottom = listRef.current.scrollTop + listRef.current.clientHeight >= listRef.current.scrollHeight - 24 + if (nearBottom && hasMore) sendScroll() + }, [hasMore, sendScroll]) + + const total = state.order.length + + return ( +
+
+
{title}
+
+ {connStatus === 'connecting' && 'Connecting…'} + {connStatus === 'open' && 'Live'} + {connStatus === 'closed' && 'Reconnecting…'} + {typeof total === 'number' ? ` · ${total} items` : ''} +
+
+ +
+ {state.order.map(k => ( + + ))} + {/* Infinite scroll sentinel */} +
+
+ +
+ {hasMore ? Scroll to load older events… : No more events.} + {lastError && · {lastError}} +
+
+ ) +} diff --git a/src/components/organisms/Events/index.ts b/src/components/organisms/Events/index.ts new file mode 100644 index 0000000..b4d4184 --- /dev/null +++ b/src/components/organisms/Events/index.ts @@ -0,0 +1 @@ +export * from './Events' diff --git a/src/components/organisms/index.ts b/src/components/organisms/index.ts index e79f7bc..7042caa 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -10,3 +10,4 @@ export * from './HeaderSecond' export * from './Sidebar' export * from './Footer' export * from './Search' +export * from './Events' diff --git a/src/pages/EventsPage/EventsPage.tsx b/src/pages/EventsPage/EventsPage.tsx new file mode 100644 index 0000000..f6584f2 --- /dev/null +++ b/src/pages/EventsPage/EventsPage.tsx @@ -0,0 +1,42 @@ +import React, { FC } from 'react' +import { useParams } from 'react-router-dom' +import { ManageableBreadcrumbs, ManageableSidebar, Events, NavigationContainer } from 'components' +import { getBreadcrumbsIdPrefix } from 'utils/getBreadcrumbsIdPrefix' +import { getSidebarIdPrefix } from 'utils/getSidebarIdPrefix' +import { BaseTemplate } from 'templates' + +export const EventsPage: FC = () => { + const { clusterName, namespace, syntheticProject, key } = useParams() + + const possibleProject = syntheticProject && namespace ? syntheticProject : namespace + const possibleInstance = syntheticProject && namespace ? namespace : undefined + + const breadcrumbsId = `${getBreadcrumbsIdPrefix({ + instance: !!syntheticProject, + project: !!namespace, + })}factory-${key}` + + const sidebarId = `${getSidebarIdPrefix({ + instance: !!syntheticProject, + project: !!namespace, + })}factory-${key}` + + return ( + + } + // withNoCluster + > + + + + + + ) +} diff --git a/src/pages/EventsPage/index.ts b/src/pages/EventsPage/index.ts new file mode 100644 index 0000000..1fe46c6 --- /dev/null +++ b/src/pages/EventsPage/index.ts @@ -0,0 +1 @@ +export { EventsPage } from './EventsPage' diff --git a/src/pages/index.ts b/src/pages/index.ts index 82d8723..a1b7138 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -19,3 +19,5 @@ export { FactoryPage } from './FactoryPage' export { FactoryAdminPage } from './FactoryAdminPage' /* search */ export { SearchPage } from './SearchPage' +/* events */ +export { EventsPage } from './EventsPage' From 7a664d94b9735ce4f0d73986ea663487729039ae Mon Sep 17 00:00:00 2001 From: typescreep Date: Wed, 29 Oct 2025 22:21:37 +0300 Subject: [PATCH 11/33] better structure --- src/components/organisms/Events/Events.tsx | 367 ++++-------------- .../Events/molecules/EventRow/EventRow.tsx | 38 ++ .../Events/molecules/EventRow/index.ts | 1 + .../Events/molecules/EventRow/styled.ts | 76 ++++ .../Events/molecules/EventRow/utils.ts | 42 ++ .../organisms/Events/molecules/index.ts | 1 + src/components/organisms/Events/reducer.ts | 60 +++ src/components/organisms/Events/styled.ts | 65 ++++ src/components/organisms/Events/types.ts | 78 ++++ src/components/organisms/Events/utils.ts | 8 + 10 files changed, 437 insertions(+), 299 deletions(-) create mode 100644 src/components/organisms/Events/molecules/EventRow/EventRow.tsx create mode 100644 src/components/organisms/Events/molecules/EventRow/index.ts create mode 100644 src/components/organisms/Events/molecules/EventRow/styled.ts create mode 100644 src/components/organisms/Events/molecules/EventRow/utils.ts create mode 100644 src/components/organisms/Events/molecules/index.ts create mode 100644 src/components/organisms/Events/reducer.ts create mode 100644 src/components/organisms/Events/styled.ts create mode 100644 src/components/organisms/Events/types.ts create mode 100644 src/components/organisms/Events/utils.ts diff --git a/src/components/organisms/Events/Events.tsx b/src/components/organisms/Events/Events.tsx index 93548b9..a8199fd 100644 --- a/src/components/organisms/Events/Events.tsx +++ b/src/components/organisms/Events/Events.tsx @@ -1,312 +1,54 @@ -import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' - // ------------------------------------------------------------ // Simple, self-contained React component implementing: // - WebSocket connection to your events endpoint // - Handling of INITIAL, PAGE, ADDED, MODIFIED, DELETED, PAGE_ERROR // - Infinite scroll via IntersectionObserver (sends { type: "SCROLL" }) // - Lightweight CSS-in-JS styling -// - Minimal reconnection logic +// - Minimal reconnection logic (bounded exponential backoff) // - Small initials avatar (derived from a name/kind) // ------------------------------------------------------------ -// Types for messages coming from the server -// (Kept permissive to avoid bringing in k8s types.) +import React, { FC, useCallback, useEffect, useReducer, useRef, useState } from 'react' +import { TScrollMsg, TServerFrame } from './types' +import { eventKey } from './utils' +import { reducer } from './reducer' +import { EventRow } from './molecules' +import { Styled } from './styled' -type TWatchPhase = 'ADDED' | 'MODIFIED' | 'DELETED' | 'BOOKMARK' - -type EventsV1Event = { - metadata?: { - name?: string - namespace?: string - resourceVersion?: string - creationTimestamp?: string - } - type?: string // Normal | Warning - reason?: string - note?: string // message text in events.k8s.io/v1 - message?: string // legacy fallback - reportingController?: string - reportingInstance?: string - deprecatedCount?: number - action?: string - eventTime?: string - regarding?: { - kind?: string - name?: string - namespace?: string - } - deprecatedSource?: { - component?: string - host?: string - } -} - -// Incoming frames from the server - -type InitialFrame = { - type: 'INITIAL' - items: EventsV1Event[] - continue?: string - remainingItemCount?: number - resourceVersion?: string -} - -type PageFrame = { - type: 'PAGE' - items: EventsV1Event[] - continue?: string - remainingItemCount?: number -} - -type PageErrorFrame = { - type: 'PAGE_ERROR' - error: string -} - -type DeltaFrame = { - type: TWatchPhase // ADDED | MODIFIED | DELETED - item: EventsV1Event -} - -type ServerFrame = InitialFrame | PageFrame | PageErrorFrame | DeltaFrame - -// Outgoing scroll request to server - -type ScrollMsg = { - type: 'SCROLL' - continue: string - limit?: number -} - -// ------------------------------------------------------------ -// Styling (CSS-in-JS) -// ------------------------------------------------------------ - -const styles = { - root: { - display: 'flex', - flexDirection: 'column' as const, - width: '100%', - height: '100%', - maxHeight: 640, - border: '1px solid #e5e7eb', - borderRadius: 12, - overflow: 'hidden', - fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif', - background: '#fff', - }, - header: { - padding: '12px 16px', - borderBottom: '1px solid #f0f2f5', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - background: '#fafafa', - }, - title: { fontSize: 14, fontWeight: 600, color: '#111827' }, - status: { fontSize: 12, color: '#6b7280' }, - list: { - flex: 1, - overflowY: 'auto' as const, - padding: 8, - }, - card: { - display: 'grid', - gridTemplateColumns: '40px 1fr', - gap: 12, - padding: 10, - margin: '6px 4px', - border: '1px solid #eef1f4', - borderRadius: 10, - background: '#ffffff', - boxShadow: '0 1px 2px rgba(16,24,40,0.04)', - }, - avatar: { - width: 36, - height: 36, - borderRadius: 999, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: 12, - fontWeight: 700, - background: '#eef2ff', - color: '#3730a3', - userSelect: 'none' as const, - letterSpacing: 0.3, - }, - primary: { fontSize: 14, fontWeight: 600, color: '#0f172a' }, - secondary: { fontSize: 12, color: '#334155' }, - meta: { fontSize: 11, color: '#64748b' }, - sentinel: { height: 1 }, - footer: { - borderTop: '1px solid #f0f2f5', - padding: '8px 12px', - fontSize: 12, - color: '#6b7280', - display: 'flex', - alignItems: 'center', - gap: 8, - }, - badge: (tone: 'warning' | 'normal') => ({ - fontSize: 10, - padding: '2px 6px', - borderRadius: 6, - background: tone === 'warning' ? '#fef3c7' : '#e5f4ff', - color: tone === 'warning' ? '#92400e' : '#0b5394', - border: `1px solid ${tone === 'warning' ? '#fde68a' : '#cfe8ff'}`, - fontWeight: 600, - }), -} - -// ------------------------------------------------------------ -// Helpers -// ------------------------------------------------------------ - -const getInitials = (s?: string) => { - if (!s) return '?' - const parts = s - .replace(/[^A-Za-z0-9 ]/g, ' ') - .split(' ') - .filter(Boolean) - const first = parts[0]?.[0] ?? '' - const last = parts.length > 1 ? parts[parts.length - 1][0] : '' - return (first + last).toUpperCase() || s.slice(0, 2).toUpperCase() -} - -const eventKey = (e: EventsV1Event) => { - const n = e.metadata?.name ?? '' - const ns = e.metadata?.namespace ?? '' - return `${ns}/${n}` -} - -const eventText = (e: EventsV1Event) => e.note || e.message || '' - -const eventKindName = (e: EventsV1Event) => e.regarding?.kind || e.metadata?.name || e.regarding?.name || 'event' - -const timeAgo = (iso?: string) => { - if (!iso) return '' - const dt = new Date(iso).getTime() - const diff = Date.now() - dt - if (diff < 60_000) return `${Math.max(0, Math.floor(diff / 1000))}s ago` - if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago` - if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago` - return new Date(iso).toLocaleString() -} - -// ------------------------------------------------------------ -// Reducer to maintain a keyed list of events, supporting ADDED/MODIFIED/DELETED -// ------------------------------------------------------------ - -type State = { - order: string[] // list of keys (newest first) - byKey: Record -} - -type Action = - | { type: 'RESET'; items: EventsV1Event[] } - | { type: 'APPEND_PAGE'; items: EventsV1Event[] } // for older pages (append to end) - | { type: 'UPSERT'; item: EventsV1Event } // ADDED/MODIFIED - | { type: 'REMOVE'; key: string } // DELETED - -const reducer = (state: State, action: Action): State => { - switch (action.type) { - case 'RESET': { - const order = action.items.map(eventKey) - const byKey: State['byKey'] = {} - // eslint-disable-next-line no-return-assign - action.items.forEach(it => (byKey[eventKey(it)] = it)) - return { order, byKey } - } - case 'APPEND_PAGE': { - const next = { ...state.byKey } - const addKeys: string[] = [] - action.items.forEach(it => { - const k = eventKey(it) - if (!next[k]) addKeys.push(k) - next[k] = it - }) - return { order: [...state.order, ...addKeys], byKey: next } - } - case 'UPSERT': { - const k = eventKey(action.item) - const exists = Boolean(state.byKey[k]) - const byKey = { ...state.byKey, [k]: action.item } - const order = exists ? state.order : [k, ...state.order] - return { order, byKey } - } - case 'REMOVE': { - if (!state.byKey[action.key]) return state - const byKey = { ...state.byKey } - delete byKey[action.key] - return { order: state.order.filter(k => k !== action.key), byKey } - } - default: - return state - } -} - -// ------------------------------------------------------------ -// Component -// ------------------------------------------------------------ - -type Props = { +type TEventsProps = { wsUrl: string // e.g. ws://localhost:3000/api/events?namespace=default&limit=40 pageSize?: number // SCROLL page size (optional) - height?: number | string // optional override + height?: number // optional override title?: string } -const EventRow: React.FC<{ e: EventsV1Event }> = ({ e }) => { - const initials = useMemo(() => getInitials(eventKindName(e)), [e]) - const tone = (e.type || '').toLowerCase() === 'warning' ? 'warning' : 'normal' - return ( -
-
- {initials} -
-
-
-
{e.reason || e.action || 'Event'}
- {e.type || 'Normal'} - {e.regarding?.kind && ( - - {e.regarding.kind} - {e.regarding.name ? ` · ${e.regarding.name}` : ''} - - )} -
- {eventText(e) &&
{eventText(e)}
} -
- {e.metadata?.namespace ? `${e.metadata.namespace} · ` : ''} - {e.metadata?.name || ''} - {e.metadata?.creationTimestamp ? ` · ${timeAgo(e.metadata.creationTimestamp)}` : ''} -
-
-
- ) -} - -export const Events: React.FC = ({ wsUrl, pageSize = 50, height, title = 'Cluster Events' }) => { +export const Events: FC = ({ wsUrl, pageSize = 50, height, title = 'Cluster Events' }) => { + // Reducer-backed store of events const [state, dispatch] = useReducer(reducer, { order: [], byKey: {} }) + + // Pagination/bookmarking state returned by server const [contToken, setContToken] = useState(undefined) const [hasMore, setHasMore] = useState(false) + + // Connection state & errors for small status UI const [connStatus, setConnStatus] = useState<'connecting' | 'open' | 'closed'>('connecting') const [lastError, setLastError] = useState(undefined) - const wsRef = useRef(null) - const listRef = useRef(null) - const sentinelRef = useRef(null) - const wantMoreRef = useRef(false) - const fetchingRef = useRef(false) - const backoffRef = useRef(750) - const urlRef = useRef(wsUrl) + // ------------------ Refs (mutable, do not trigger render) ------------------ + const wsRef = useRef(null) // current WebSocket instance + const listRef = useRef(null) // scrollable list element + const sentinelRef = useRef(null) // bottom sentinel for IO + const wantMoreRef = useRef(false) // whether sentinel is currently visible + const fetchingRef = useRef(false) // guard: avoid parallel PAGE requests + const backoffRef = useRef(750) // ms; increases on failures up to a cap + const urlRef = useRef(wsUrl) // latest wsUrl (stable inside callbacks) + // Keep urlRef in sync so connect() uses the latest wsUrl useEffect(() => { urlRef.current = wsUrl }, [wsUrl]) + // Close current WS safely const closeWS = useCallback(() => { try { wsRef.current?.close() @@ -317,25 +59,29 @@ export const Events: React.FC = ({ wsUrl, pageSize = 50, height, title = wsRef.current = null }, []) + // Attempt to request the next page of older events const sendScroll = useCallback(() => { const token = contToken if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return if (!token || fetchingRef.current) return fetchingRef.current = true - const msg: ScrollMsg = { type: 'SCROLL', continue: token, limit: pageSize } + const msg: TScrollMsg = { type: 'SCROLL', continue: token, limit: pageSize } wsRef.current.send(JSON.stringify(msg)) }, [contToken, pageSize]) + // Handle all incoming frames from the server const onMessage = useCallback((ev: MessageEvent) => { - let frame: ServerFrame | undefined + let frame: TServerFrame | undefined try { frame = JSON.parse(String(ev.data)) } catch { + // Ignore malformed frames; you could surface these in UI if desired return } if (!frame) return if (frame.type === 'INITIAL') { + // Replace current list with newest set; set pagination token dispatch({ type: 'RESET', items: frame.items }) setContToken(frame.continue) setHasMore(Boolean(frame.continue)) @@ -344,6 +90,7 @@ export const Events: React.FC = ({ wsUrl, pageSize = 50, height, title = } if (frame.type === 'PAGE') { + // Append older items to the end; clear fetching guard dispatch({ type: 'APPEND_PAGE', items: frame.items }) setContToken(frame.continue) setHasMore(Boolean(frame.continue)) @@ -352,25 +99,30 @@ export const Events: React.FC = ({ wsUrl, pageSize = 50, height, title = } if (frame.type === 'PAGE_ERROR') { + // Keep live stream but surface pagination error setLastError(frame.error || 'Failed to load next page') fetchingRef.current = false return } if (frame.type === 'ADDED' || frame.type === 'MODIFIED') { + // Live update: insert or replace dispatch({ type: 'UPSERT', item: frame.item }) return } if (frame.type === 'DELETED') { + // Live delete dispatch({ type: 'REMOVE', key: eventKey(frame.item) }) } }, []) + // Establish and maintain the WebSocket connection with bounded backoff const connect = useCallback(() => { setConnStatus('connecting') setLastError(undefined) + // Accept absolute ws(s) URLs; otherwise resolve relative to current origin const buildWsUrl = (raw: string) => { if (/^wss?:/i.test(raw)) return raw // already absolute ws(s) const origin = window.location.origin.replace(/^http/i, 'ws') @@ -389,12 +141,13 @@ export const Events: React.FC = ({ wsUrl, pageSize = 50, height, title = ws.addEventListener('message', onMessage) const scheduleReconnect = () => { + // Only clear if we're still looking at this instance if (wsRef.current === ws) wsRef.current = null setConnStatus('closed') const wait = Math.min(backoffRef.current, 8000) const next = Math.min(wait * 2, 12000) backoffRef.current = next - // reconnection timer + // Reconnect after a short delay; preserves component mount semantics setTimeout(() => { connect() }, wait) @@ -407,6 +160,7 @@ export const Events: React.FC = ({ wsUrl, pageSize = 50, height, title = }) }, [onMessage]) + // Kick off initial connection on mount; clean up on unmount useEffect(() => { connect() return () => closeWS() @@ -414,20 +168,34 @@ export const Events: React.FC = ({ wsUrl, pageSize = 50, height, title = // IntersectionObserver to trigger SCROLL when sentinel becomes visible useEffect(() => { + // Get the current DOM element referenced by sentinelRef const el = sentinelRef.current + + // If the sentinel element is not mounted yet, exit early if (!el) return undefined + // Create a new IntersectionObserver to watch visibility changes of the sentinel const io = new IntersectionObserver(entries => { + // Determine if any observed element is currently visible in the viewport const visible = entries.some(e => e.isIntersecting) + + // Store the current visibility status in a ref (no re-render triggered) wantMoreRef.current = visible + + // If sentinel is visible and there are more pages available, request the next page if (visible && hasMore) sendScroll() }) + // Start observing the sentinel element for intersection events io.observe(el) + + // Cleanup: disconnect the observer when component unmounts or dependencies change return () => io.disconnect() + + // Dependencies: re-run this effect if hasMore or sendScroll changes }, [hasMore, sendScroll]) - // If user scrolls manually and hits bottom, attempt to fetch + // Fallback: if user scrolls near bottom manually, also try to fetch const onScroll = useCallback(() => { if (!listRef.current) return const nearBottom = listRef.current.scrollTop + listRef.current.clientHeight >= listRef.current.scrollHeight - 24 @@ -437,29 +205,30 @@ export const Events: React.FC = ({ wsUrl, pageSize = 50, height, title = const total = state.order.length return ( -
-
-
{title}
-
+ + + {title} + {connStatus === 'connecting' && 'Connecting…'} {connStatus === 'open' && 'Live'} {connStatus === 'closed' && 'Reconnecting…'} {typeof total === 'number' ? ` · ${total} items` : ''} -
-
+ + -
+ {/* Scrollable list of event rows */} + {state.order.map(k => ( ))} {/* Infinite scroll sentinel */} -
-
+ +
-
+ {hasMore ? Scroll to load older events… : No more events.} {lastError && · {lastError}} -
-
+ + ) } diff --git a/src/components/organisms/Events/molecules/EventRow/EventRow.tsx b/src/components/organisms/Events/molecules/EventRow/EventRow.tsx new file mode 100644 index 0000000..f0d36db --- /dev/null +++ b/src/components/organisms/Events/molecules/EventRow/EventRow.tsx @@ -0,0 +1,38 @@ +import React, { FC, useMemo } from 'react' +import { Flex } from 'antd' +import { TEventsV1Event } from '../../types' +import { getInitials, eventKindName, eventText, timeAgo } from './utils' +import { Styled } from './styled' + +type TEventRowProps = { + e: TEventsV1Event +} + +export const EventRow: FC = ({ e }) => { + const initials = useMemo(() => getInitials(eventKindName(e)), [e]) + const tone = (e.type || '').toLowerCase() === 'warning' ? 'warning' : 'normal' + + return ( + + {initials} +
+ + {e.reason || e.action || 'Event'} + {e.type || 'Normal'} + {e.regarding?.kind && ( + + {e.regarding.kind} + {e.regarding.name ? ` · ${e.regarding.name}` : ''} + + )} + + {eventText(e) && {eventText(e)}} + + {e.metadata?.namespace ? `${e.metadata.namespace} · ` : ''} + {e.metadata?.name || ''} + {e.metadata?.creationTimestamp ? ` · ${timeAgo(e.metadata.creationTimestamp)}` : ''} + +
+
+ ) +} diff --git a/src/components/organisms/Events/molecules/EventRow/index.ts b/src/components/organisms/Events/molecules/EventRow/index.ts new file mode 100644 index 0000000..1f3ecc6 --- /dev/null +++ b/src/components/organisms/Events/molecules/EventRow/index.ts @@ -0,0 +1 @@ +export { EventRow } from './EventRow' diff --git a/src/components/organisms/Events/molecules/EventRow/styled.ts b/src/components/organisms/Events/molecules/EventRow/styled.ts new file mode 100644 index 0000000..ddf48d7 --- /dev/null +++ b/src/components/organisms/Events/molecules/EventRow/styled.ts @@ -0,0 +1,76 @@ +import styled, { css } from 'styled-components' + +const Card = styled.div` + display: grid; + grid-template-columns: 40px 1fr; + gap: 12px; + padding: 10px; + margin: 6px 4px; + border: 1px solid #eef1f4; + border-radius: 10px; + background: #ffffff; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); +` + +const Avatar = styled.div` + width: 36px; + height: 36px; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + background: #eef2ff; + color: #3730a3; + user-select: none; + letter-spacing: 0.3px; +` + +const Primary = styled.div` + font-size: 14px; + font-weight: 600; + color: #0f172a; +` + +const Secondary = styled.div` + font-size: 12px; + color: #334155; +` + +const Meta = styled.div` + font-size: 11px; + color: #64748b; +` + +type TBadgeProps = { + $tone?: 'warning' | 'normal' +} + +const Badge = styled.span` + font-size: 10px; + padding: 2px 6px; + border-radius: 6px; + font-weight: 600; + ${({ $tone = 'normal' }) => + $tone === 'warning' + ? css` + background: #fef3c7; + color: #92400e; + border: 1px solid #fde68a; + ` + : css` + background: #e5f4ff; + color: #0b5394; + border: 1px solid #cfe8ff; + `} +` + +export const Styled = { + Card, + Avatar, + Primary, + Secondary, + Meta, + Badge, +} diff --git a/src/components/organisms/Events/molecules/EventRow/utils.ts b/src/components/organisms/Events/molecules/EventRow/utils.ts new file mode 100644 index 0000000..0d0f12c --- /dev/null +++ b/src/components/organisms/Events/molecules/EventRow/utils.ts @@ -0,0 +1,42 @@ +import { TEventsV1Event } from '../../types' + +// Derive two-letter initials from a string (e.g., "Deployment/my-app" -> "DM") +export const getInitials = (s?: string) => { + if (!s) return '?' + const parts = s + .replace(/[^A-Za-z0-9 ]/g, ' ') + .split(' ') + .filter(Boolean) + const first = parts[0]?.[0] ?? '' + const last = parts.length > 1 ? parts[parts.length - 1][0] : '' + return (first + last).toUpperCase() || s.slice(0, 2).toUpperCase() +} + +// Prefer modern `note`, fallback to legacy `message` +export const eventText = (e: TEventsV1Event) => e.note || e.message || '' + +// Prefer `regarding.kind`, fallback to metadata/name; used for avatar initials +export const eventKindName = (e: TEventsV1Event) => + e.regarding?.kind || e.metadata?.name || e.regarding?.name || 'event' + +// Friendly relative time formatter; returns locale string for >24h +export const timeAgo = (iso?: string) => { + if (!iso) { + return '' + } + const dt = new Date(iso).getTime() + + const diff = Date.now() - dt + + if (diff < 60_000) { + return `${Math.max(0, Math.floor(diff / 1000))}s ago` + } + if (diff < 3_600_000) { + return `${Math.floor(diff / 60_000)}m ago` + } + if (diff < 86_400_000) { + return `${Math.floor(diff / 3_600_000)}h ago` + } + + return new Date(iso).toLocaleString() +} diff --git a/src/components/organisms/Events/molecules/index.ts b/src/components/organisms/Events/molecules/index.ts new file mode 100644 index 0000000..1f3ecc6 --- /dev/null +++ b/src/components/organisms/Events/molecules/index.ts @@ -0,0 +1 @@ +export { EventRow } from './EventRow' diff --git a/src/components/organisms/Events/reducer.ts b/src/components/organisms/Events/reducer.ts new file mode 100644 index 0000000..e30c701 --- /dev/null +++ b/src/components/organisms/Events/reducer.ts @@ -0,0 +1,60 @@ +// ------------------------------------------------------------ +// Reducer to maintain a keyed list of events, supporting ADDED/MODIFIED/DELETED +// ------------------------------------------------------------ +// We keep an `order` array for display order (newest first) and a `byKey` map +// for O(1) updates/reads. Pages append to the END (older items), while live +// UPSERTs (ADDED/MODIFIED) unshift to the START if new. +import { TEventsV1Event } from './types' +import { eventKey } from './utils' + +type TState = { + order: string[] // list of keys (newest first) + byKey: Record +} + +type TAction = + | { type: 'RESET'; items: TEventsV1Event[] } + | { type: 'APPEND_PAGE'; items: TEventsV1Event[] } // for older pages (append to end) + | { type: 'UPSERT'; item: TEventsV1Event } // ADDED/MODIFIED + | { type: 'REMOVE'; key: string } // DELETED + +export const reducer = (state: TState, action: TAction): TState => { + switch (action.type) { + case 'RESET': { + // Replace everything with the initial payload (usually newest N) + const order = action.items.map(eventKey) + const byKey: TState['byKey'] = {} + // eslint-disable-next-line no-return-assign + action.items.forEach(it => (byKey[eventKey(it)] = it)) + return { order, byKey } + } + case 'APPEND_PAGE': { + // Append only truly new keys to the end; update any items that already exist + const next = { ...state.byKey } + const addKeys: string[] = [] + action.items.forEach(it => { + const k = eventKey(it) + if (!next[k]) addKeys.push(k) + next[k] = it + }) + return { order: [...state.order, ...addKeys], byKey: next } + } + case 'UPSERT': { + // Insert new items at the front; replace existing in-place + const k = eventKey(action.item) + const exists = Boolean(state.byKey[k]) + const byKey = { ...state.byKey, [k]: action.item } + const order = exists ? state.order : [k, ...state.order] + return { order, byKey } + } + case 'REMOVE': { + // Remove from map and order if present + if (!state.byKey[action.key]) return state + const byKey = { ...state.byKey } + delete byKey[action.key] + return { order: state.order.filter(k => k !== action.key), byKey } + } + default: + return state + } +} diff --git a/src/components/organisms/Events/styled.ts b/src/components/organisms/Events/styled.ts new file mode 100644 index 0000000..410c929 --- /dev/null +++ b/src/components/organisms/Events/styled.ts @@ -0,0 +1,65 @@ +import styled from 'styled-components' + +type TRootProps = { + $maxHeight: number +} + +const Root = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + max-height: ${({ $maxHeight }) => $maxHeight}px; + border: 1px solid #e5e7eb; + border-radius: 12px; + overflow: hidden; +` + +const Header = styled.header` + padding: 12px 16px; + border-bottom: 1px solid #f0f2f5; + display: flex; + align-items: center; + justify-content: space-between; +` + +const Title = styled.h2` + font-size: 14px; + font-weight: 600; + margin: 0; +` + +const Status = styled.span` + font-size: 12px; + color: #6b7280; +` + +const List = styled.div` + flex: 1; + overflow-y: auto; + padding: 8px; +` + +const Sentinel = styled.div` + height: 1px; +` + +const Footer = styled.footer` + border-top: 1px solid #f0f2f5; + padding: 8px 12px; + font-size: 12px; + color: #6b7280; + display: flex; + align-items: center; + gap: 8px; +` + +export const Styled = { + Root, + Header, + Title, + Status, + List, + Sentinel, + Footer, +} diff --git a/src/components/organisms/Events/types.ts b/src/components/organisms/Events/types.ts new file mode 100644 index 0000000..e751516 --- /dev/null +++ b/src/components/organisms/Events/types.ts @@ -0,0 +1,78 @@ +// ========================= Types ============================ +// Messages are intentionally permissive (no k8s deps). Adjust to your API as needed. + +type TWatchPhase = 'ADDED' | 'MODIFIED' | 'DELETED' | 'BOOKMARK' + +// Shape of an events.k8s.io/v1 Event (subset) +// Note: Both modern `note` and legacy `message` are supported for text. +// Only the fields we render / key on are listed here. + +export type TEventsV1Event = { + metadata?: { + name?: string + namespace?: string + resourceVersion?: string + creationTimestamp?: string + } + type?: string // Normal | Warning + reason?: string + note?: string // message text in events.k8s.io/v1 + message?: string // legacy fallback + reportingController?: string + reportingInstance?: string + deprecatedCount?: number + action?: string + eventTime?: string + regarding?: { + kind?: string + name?: string + namespace?: string + } + deprecatedSource?: { + component?: string + host?: string + } +} + +// ====================== Server Frames ======================= +// Incoming frames from the server. Your backend should emit one of these. +// INITIAL: first page (newest events) + a `continue` token +// PAGE: older page fetched via SCROLL +// PAGE_ERROR: pagination failed (keep live stream running) +// ADDED/MODIFIED/DELETED: watch-style deltas for live updates + +type TInitialFrame = { + type: 'INITIAL' + items: TEventsV1Event[] + continue?: string + remainingItemCount?: number + resourceVersion?: string +} + +type TPageFrame = { + type: 'PAGE' + items: TEventsV1Event[] + continue?: string + remainingItemCount?: number +} + +type TPageErrorFrame = { + type: 'PAGE_ERROR' + error: string +} + +type TDeltaFrame = { + type: TWatchPhase // ADDED | MODIFIED | DELETED + item: TEventsV1Event +} + +export type TServerFrame = TInitialFrame | TPageFrame | TPageErrorFrame | TDeltaFrame + +// Outgoing scroll request to server +// Sent when the bottom sentinel intersects view and `continue` exists. + +export type TScrollMsg = { + type: 'SCROLL' + continue: string + limit?: number +} diff --git a/src/components/organisms/Events/utils.ts b/src/components/organisms/Events/utils.ts new file mode 100644 index 0000000..33ada73 --- /dev/null +++ b/src/components/organisms/Events/utils.ts @@ -0,0 +1,8 @@ +import { TEventsV1Event } from './types' + +// Unique key per event for stable list rendering and updates +export const eventKey = (e: TEventsV1Event) => { + const n = e.metadata?.name ?? '' + const ns = e.metadata?.namespace ?? '' + return `${ns}/${n}` +} From 864d0e7a6e972c28281658851f1eb12c0e6abd19 Mon Sep 17 00:00:00 2001 From: typescreep Date: Thu, 30 Oct 2025 00:02:36 +0300 Subject: [PATCH 12/33] events wip --- package-lock.json | 8 +- package.json | 2 +- src/components/organisms/Events/Events.tsx | 12 +- .../Events/molecules/EventRow/EventRow.tsx | 64 +++++---- .../Events/molecules/EventRow/styled.ts | 125 ++++++++---------- .../Events/molecules/EventRow/utils.ts | 16 --- src/components/organisms/Events/styled.ts | 49 ++++--- 7 files changed, 138 insertions(+), 138 deletions(-) diff --git a/package-lock.json b/package-lock.json index 79f7080..dd1b003 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.148", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.149", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.148", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.148.tgz", - "integrity": "sha512-8AwZdoS4sp05dobcGIFs8YWd0gsbZLyWC7dJqFSG990B9z432Rfsay/yRfTKSJJgJbznUAZrj5Bir73zAsaW/g==", + "version": "0.0.1-alpha.149", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.149.tgz", + "integrity": "sha512-qRgbcBXSaxgayoRH9vziaoglno7jSWjkuOqBQ/CsHvuEDBZ7Tnbe1lzrfWHOuRngfFt1ulCHYfoiLmi9Ou5OUQ==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index 618ade7..19f5dc2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.148", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.149", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", diff --git a/src/components/organisms/Events/Events.tsx b/src/components/organisms/Events/Events.tsx index a8199fd..cbcb9ce 100644 --- a/src/components/organisms/Events/Events.tsx +++ b/src/components/organisms/Events/Events.tsx @@ -9,6 +9,7 @@ // ------------------------------------------------------------ import React, { FC, useCallback, useEffect, useReducer, useRef, useState } from 'react' +import { theme as antdtheme } from 'antd' import { TScrollMsg, TServerFrame } from './types' import { eventKey } from './utils' import { reducer } from './reducer' @@ -22,7 +23,8 @@ type TEventsProps = { title?: string } -export const Events: FC = ({ wsUrl, pageSize = 50, height, title = 'Cluster Events' }) => { +export const Events: FC = ({ wsUrl, pageSize = 50, height }) => { + const { token } = antdtheme.useToken() // Reducer-backed store of events const [state, dispatch] = useReducer(reducer, { order: [], byKey: {} }) @@ -207,13 +209,14 @@ export const Events: FC = ({ wsUrl, pageSize = 50, height, title = return ( - {title} {connStatus === 'connecting' && 'Connecting…'} {connStatus === 'open' && 'Live'} {connStatus === 'closed' && 'Reconnecting…'} {typeof total === 'number' ? ` · ${total} items` : ''} + {hasMore ? Scroll to load older events… : No more events.} + {lastError && · {lastError}} {/* Scrollable list of event rows */} @@ -225,10 +228,7 @@ export const Events: FC = ({ wsUrl, pageSize = 50, height, title = - - {hasMore ? Scroll to load older events… : No more events.} - {lastError && · {lastError}} - + ) } diff --git a/src/components/organisms/Events/molecules/EventRow/EventRow.tsx b/src/components/organisms/Events/molecules/EventRow/EventRow.tsx index f0d36db..e466132 100644 --- a/src/components/organisms/Events/molecules/EventRow/EventRow.tsx +++ b/src/components/organisms/Events/molecules/EventRow/EventRow.tsx @@ -1,7 +1,10 @@ -import React, { FC, useMemo } from 'react' -import { Flex } from 'antd' +import React, { FC } from 'react' +import { theme as antdtheme, Flex } from 'antd' +import { EarthIcon, getUppercase, hslFromString, Spacer } from '@prorobotech/openapi-k8s-toolkit' +import { useSelector } from 'react-redux' +import { RootState } from 'store/store' import { TEventsV1Event } from '../../types' -import { getInitials, eventKindName, eventText, timeAgo } from './utils' +import { eventText, timeAgo } from './utils' import { Styled } from './styled' type TEventRowProps = { @@ -9,30 +12,45 @@ type TEventRowProps = { } export const EventRow: FC = ({ e }) => { - const initials = useMemo(() => getInitials(eventKindName(e)), [e]) - const tone = (e.type || '').toLowerCase() === 'warning' ? 'warning' : 'normal' + const { token } = antdtheme.useToken() + const theme = useSelector((state: RootState) => state.openapiTheme.theme) + + const abbr = e.regarding?.kind ? getUppercase(e.regarding.kind) : undefined + const bgColor = e.regarding?.kind && abbr ? hslFromString(abbr, theme) : 'initial' + const bgColorNamespace = hslFromString('NS', theme) return ( - - {initials} -
- - {e.reason || e.action || 'Event'} - {e.type || 'Normal'} - {e.regarding?.kind && ( - - {e.regarding.kind} - {e.regarding.name ? ` · ${e.regarding.name}` : ''} - + + + + + {abbr} + {e.regarding?.name} + + {e.metadata?.namespace && ( + + NS + {e.metadata?.namespace} + )} - {eventText(e) && {eventText(e)}} - - {e.metadata?.namespace ? `${e.metadata.namespace} · ` : ''} - {e.metadata?.name || ''} - {e.metadata?.creationTimestamp ? ` · ${timeAgo(e.metadata.creationTimestamp)}` : ''} - -
+ {e.metadata?.creationTimestamp && ( + +
+ +
+ {timeAgo(e.metadata?.creationTimestamp)} +
+ )} + + + + {e.reason || e.action || 'Event'} + + {e.type || 'Normal'} + + + {eventText(e) &&
{eventText(e)}
}
) } diff --git a/src/components/organisms/Events/molecules/EventRow/styled.ts b/src/components/organisms/Events/molecules/EventRow/styled.ts index ddf48d7..4eac075 100644 --- a/src/components/organisms/Events/molecules/EventRow/styled.ts +++ b/src/components/organisms/Events/molecules/EventRow/styled.ts @@ -1,76 +1,67 @@ -import styled, { css } from 'styled-components' +import styled from 'styled-components' -const Card = styled.div` - display: grid; - grid-template-columns: 40px 1fr; - gap: 12px; - padding: 10px; - margin: 6px 4px; - border: 1px solid #eef1f4; - border-radius: 10px; - background: #ffffff; - box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); -` - -const Avatar = styled.div` - width: 36px; - height: 36px; - border-radius: 999px; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: 700; - background: #eef2ff; - color: #3730a3; - user-select: none; - letter-spacing: 0.3px; -` - -const Primary = styled.div` - font-size: 14px; - font-weight: 600; - color: #0f172a; -` - -const Secondary = styled.div` - font-size: 12px; - color: #334155; -` - -const Meta = styled.div` - font-size: 11px; - color: #64748b; -` - -type TBadgeProps = { - $tone?: 'warning' | 'normal' +type TCardProps = { + $colorText: string } -const Badge = styled.span` - font-size: 10px; - padding: 2px 6px; +const Card = styled.div` border-radius: 6px; - font-weight: 600; - ${({ $tone = 'normal' }) => - $tone === 'warning' - ? css` - background: #fef3c7; - color: #92400e; - border: 1px solid #fde68a; - ` - : css` - background: #e5f4ff; - color: #0b5394; - border: 1px solid #cfe8ff; - `} + padding: 16px 8px; + border: 1px solid ${({ $colorText }) => $colorText}; + gap: 12px; + margin-bottom: 16px; + position: relative; + + &:before { + position: absolute; + content: ''; + width: 36px; + height: 1px; + background: ${({ $colorText }) => $colorText}; + left: -37px; + top: 50%; /* halfway down parent */ + transform: translateY(-50%); /* center vertically */ + } + + &:after { + position: absolute; + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: ${({ $colorText }) => $colorText}; + left: -39px; + top: 50%; + transform: translateY(-50%); + } +` + +type TAbbrProps = { + $bgColor: string +} + +const Abbr = styled.span` + background-color: ${({ $bgColor }) => $bgColor}; + border-radius: 13px; + padding: 1px 5px; + font-size: 13px; + height: min-content; + margin-right: 4px; +` + +const TimeStamp = styled.div` + font-weight: 400; + font-size: 12px; + line-height: 20px; +` + +const Title = styled.div` + font-weight: 700; ` export const Styled = { Card, - Avatar, - Primary, - Secondary, - Meta, - Badge, + Abbr, + TimeStamp, + Title, } diff --git a/src/components/organisms/Events/molecules/EventRow/utils.ts b/src/components/organisms/Events/molecules/EventRow/utils.ts index 0d0f12c..11d1b42 100644 --- a/src/components/organisms/Events/molecules/EventRow/utils.ts +++ b/src/components/organisms/Events/molecules/EventRow/utils.ts @@ -1,24 +1,8 @@ import { TEventsV1Event } from '../../types' -// Derive two-letter initials from a string (e.g., "Deployment/my-app" -> "DM") -export const getInitials = (s?: string) => { - if (!s) return '?' - const parts = s - .replace(/[^A-Za-z0-9 ]/g, ' ') - .split(' ') - .filter(Boolean) - const first = parts[0]?.[0] ?? '' - const last = parts.length > 1 ? parts[parts.length - 1][0] : '' - return (first + last).toUpperCase() || s.slice(0, 2).toUpperCase() -} - // Prefer modern `note`, fallback to legacy `message` export const eventText = (e: TEventsV1Event) => e.note || e.message || '' -// Prefer `regarding.kind`, fallback to metadata/name; used for avatar initials -export const eventKindName = (e: TEventsV1Event) => - e.regarding?.kind || e.metadata?.name || e.regarding?.name || 'event' - // Friendly relative time formatter; returns locale string for >24h export const timeAgo = (iso?: string) => { if (!iso) { diff --git a/src/components/organisms/Events/styled.ts b/src/components/organisms/Events/styled.ts index 410c929..f6dae34 100644 --- a/src/components/organisms/Events/styled.ts +++ b/src/components/organisms/Events/styled.ts @@ -10,25 +10,18 @@ const Root = styled.div` width: 100%; height: 100%; max-height: ${({ $maxHeight }) => $maxHeight}px; - border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; + position: relative; ` const Header = styled.header` padding: 12px 16px; - border-bottom: 1px solid #f0f2f5; display: flex; align-items: center; justify-content: space-between; ` -const Title = styled.h2` - font-size: 14px; - font-weight: 600; - margin: 0; -` - const Status = styled.span` font-size: 12px; color: #6b7280; @@ -37,29 +30,43 @@ const Status = styled.span` const List = styled.div` flex: 1; overflow-y: auto; - padding: 8px; + padding: 8px 8px 8px 72px; + z-index: 2; +` + +type TTimelineProps = { + $colorText: string + $maxHeight: number +} + +const Timeline = styled.div` + width: 100%; + height: ${({ $maxHeight }) => $maxHeight}px; + position: absolute; + top: 40px; + left: 36px; + z-index: 1; + + &:before { + content: ''; + position: absolute; + top: -2px; + width: 1px; + background: ${({ $colorText }) => $colorText}; + pointer-events: none; + height: 100%; + } ` const Sentinel = styled.div` height: 1px; ` -const Footer = styled.footer` - border-top: 1px solid #f0f2f5; - padding: 8px 12px; - font-size: 12px; - color: #6b7280; - display: flex; - align-items: center; - gap: 8px; -` - export const Styled = { Root, Header, - Title, Status, + Timeline, List, Sentinel, - Footer, } From 79ea642953171ea06fdd840560523114c15c6dd9 Mon Sep 17 00:00:00 2001 From: typescreep Date: Thu, 30 Oct 2025 22:25:21 +0300 Subject: [PATCH 13/33] events headers | event handlers controls --- package-lock.json | 8 +- package.json | 2 +- src/components/organisms/Events/Events.tsx | 86 +++++++++++++++++----- src/components/organisms/Events/styled.ts | 42 +++++++++-- 4 files changed, 109 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index dd1b003..e784628 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.149", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.150", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.149", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.149.tgz", - "integrity": "sha512-qRgbcBXSaxgayoRH9vziaoglno7jSWjkuOqBQ/CsHvuEDBZ7Tnbe1lzrfWHOuRngfFt1ulCHYfoiLmi9Ou5OUQ==", + "version": "0.0.1-alpha.150", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.150.tgz", + "integrity": "sha512-WkrZDN4XNHA5p/Vtcj4vE+yNHu1vyG6ezX2QCrE77TxmCTRfTSJM69Pvh6AllqWwoL7kDeUKG66Zbh+TiDm+vg==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index 19f5dc2..c79b935 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.149", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.150", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", diff --git a/src/components/organisms/Events/Events.tsx b/src/components/organisms/Events/Events.tsx index cbcb9ce..350fba8 100644 --- a/src/components/organisms/Events/Events.tsx +++ b/src/components/organisms/Events/Events.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines-per-function */ // ------------------------------------------------------------ // Simple, self-contained React component implementing: // - WebSocket connection to your events endpoint @@ -9,7 +10,8 @@ // ------------------------------------------------------------ import React, { FC, useCallback, useEffect, useReducer, useRef, useState } from 'react' -import { theme as antdtheme } from 'antd' +import { theme as antdtheme, Flex, Tooltip } from 'antd' +import { ResumeCircleIcon, PauseCircleIcon, LockedIcon, UnlockedIcon } from '@prorobotech/openapi-k8s-toolkit' import { TScrollMsg, TServerFrame } from './types' import { eventKey } from './utils' import { reducer } from './reducer' @@ -25,6 +27,23 @@ type TEventsProps = { export const Events: FC = ({ wsUrl, pageSize = 50, height }) => { const { token } = antdtheme.useToken() + + // pause behaviour + const [isPaused, setIsPaused] = useState(false) + const pausedRef = useRef(isPaused) + + useEffect(() => { + pausedRef.current = isPaused + }, [isPaused]) + + // ignore REMOVE signal + const [isRemoveIgnored, setIsRemoveIgnored] = useState(true) + const removeIgnoredRef = useRef(isRemoveIgnored) + + useEffect(() => { + removeIgnoredRef.current = isRemoveIgnored + }, [isRemoveIgnored]) + // Reducer-backed store of events const [state, dispatch] = useReducer(reducer, { order: [], byKey: {} }) @@ -107,15 +126,17 @@ export const Events: FC = ({ wsUrl, pageSize = 50, height }) => { return } - if (frame.type === 'ADDED' || frame.type === 'MODIFIED') { - // Live update: insert or replace - dispatch({ type: 'UPSERT', item: frame.item }) - return - } + if (!pausedRef.current) { + if (frame.type === 'ADDED' || frame.type === 'MODIFIED') { + // Live update: insert or replace + dispatch({ type: 'UPSERT', item: frame.item }) + return + } - if (frame.type === 'DELETED') { - // Live delete - dispatch({ type: 'REMOVE', key: eventKey(frame.item) }) + if (!removeIgnoredRef.current && frame.type === 'DELETED') { + // Live delete + dispatch({ type: 'REMOVE', key: eventKey(frame.item) }) + } } }, []) @@ -209,14 +230,45 @@ export const Events: FC = ({ wsUrl, pageSize = 50, height }) => { return ( - - {connStatus === 'connecting' && 'Connecting…'} - {connStatus === 'open' && 'Live'} - {connStatus === 'closed' && 'Reconnecting…'} - {typeof total === 'number' ? ` · ${total} items` : ''} - - {hasMore ? Scroll to load older events… : No more events.} - {lastError && · {lastError}} + + + { + if (isPaused) { + setIsPaused(false) + } else { + setIsPaused(true) + } + }} + > + {isPaused ? : } + + + {isPaused && 'Streaming paused'} + {!isPaused && connStatus === 'connecting' && 'Connecting…'} + {!isPaused && connStatus === 'open' && 'Streaming events...'} + {!isPaused && connStatus === 'closed' && 'Reconnecting…'} + + + + + {!hasMore &&
No more events ·
} + {typeof total === 'number' ?
Loaded {total} events
: ''} + {lastError && · {lastError}} + +
{isRemoveIgnored ? 'Handle REMOVE signals' : 'Ignore REMOVE signals'}
+ Locked means ignore +
+ } + placement="left" + > + setIsRemoveIgnored(!isRemoveIgnored)}> + {isRemoveIgnored ? : } + + + {/* Scrollable list of event rows */} diff --git a/src/components/organisms/Events/styled.ts b/src/components/organisms/Events/styled.ts index f6dae34..1ac1e29 100644 --- a/src/components/organisms/Events/styled.ts +++ b/src/components/organisms/Events/styled.ts @@ -15,16 +15,41 @@ const Root = styled.div` position: relative; ` -const Header = styled.header` - padding: 12px 16px; +const Header = styled.div` display: flex; align-items: center; - justify-content: space-between; + gap: 16px; + align-self: stretch; + margin-bottom: 16px; + padding-left: 19px; ` -const Status = styled.span` - font-size: 12px; - color: #6b7280; +const HeaderLeftSide = styled.div` + display: flex; + align-items: center; + gap: 10px; + flex: 1 0 0; +` + +const CursorPointerDiv = styled.div` + cursor: pointer; + user-select: none; +` + +const StatusText = styled.div` + font-size: 16px; + line-height: 24px; /* 150% */ +` + +type THeaderRightSideProps = { + $colorTextDescription: string +} + +const HeaderRightSide = styled.div` + display: flex; + gap: 4px; + text-align: right; + color: ${({ $colorTextDescription }) => $colorTextDescription}; ` const List = styled.div` @@ -65,7 +90,10 @@ const Sentinel = styled.div` export const Styled = { Root, Header, - Status, + HeaderLeftSide, + CursorPointerDiv, + StatusText, + HeaderRightSide, Timeline, List, Sentinel, From 8a3477241f742e8b334e424cb3ee8977aaa1805c Mon Sep 17 00:00:00 2001 From: typescreep Date: Fri, 31 Oct 2025 01:56:07 +0300 Subject: [PATCH 14/33] safe websocket --- package-lock.json | 2 +- src/components/organisms/Events/Events.tsx | 208 +++++++++++++++------ src/components/organisms/Events/utils.ts | 20 ++ 3 files changed, 169 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index e784628..93b509e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.150", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.150", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", diff --git a/src/components/organisms/Events/Events.tsx b/src/components/organisms/Events/Events.tsx index 350fba8..abb722c 100644 --- a/src/components/organisms/Events/Events.tsx +++ b/src/components/organisms/Events/Events.tsx @@ -13,7 +13,7 @@ import React, { FC, useCallback, useEffect, useReducer, useRef, useState } from import { theme as antdtheme, Flex, Tooltip } from 'antd' import { ResumeCircleIcon, PauseCircleIcon, LockedIcon, UnlockedIcon } from '@prorobotech/openapi-k8s-toolkit' import { TScrollMsg, TServerFrame } from './types' -import { eventKey } from './utils' +import { eventKey, compareRV, getRV, getMaxRV } from './utils' import { reducer } from './reducer' import { EventRow } from './molecules' import { Styled } from './styled' @@ -44,6 +44,9 @@ export const Events: FC = ({ wsUrl, pageSize = 50, height }) => { removeIgnoredRef.current = isRemoveIgnored }, [isRemoveIgnored]) + // track latest resourceVersion we have processed + const latestRVRef = useRef(undefined) + // Reducer-backed store of events const [state, dispatch] = useReducer(reducer, { order: [], byKey: {} }) @@ -64,6 +67,14 @@ export const Events: FC = ({ wsUrl, pageSize = 50, height }) => { const backoffRef = useRef(750) // ms; increases on failures up to a cap const urlRef = useRef(wsUrl) // latest wsUrl (stable inside callbacks) + // Guards for unmount & reconnect timer + const mountedRef = useRef(true) + const reconnectTimerRef = useRef(null) + const onMessageRef = useRef<(ev: MessageEvent) => void>(() => {}) + const startedRef = useRef(false) + const connectingRef = useRef(false) + const haveAnchorRef = useRef(false) + // Keep urlRef in sync so connect() uses the latest wsUrl useEffect(() => { urlRef.current = wsUrl @@ -90,88 +101,148 @@ export const Events: FC = ({ wsUrl, pageSize = 50, height }) => { wsRef.current.send(JSON.stringify(msg)) }, [contToken, pageSize]) + const maybeAutoScroll = useCallback(() => { + if (wantMoreRef.current && hasMore) sendScroll() + }, [hasMore, sendScroll]) + // Handle all incoming frames from the server - const onMessage = useCallback((ev: MessageEvent) => { - let frame: TServerFrame | undefined - try { - frame = JSON.parse(String(ev.data)) - } catch { - // Ignore malformed frames; you could surface these in UI if desired - return - } - if (!frame) return + useEffect(() => { + onMessageRef.current = (ev: MessageEvent) => { + let frame: TServerFrame | undefined + try { + frame = JSON.parse(String(ev.data)) as TServerFrame + } catch { + return + } + if (!frame) return - if (frame.type === 'INITIAL') { - // Replace current list with newest set; set pagination token - dispatch({ type: 'RESET', items: frame.items }) - setContToken(frame.continue) - setHasMore(Boolean(frame.continue)) - setLastError(undefined) - return - } + if (frame.type === 'INITIAL') { + dispatch({ type: 'RESET', items: frame.items }) + setContToken(frame.continue) + setHasMore(Boolean(frame.continue)) + setLastError(undefined) + fetchingRef.current = false - if (frame.type === 'PAGE') { - // Append older items to the end; clear fetching guard - dispatch({ type: 'APPEND_PAGE', items: frame.items }) - setContToken(frame.continue) - setHasMore(Boolean(frame.continue)) - fetchingRef.current = false - return - } - - if (frame.type === 'PAGE_ERROR') { - // Keep live stream but surface pagination error - setLastError(frame.error || 'Failed to load next page') - fetchingRef.current = false - return - } - - if (!pausedRef.current) { - if (frame.type === 'ADDED' || frame.type === 'MODIFIED') { - // Live update: insert or replace - dispatch({ type: 'UPSERT', item: frame.item }) + const snapshotRV = frame.resourceVersion || getMaxRV(frame.items) + if (snapshotRV) { + latestRVRef.current = snapshotRV + haveAnchorRef.current = true // NEW: we now have a safe anchor + } return } - if (!removeIgnoredRef.current && frame.type === 'DELETED') { - // Live delete - dispatch({ type: 'REMOVE', key: eventKey(frame.item) }) + if (frame.type === 'PAGE') { + dispatch({ type: 'APPEND_PAGE', items: frame.items }) + setContToken(frame.continue) + setHasMore(Boolean(frame.continue)) + fetchingRef.current = false + + const batchRV = getMaxRV(frame.items) + if (batchRV && (!latestRVRef.current || compareRV(batchRV, latestRVRef.current) > 0)) { + latestRVRef.current = batchRV + } + maybeAutoScroll() + return } + + if (frame.type === 'PAGE_ERROR') { + setLastError(frame.error || 'Failed to load next page') + fetchingRef.current = false + return + } + + if (frame.type === 'ADDED' || frame.type === 'MODIFIED' || frame.type === 'DELETED') { + const rv = getRV(frame.item) + if (rv && (!latestRVRef.current || compareRV(rv, latestRVRef.current) > 0)) { + latestRVRef.current = rv + } + } + + if (!pausedRef.current) { + if (frame.type === 'ADDED' || frame.type === 'MODIFIED') { + dispatch({ type: 'UPSERT', item: frame.item }) + return + } + + if (!removeIgnoredRef.current && frame.type === 'DELETED') { + dispatch({ type: 'REMOVE', key: eventKey(frame.item) }) + } + } + } + }, [maybeAutoScroll]) + + const buildWsUrl = useCallback((raw: string) => { + try { + const hasScheme = /^[a-z]+:/i.test(raw) + const base = window.location.origin + let u = hasScheme ? new URL(raw) : new URL(raw.startsWith('/') ? raw : `/${raw}`, base) + if (u.protocol === 'http:') u.protocol = 'ws:' + if (u.protocol === 'https:') u.protocol = 'wss:' + if (u.protocol !== 'ws:' && u.protocol !== 'wss:') { + u = new URL(u.pathname + u.search + u.hash, base) + u.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + } + if (haveAnchorRef.current && latestRVRef.current) { + u.searchParams.set('sinceRV', latestRVRef.current) + } else { + u.searchParams.delete('sinceRV') + } + return u.toString() + } catch { + const origin = window.location.origin.replace(/^http/, 'ws') + const prefix = raw.startsWith('/') ? '' : '/' + const rv = haveAnchorRef.current ? latestRVRef.current : undefined + const sep = raw.includes('?') ? '&' : '?' + return `${origin}${prefix}${raw}${rv ? `${sep}sinceRV=${encodeURIComponent(rv)}` : ''}` } }, []) // Establish and maintain the WebSocket connection with bounded backoff const connect = useCallback(() => { + if (!mountedRef.current) return + // Prevent duplicate opens + if (connectingRef.current) return + if ( + wsRef.current && + (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING) + ) { + return + } + connectingRef.current = true + setConnStatus('connecting') setLastError(undefined) - // Accept absolute ws(s) URLs; otherwise resolve relative to current origin - const buildWsUrl = (raw: string) => { - if (/^wss?:/i.test(raw)) return raw // already absolute ws(s) - const origin = window.location.origin.replace(/^http/i, 'ws') - if (raw.startsWith('/')) return `${origin}${raw}` - return `${origin}/${raw}` - } - - const ws = new WebSocket(buildWsUrl(urlRef.current)) + const url = buildWsUrl(urlRef.current) + const ws = new WebSocket(url) wsRef.current = ws ws.addEventListener('open', () => { + if (!mountedRef.current) return + backoffRef.current = 750 + fetchingRef.current = false setConnStatus('open') - backoffRef.current = 750 // reset backoff on success + connectingRef.current = false }) - ws.addEventListener('message', onMessage) + ws.addEventListener('message', ev => onMessageRef.current(ev)) const scheduleReconnect = () => { - // Only clear if we're still looking at this instance if (wsRef.current === ws) wsRef.current = null setConnStatus('closed') - const wait = Math.min(backoffRef.current, 8000) - const next = Math.min(wait * 2, 12000) + connectingRef.current = false + // Bounded exponential backoff with jitter to avoid herding + const base = Math.min(backoffRef.current, 8000) + const jitter = Math.random() * 0.4 + 0.8 // 0.8x–1.2x + const wait = Math.floor(base * jitter) + const next = Math.min(base * 2, 12000) backoffRef.current = next - // Reconnect after a short delay; preserves component mount semantics - setTimeout(() => { + if (reconnectTimerRef.current) { + window.clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + reconnectTimerRef.current = window.setTimeout(() => { + if (!mountedRef.current) return connect() }, wait) } @@ -181,13 +252,30 @@ export const Events: FC = ({ wsUrl, pageSize = 50, height }) => { setLastError('WebSocket error') scheduleReconnect() }) - }, [onMessage]) + }, [buildWsUrl]) // Kick off initial connection on mount; clean up on unmount useEffect(() => { + if (startedRef.current) return undefined // StrictMode double-invoke guard + startedRef.current = true + + mountedRef.current = true connect() - return () => closeWS() - }, [connect, closeWS]) + + return () => { + mountedRef.current = false + startedRef.current = false + if (reconnectTimerRef.current) { + window.clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + closeWS() + wsRef.current = null + connectingRef.current = false + } + // INTENTIONALLY EMPTY DEPS – do not reopen on state changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // IntersectionObserver to trigger SCROLL when sentinel becomes visible useEffect(() => { diff --git a/src/components/organisms/Events/utils.ts b/src/components/organisms/Events/utils.ts index 33ada73..59db057 100644 --- a/src/components/organisms/Events/utils.ts +++ b/src/components/organisms/Events/utils.ts @@ -6,3 +6,23 @@ export const eventKey = (e: TEventsV1Event) => { const ns = e.metadata?.namespace ?? '' return `${ns}/${n}` } + +// Compare resourceVersions safely (string-based) +export const compareRV = (a: string, b: string): number => { + if (a.length !== b.length) return a.length > b.length ? 1 : -1 + // eslint-disable-next-line no-nested-ternary + return a > b ? 1 : a < b ? -1 : 0 +} + +type WithRV = { metadata?: { resourceVersion?: string } } + +export const getRV = (item: WithRV): string | undefined => item?.metadata?.resourceVersion + +// ✅ Pure functional + no restricted syntax +export const getMaxRV = (items: ReadonlyArray): string | undefined => { + const rvs = items + .map(getRV) + .filter((v): v is string => Boolean(v)) + .sort(compareRV) + return rvs.length ? rvs[rvs.length - 1] : undefined +} From a416ee43f7c2b1944ad68e6034a562d276a12e24 Mon Sep 17 00:00:00 2001 From: typescreep Date: Fri, 31 Oct 2025 03:38:23 +0300 Subject: [PATCH 15/33] layout improvements --- .../Events/molecules/EventRow/EventRow.tsx | 28 ++++++++++++++----- .../Events/molecules/EventRow/styled.ts | 17 +++++------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/components/organisms/Events/molecules/EventRow/EventRow.tsx b/src/components/organisms/Events/molecules/EventRow/EventRow.tsx index e466132..103aff3 100644 --- a/src/components/organisms/Events/molecules/EventRow/EventRow.tsx +++ b/src/components/organisms/Events/molecules/EventRow/EventRow.tsx @@ -1,5 +1,5 @@ import React, { FC } from 'react' -import { theme as antdtheme, Flex } from 'antd' +import { theme as antdtheme, Flex, Typography } from 'antd' import { EarthIcon, getUppercase, hslFromString, Spacer } from '@prorobotech/openapi-k8s-toolkit' import { useSelector } from 'react-redux' import { RootState } from 'store/store' @@ -16,11 +16,14 @@ export const EventRow: FC = ({ e }) => { const theme = useSelector((state: RootState) => state.openapiTheme.theme) const abbr = e.regarding?.kind ? getUppercase(e.regarding.kind) : undefined - const bgColor = e.regarding?.kind && abbr ? hslFromString(abbr, theme) : 'initial' - const bgColorNamespace = hslFromString('NS', theme) + const bgColor = e.regarding?.kind && abbr ? hslFromString(e.regarding?.kind, theme) : 'initial' + const bgColorNamespace = hslFromString('Namespace', theme) return ( - + @@ -44,10 +47,21 @@ export const EventRow: FC = ({ e }) => { )} - + +
+ {e.deprecatedSource?.component && ( + + + Generated by + {e.deprecatedSource?.component} + +
+ +
+
+ )} +
{e.reason || e.action || 'Event'} - - {e.type || 'Normal'}
{eventText(e) &&
{eventText(e)}
} diff --git a/src/components/organisms/Events/molecules/EventRow/styled.ts b/src/components/organisms/Events/molecules/EventRow/styled.ts index 4eac075..2d14afe 100644 --- a/src/components/organisms/Events/molecules/EventRow/styled.ts +++ b/src/components/organisms/Events/molecules/EventRow/styled.ts @@ -1,13 +1,14 @@ import styled from 'styled-components' type TCardProps = { - $colorText: string + $mainColor: string + $bigBorder?: boolean } const Card = styled.div` border-radius: 6px; padding: 16px 8px; - border: 1px solid ${({ $colorText }) => $colorText}; + border: ${({ $bigBorder }) => ($bigBorder ? 2 : 1)}px solid ${({ $mainColor }) => $mainColor}; gap: 12px; margin-bottom: 16px; position: relative; @@ -16,8 +17,8 @@ const Card = styled.div` position: absolute; content: ''; width: 36px; - height: 1px; - background: ${({ $colorText }) => $colorText}; + height: ${({ $bigBorder }) => ($bigBorder ? 2 : 1)}px; + background: ${({ $mainColor }) => $mainColor}; left: -37px; top: 50%; /* halfway down parent */ transform: translateY(-50%); /* center vertically */ @@ -26,11 +27,11 @@ const Card = styled.div` &:after { position: absolute; content: ''; - width: 6px; - height: 6px; + width: ${({ $bigBorder }) => ($bigBorder ? 7 : 6)}px; + height: ${({ $bigBorder }) => ($bigBorder ? 7 : 6)}px; border-radius: 50%; - background: ${({ $colorText }) => $colorText}; - left: -39px; + background: ${({ $mainColor }) => $mainColor}; + left: ${({ $bigBorder }) => ($bigBorder ? -41 : -39)}px; top: 50%; transform: translateY(-50%); } From dfbc8b55ec2895ad80a1a73133e42e24ce89ecec Mon Sep 17 00:00:00 2001 From: typescreep Date: Fri, 31 Oct 2025 16:12:03 +0300 Subject: [PATCH 16/33] events: times in period; links; empty view --- .env | 6 ++ .env.options.dist | 6 ++ package-lock.json | 8 +- package.json | 2 +- server/index.ts | 26 +++++- src/components/organisms/Events/Events.tsx | 57 ++++++++++-- .../Events/molecules/EventRow/EventRow.tsx | 90 +++++++++++++++---- .../Events/molecules/EventRow/styled.ts | 5 ++ .../Events/molecules/EventRow/utils.ts | 69 ++++++++++++++ src/components/organisms/Events/types.ts | 2 + .../customizationApiGroupAndVersion.ts | 16 ++++ src/pages/EventsPage/EventsPage.tsx | 8 +- 12 files changed, 262 insertions(+), 33 deletions(-) diff --git a/.env b/.env index 1844f4b..4bed8ba 100644 --- a/.env +++ b/.env @@ -33,3 +33,9 @@ VITE_REMOVE_BACKLINK_TEXT=true VITE_DOCS_URL=https://in-cloud.io/docs/tech-docs/introduction/ VITE_SEARCH_TABLE_CUSTOMIZATION_PREFIX=stock- + +VITE_BASE_FACTORY_NAMESPACED_API_KEY=base-factory-namespaced-api +VITE_BASE_FACTORY_CLUSTERSCOPED_API_KEY=base-factory-clusterscoped-api +VITE_BASE_FACTORY_NAMESPACED_BUILTIN_KEY=base-factory-namespaced-builtin +VITE_BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY=base-factory-clusterscoped-builtin +VITE_BASE_NAMESPACE_FACTORY_KEY=base-factory-clusterscoped-builtin diff --git a/.env.options.dist b/.env.options.dist index a04925e..4efefbc 100644 --- a/.env.options.dist +++ b/.env.options.dist @@ -35,3 +35,9 @@ REMOVE_BACKLINK_TEXT= DOCS_URL= SEARCH_TABLE_CUSTOMIZATION_PREFIX= + +BASE_FACTORY_NAMESPACED_API_KEY= +BASE_FACTORY_CLUSTERSCOPED_API_KEY= +BASE_FACTORY_NAMESPACED_BUILTIN_KEY= +BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY= +BASE_NAMESPACE_FACTORY_KEY= diff --git a/package-lock.json b/package-lock.json index 93b509e..a69a72d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.150", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.151", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.150", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.150.tgz", - "integrity": "sha512-WkrZDN4XNHA5p/Vtcj4vE+yNHu1vyG6ezX2QCrE77TxmCTRfTSJM69Pvh6AllqWwoL7kDeUKG66Zbh+TiDm+vg==", + "version": "0.0.1-alpha.151", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.151.tgz", + "integrity": "sha512-AV+6muJNp75WLUPGuLi3JIH8j3P1sBgodHFNfebJ25CdfzJQeYUVkRs0yqWQoNFnw1lG92W4i7fIToYmk1WzJQ==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index c79b935..eb29204 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.150", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.151", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", diff --git a/server/index.ts b/server/index.ts index f95b1ec..d1bf62f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -73,6 +73,23 @@ const SEARCH_TABLE_CUSTOMIZATION_PREFIX = ? options?.SEARCH_TABLE_CUSTOMIZATION_PREFIX : process.env.SEARCH_TABLE_CUSTOMIZATION_PREFIX +const BASE_FACTORY_NAMESPACED_API_KEY = + process.env.LOCAL === 'true' ? options?.BASE_FACTORY_NAMESPACED_API_KEY : process.env.BASE_FACTORY_NAMESPACED_API_KEY +const BASE_FACTORY_CLUSTERSCOPED_API_KEY = + process.env.LOCAL === 'true' + ? options?.BASE_FACTORY_CLUSTERSCOPED_API_KEY + : process.env.BASE_FACTORY_CLUSTERSCOPED_API_KEY +const BASE_FACTORY_NAMESPACED_BUILTIN_KEY = + process.env.LOCAL === 'true' + ? options?.BASE_FACTORY_NAMESPACED_BUILTIN_KEY + : process.env.BASE_FACTORY_NAMESPACED_BUILTIN_KEY +const BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY = + process.env.LOCAL === 'true' + ? options?.BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY + : process.env.BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY +const BASE_NAMESPACE_FACTORY_KEY = + process.env.LOCAL === 'true' ? options?.BASE_NAMESPACE_FACTORY_KEY : process.env.BASE_NAMESPACE_FACTORY_KEY + const healthcheck = require('express-healthcheck') const promBundle = require('express-prom-bundle') @@ -199,7 +216,14 @@ app.get(`${basePrefix ? basePrefix : ''}/env.js`, (_, res) => { DOCS_URL: ${JSON.stringify(DOCS_URL) || '"/docs"'}, SEARCH_TABLE_CUSTOMIZATION_PREFIX: ${JSON.stringify(SEARCH_TABLE_CUSTOMIZATION_PREFIX) || '"search-"'}, REMOVE_BACKLINK: ${!!REMOVE_BACKLINK ? JSON.stringify(REMOVE_BACKLINK).toLowerCase() : '"false"'}, - REMOVE_BACKLINK_TEXT: ${!!REMOVE_BACKLINK_TEXT ? JSON.stringify(REMOVE_BACKLINK_TEXT).toLowerCase() : '"false"'} + REMOVE_BACKLINK_TEXT: ${!!REMOVE_BACKLINK_TEXT ? JSON.stringify(REMOVE_BACKLINK_TEXT).toLowerCase() : '"false"'}, + BASE_FACTORY_NAMESPACED_API_KEY: ${JSON.stringify(BASE_FACTORY_NAMESPACED_API_KEY) || '"check envs"'}, + BASE_FACTORY_CLUSTERSCOPED_API_KEY: ${JSON.stringify(BASE_FACTORY_CLUSTERSCOPED_API_KEY) || '"check envs"'}, + BASE_FACTORY_NAMESPACED_BUILTIN_KEY: ${JSON.stringify(BASE_FACTORY_NAMESPACED_BUILTIN_KEY) || '"check envs"'}, + BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY: ${ + JSON.stringify(BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY) || '"check envs"' + }, + BASE_NAMESPACE_FACTORY_KEY: ${JSON.stringify(BASE_NAMESPACE_FACTORY_KEY) || '"check envs"'} } `, ) diff --git a/src/components/organisms/Events/Events.tsx b/src/components/organisms/Events/Events.tsx index abb722c..497c80d 100644 --- a/src/components/organisms/Events/Events.tsx +++ b/src/components/organisms/Events/Events.tsx @@ -10,8 +10,19 @@ // ------------------------------------------------------------ import React, { FC, useCallback, useEffect, useReducer, useRef, useState } from 'react' -import { theme as antdtheme, Flex, Tooltip } from 'antd' -import { ResumeCircleIcon, PauseCircleIcon, LockedIcon, UnlockedIcon } from '@prorobotech/openapi-k8s-toolkit' +import { theme as antdtheme, Flex, Tooltip, Empty } from 'antd' +import { + // TRequestError, + TKindIndex, + TKindWithVersion, + getKinds, + getSortedKindsAll, + pluralByKind, + ResumeCircleIcon, + PauseCircleIcon, + LockedIcon, + UnlockedIcon, +} from '@prorobotech/openapi-k8s-toolkit' import { TScrollMsg, TServerFrame } from './types' import { eventKey, compareRV, getRV, getMaxRV } from './utils' import { reducer } from './reducer' @@ -19,15 +30,41 @@ import { EventRow } from './molecules' import { Styled } from './styled' type TEventsProps = { + baseprefix?: string + cluster: string wsUrl: string // e.g. ws://localhost:3000/api/events?namespace=default&limit=40 pageSize?: number // SCROLL page size (optional) height?: number // optional override title?: string } -export const Events: FC = ({ wsUrl, pageSize = 50, height }) => { +export const Events: FC = ({ baseprefix, cluster, wsUrl, pageSize = 50, height }) => { const { token } = antdtheme.useToken() + // const [error, setError] = useState() + // const [isLoading, setIsLoading] = useState(false) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [kindIndex, setKindIndex] = useState() + const [kindsWithVersion, setKindWithVersion] = useState() + + useEffect(() => { + // setIsLoading(true) + // setError(undefined) + getKinds({ clusterName: cluster }) + .then(data => { + setKindIndex(data) + setKindWithVersion(getSortedKindsAll(data)) + // setIsLoading(false) + // setError(undefined) + }) + .catch(error => { + // setIsLoading(false) + // setError(error) + // eslint-disable-next-line no-console + console.error(error) + }) + }, [cluster]) + // pause behaviour const [isPaused, setIsPaused] = useState(false) const pausedRef = useRef(isPaused) @@ -315,6 +352,8 @@ export const Events: FC = ({ wsUrl, pageSize = 50, height }) => { const total = state.order.length + const getPlural = kindsWithVersion ? pluralByKind(kindsWithVersion) : undefined + return ( @@ -361,14 +400,18 @@ export const Events: FC = ({ wsUrl, pageSize = 50, height }) => { {/* Scrollable list of event rows */} - {state.order.map(k => ( - - ))} + {state.order.length > 0 ? ( + state.order.map(k => ( + + )) + ) : ( + + )} {/* Infinite scroll sentinel */} - + {state.order.length > 0 && } ) } diff --git a/src/components/organisms/Events/molecules/EventRow/EventRow.tsx b/src/components/organisms/Events/molecules/EventRow/EventRow.tsx index 103aff3..824c191 100644 --- a/src/components/organisms/Events/molecules/EventRow/EventRow.tsx +++ b/src/components/organisms/Events/molecules/EventRow/EventRow.tsx @@ -1,24 +1,49 @@ import React, { FC } from 'react' +import { useNavigate } from 'react-router-dom' import { theme as antdtheme, Flex, Typography } from 'antd' import { EarthIcon, getUppercase, hslFromString, Spacer } from '@prorobotech/openapi-k8s-toolkit' import { useSelector } from 'react-redux' import { RootState } from 'store/store' import { TEventsV1Event } from '../../types' -import { eventText, timeAgo } from './utils' +import { eventText, timeAgo, getResourceLink, getNamespaceLink, formatEventSummary } from './utils' import { Styled } from './styled' type TEventRowProps = { e: TEventsV1Event + baseprefix?: string + cluster: string + getPlural?: (kind: string, apiVersion?: string) => string | undefined } -export const EventRow: FC = ({ e }) => { +export const EventRow: FC = ({ e, baseprefix, cluster, getPlural }) => { const { token } = antdtheme.useToken() + const navigate = useNavigate() const theme = useSelector((state: RootState) => state.openapiTheme.theme) const abbr = e.regarding?.kind ? getUppercase(e.regarding.kind) : undefined const bgColor = e.regarding?.kind && abbr ? hslFromString(e.regarding?.kind, theme) : 'initial' const bgColorNamespace = hslFromString('Namespace', theme) + const regardingKind: string | undefined = e.regarding?.kind + const regardingApiVersion: string = e.regarding?.apiVersion || 'v1' + const pluralName: string | undefined = + regardingKind && regardingApiVersion ? getPlural?.(regardingKind, regardingApiVersion) : undefined + const resourceLink: string | undefined = getResourceLink({ + baseprefix, + cluster, + namespace: e.regarding?.namespace, + apiGroupVersion: regardingApiVersion, + pluralName, + name: e.regarding?.name, + }) + const namespaceLink: string | undefined = getNamespaceLink({ + baseprefix, + cluster, + apiGroupVersion: 'v1', + pluralName: 'namespaces', + namespace: e.regarding?.namespace, + }) + return ( = ({ e }) => { {abbr} - {e.regarding?.name} + {resourceLink ? ( + { + e.preventDefault() + navigate(resourceLink) + }} + > + {e.regarding?.name} + + ) : ( + {e.regarding?.name} + )} - {e.metadata?.namespace && ( + {e.regarding?.namespace && ( NS - {e.metadata?.namespace} + {namespaceLink ? ( + { + e.preventDefault() + navigate(namespaceLink) + }} + > + {e.regarding?.namespace} + + ) : ( + {e.regarding?.namespace} + )} )} @@ -47,21 +94,26 @@ export const EventRow: FC = ({ e }) => { )}
- -
- {e.deprecatedSource?.component && ( - - - Generated by - {e.deprecatedSource?.component} + + +
+ {e.deprecatedSource?.component && ( + + + Generated by + {e.deprecatedSource?.component} + +
+ +
-
- -
- - )} -
- {e.reason || e.action || 'Event'} + )} +
+ {e.reason || e.action || 'Event'} +
+ + {formatEventSummary(e)} +
{eventText(e) &&
{eventText(e)}
} diff --git a/src/components/organisms/Events/molecules/EventRow/styled.ts b/src/components/organisms/Events/molecules/EventRow/styled.ts index 2d14afe..315b71e 100644 --- a/src/components/organisms/Events/molecules/EventRow/styled.ts +++ b/src/components/organisms/Events/molecules/EventRow/styled.ts @@ -60,9 +60,14 @@ const Title = styled.div` font-weight: 700; ` +const TimesInPeriod = styled.div` + margin-top: -16px; +` + export const Styled = { Card, Abbr, TimeStamp, Title, + TimesInPeriod, } diff --git a/src/components/organisms/Events/molecules/EventRow/utils.ts b/src/components/organisms/Events/molecules/EventRow/utils.ts index 11d1b42..739bff9 100644 --- a/src/components/organisms/Events/molecules/EventRow/utils.ts +++ b/src/components/organisms/Events/molecules/EventRow/utils.ts @@ -1,3 +1,10 @@ +import { + BASE_FACTORY_NAMESPACED_API_KEY, + BASE_FACTORY_CLUSTERSCOPED_API_KEY, + BASE_FACTORY_NAMESPACED_BUILTIN_KEY, + BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY, + BASE_NAMESPACE_FACTORY_KEY, +} from 'constants/customizationApiGroupAndVersion' import { TEventsV1Event } from '../../types' // Prefer modern `note`, fallback to legacy `message` @@ -24,3 +31,65 @@ export const timeAgo = (iso?: string) => { return new Date(iso).toLocaleString() } + +export const getResourceLink = ({ + baseprefix, + cluster, + namespace, + apiGroupVersion, + pluralName, + name, +}: { + baseprefix?: string + cluster: string + namespace?: string + apiGroupVersion: string + pluralName?: string + name?: string +}): string | undefined => { + if (!pluralName || !name) { + return undefined + } + + if (apiGroupVersion === 'v1') { + return `${baseprefix}/${cluster}${namespace ? `/${namespace}` : ''}/factory/${ + namespace ? BASE_FACTORY_NAMESPACED_BUILTIN_KEY : BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY + }/${apiGroupVersion}/${pluralName}/${name}` + } + + return `${baseprefix}/${cluster}${namespace ? `/${namespace}` : ''}/factory/${ + namespace ? BASE_FACTORY_NAMESPACED_API_KEY : BASE_FACTORY_CLUSTERSCOPED_API_KEY + }/${apiGroupVersion}/${pluralName}/${name}` +} + +export const getNamespaceLink = ({ + baseprefix, + cluster, + apiGroupVersion, + pluralName, + namespace, +}: { + baseprefix?: string + cluster: string + pluralName: string + apiGroupVersion: string + namespace?: string +}): string | undefined => { + if (!namespace) { + return undefined + } + + return `${baseprefix}/${cluster}/factory/${BASE_NAMESPACE_FACTORY_KEY}/${apiGroupVersion}/${pluralName}/${namespace}` +} + +export const formatEventSummary = (event: TEventsV1Event): string | undefined => { + if (!event.deprecatedCount || !event.deprecatedFirstTimestamp) { + return undefined + } + + const now = new Date() + const first = new Date(event.deprecatedFirstTimestamp) + const days = Math.floor((now.getTime() - first.getTime()) / (1000 * 60 * 60 * 24)) + + return `${event.deprecatedCount} times ${days === 0 ? 'today' : `in the last ${days} days`}` +} diff --git a/src/components/organisms/Events/types.ts b/src/components/organisms/Events/types.ts index e751516..15008de 100644 --- a/src/components/organisms/Events/types.ts +++ b/src/components/organisms/Events/types.ts @@ -21,9 +21,11 @@ export type TEventsV1Event = { reportingController?: string reportingInstance?: string deprecatedCount?: number + deprecatedFirstTimestamp?: Date action?: string eventTime?: string regarding?: { + apiVersion?: string kind?: string name?: string namespace?: string diff --git a/src/constants/customizationApiGroupAndVersion.ts b/src/constants/customizationApiGroupAndVersion.ts index b261e26..45d6198 100644 --- a/src/constants/customizationApiGroupAndVersion.ts +++ b/src/constants/customizationApiGroupAndVersion.ts @@ -77,3 +77,19 @@ export const BASE_REMOVE_BACKLINK_TEXT = import.meta.env.DEV ? window._env_.REMOVE_BACKLINK_TEXT === 'true' || import.meta.env.VITE_REMOVE_BACKLINK_TEXT?.toString().toLowerCase() === 'true' : window._env_.REMOVE_BACKLINK_TEXT === 'true' + +export const BASE_FACTORY_NAMESPACED_API_KEY = import.meta.env.DEV + ? window._env_.BASE_FACTORY_NAMESPACED_API_KEY || import.meta.env.VITE_BASE_FACTORY_NAMESPACED_API_KEY + : window._env_.BASE_FACTORY_NAMESPACED_API_KEY +export const BASE_FACTORY_CLUSTERSCOPED_API_KEY = import.meta.env.DEV + ? window._env_.BASE_FACTORY_CLUSTERSCOPED_API_KEY || import.meta.env.VITE_BASE_FACTORY_CLUSTERSCOPED_API_KEY + : window._env_.BASE_FACTORY_CLUSTERSCOPED_API_KEY +export const BASE_FACTORY_NAMESPACED_BUILTIN_KEY = import.meta.env.DEV + ? window._env_.BASE_FACTORY_NAMESPACED_BUILTIN_KEY || import.meta.env.VITE_BASE_FACTORY_NAMESPACED_BUILTIN_KEY + : window._env_.BASE_FACTORY_NAMESPACED_BUILTIN_KEY +export const BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY = import.meta.env.DEV + ? window._env_.BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY || import.meta.env.VITE_BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY + : window._env_.BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY +export const BASE_NAMESPACE_FACTORY_KEY = import.meta.env.DEV + ? window._env_.BASE_NAMESPACE_FACTORY_KEY || import.meta.env.VITE_BASE_NAMESPACE_FACTORY_KEY + : window._env_.BASE_NAMESPACE_FACTORY_KEY diff --git a/src/pages/EventsPage/EventsPage.tsx b/src/pages/EventsPage/EventsPage.tsx index f6584f2..2264b65 100644 --- a/src/pages/EventsPage/EventsPage.tsx +++ b/src/pages/EventsPage/EventsPage.tsx @@ -36,7 +36,13 @@ export const EventsPage: FC = () => { - + {clusterName && ( + + )} ) } From f67c6a3a9bd940f39ab18476be4c8d86c1200034 Mon Sep 17 00:00:00 2001 From: typescreep Date: Sat, 1 Nov 2025 17:44:03 +0300 Subject: [PATCH 17/33] events factory/toolkit --- package-lock.json | 8 +- package.json | 2 +- src/App.tsx | 2 - src/components/organisms/Events/Events.tsx | 417 ------------------ src/components/organisms/Events/index.ts | 1 - .../Events/molecules/EventRow/EventRow.tsx | 122 ----- .../Events/molecules/EventRow/index.ts | 1 - .../Events/molecules/EventRow/styled.ts | 73 --- .../Events/molecules/EventRow/utils.ts | 95 ---- .../organisms/Events/molecules/index.ts | 1 - src/components/organisms/Events/reducer.ts | 60 --- src/components/organisms/Events/styled.ts | 100 ----- src/components/organisms/Events/types.ts | 80 ---- src/components/organisms/Events/utils.ts | 28 -- src/components/organisms/index.ts | 1 - src/pages/EventsPage/EventsPage.tsx | 48 -- src/pages/EventsPage/index.ts | 1 - src/pages/index.ts | 2 - 18 files changed, 5 insertions(+), 1037 deletions(-) delete mode 100644 src/components/organisms/Events/Events.tsx delete mode 100644 src/components/organisms/Events/index.ts delete mode 100644 src/components/organisms/Events/molecules/EventRow/EventRow.tsx delete mode 100644 src/components/organisms/Events/molecules/EventRow/index.ts delete mode 100644 src/components/organisms/Events/molecules/EventRow/styled.ts delete mode 100644 src/components/organisms/Events/molecules/EventRow/utils.ts delete mode 100644 src/components/organisms/Events/molecules/index.ts delete mode 100644 src/components/organisms/Events/reducer.ts delete mode 100644 src/components/organisms/Events/styled.ts delete mode 100644 src/components/organisms/Events/types.ts delete mode 100644 src/components/organisms/Events/utils.ts delete mode 100644 src/pages/EventsPage/EventsPage.tsx delete mode 100644 src/pages/EventsPage/index.ts diff --git a/package-lock.json b/package-lock.json index a69a72d..ae7697c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.151", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.152", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.151", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.151.tgz", - "integrity": "sha512-AV+6muJNp75WLUPGuLi3JIH8j3P1sBgodHFNfebJ25CdfzJQeYUVkRs0yqWQoNFnw1lG92W4i7fIToYmk1WzJQ==", + "version": "0.0.1-alpha.152", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.152.tgz", + "integrity": "sha512-bfOD3cTkfqc5C+4FR1LNvB7I7JzXdOUf9BhoEb1QD9bGH4jel89ZAScvqRhB1HichogXO21NnZNGSmco2oYOfQ==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index eb29204..e599830 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.151", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.152", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", diff --git a/src/App.tsx b/src/App.tsx index 34dedbc..6711b2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,7 +27,6 @@ import { FactoryPage, FactoryAdminPage, SearchPage, - EventsPage, } from 'pages' import { getBasePrefix } from 'utils/getBaseprefix' import { colorsLight, colorsDark, sizes } from 'constants/colors' @@ -125,7 +124,6 @@ export const App: FC = ({ isFederation, forcedTheme }) => { element={} /> } /> - } /> } /> diff --git a/src/components/organisms/Events/Events.tsx b/src/components/organisms/Events/Events.tsx deleted file mode 100644 index 497c80d..0000000 --- a/src/components/organisms/Events/Events.tsx +++ /dev/null @@ -1,417 +0,0 @@ -/* eslint-disable max-lines-per-function */ -// ------------------------------------------------------------ -// Simple, self-contained React component implementing: -// - WebSocket connection to your events endpoint -// - Handling of INITIAL, PAGE, ADDED, MODIFIED, DELETED, PAGE_ERROR -// - Infinite scroll via IntersectionObserver (sends { type: "SCROLL" }) -// - Lightweight CSS-in-JS styling -// - Minimal reconnection logic (bounded exponential backoff) -// - Small initials avatar (derived from a name/kind) -// ------------------------------------------------------------ - -import React, { FC, useCallback, useEffect, useReducer, useRef, useState } from 'react' -import { theme as antdtheme, Flex, Tooltip, Empty } from 'antd' -import { - // TRequestError, - TKindIndex, - TKindWithVersion, - getKinds, - getSortedKindsAll, - pluralByKind, - ResumeCircleIcon, - PauseCircleIcon, - LockedIcon, - UnlockedIcon, -} from '@prorobotech/openapi-k8s-toolkit' -import { TScrollMsg, TServerFrame } from './types' -import { eventKey, compareRV, getRV, getMaxRV } from './utils' -import { reducer } from './reducer' -import { EventRow } from './molecules' -import { Styled } from './styled' - -type TEventsProps = { - baseprefix?: string - cluster: string - wsUrl: string // e.g. ws://localhost:3000/api/events?namespace=default&limit=40 - pageSize?: number // SCROLL page size (optional) - height?: number // optional override - title?: string -} - -export const Events: FC = ({ baseprefix, cluster, wsUrl, pageSize = 50, height }) => { - const { token } = antdtheme.useToken() - - // const [error, setError] = useState() - // const [isLoading, setIsLoading] = useState(false) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [kindIndex, setKindIndex] = useState() - const [kindsWithVersion, setKindWithVersion] = useState() - - useEffect(() => { - // setIsLoading(true) - // setError(undefined) - getKinds({ clusterName: cluster }) - .then(data => { - setKindIndex(data) - setKindWithVersion(getSortedKindsAll(data)) - // setIsLoading(false) - // setError(undefined) - }) - .catch(error => { - // setIsLoading(false) - // setError(error) - // eslint-disable-next-line no-console - console.error(error) - }) - }, [cluster]) - - // pause behaviour - const [isPaused, setIsPaused] = useState(false) - const pausedRef = useRef(isPaused) - - useEffect(() => { - pausedRef.current = isPaused - }, [isPaused]) - - // ignore REMOVE signal - const [isRemoveIgnored, setIsRemoveIgnored] = useState(true) - const removeIgnoredRef = useRef(isRemoveIgnored) - - useEffect(() => { - removeIgnoredRef.current = isRemoveIgnored - }, [isRemoveIgnored]) - - // track latest resourceVersion we have processed - const latestRVRef = useRef(undefined) - - // Reducer-backed store of events - const [state, dispatch] = useReducer(reducer, { order: [], byKey: {} }) - - // Pagination/bookmarking state returned by server - const [contToken, setContToken] = useState(undefined) - const [hasMore, setHasMore] = useState(false) - - // Connection state & errors for small status UI - const [connStatus, setConnStatus] = useState<'connecting' | 'open' | 'closed'>('connecting') - const [lastError, setLastError] = useState(undefined) - - // ------------------ Refs (mutable, do not trigger render) ------------------ - const wsRef = useRef(null) // current WebSocket instance - const listRef = useRef(null) // scrollable list element - const sentinelRef = useRef(null) // bottom sentinel for IO - const wantMoreRef = useRef(false) // whether sentinel is currently visible - const fetchingRef = useRef(false) // guard: avoid parallel PAGE requests - const backoffRef = useRef(750) // ms; increases on failures up to a cap - const urlRef = useRef(wsUrl) // latest wsUrl (stable inside callbacks) - - // Guards for unmount & reconnect timer - const mountedRef = useRef(true) - const reconnectTimerRef = useRef(null) - const onMessageRef = useRef<(ev: MessageEvent) => void>(() => {}) - const startedRef = useRef(false) - const connectingRef = useRef(false) - const haveAnchorRef = useRef(false) - - // Keep urlRef in sync so connect() uses the latest wsUrl - useEffect(() => { - urlRef.current = wsUrl - }, [wsUrl]) - - // Close current WS safely - const closeWS = useCallback(() => { - try { - wsRef.current?.close() - } catch (e) { - // eslint-disable-next-line no-console - console.error(e) - } - wsRef.current = null - }, []) - - // Attempt to request the next page of older events - const sendScroll = useCallback(() => { - const token = contToken - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return - if (!token || fetchingRef.current) return - fetchingRef.current = true - const msg: TScrollMsg = { type: 'SCROLL', continue: token, limit: pageSize } - wsRef.current.send(JSON.stringify(msg)) - }, [contToken, pageSize]) - - const maybeAutoScroll = useCallback(() => { - if (wantMoreRef.current && hasMore) sendScroll() - }, [hasMore, sendScroll]) - - // Handle all incoming frames from the server - useEffect(() => { - onMessageRef.current = (ev: MessageEvent) => { - let frame: TServerFrame | undefined - try { - frame = JSON.parse(String(ev.data)) as TServerFrame - } catch { - return - } - if (!frame) return - - if (frame.type === 'INITIAL') { - dispatch({ type: 'RESET', items: frame.items }) - setContToken(frame.continue) - setHasMore(Boolean(frame.continue)) - setLastError(undefined) - fetchingRef.current = false - - const snapshotRV = frame.resourceVersion || getMaxRV(frame.items) - if (snapshotRV) { - latestRVRef.current = snapshotRV - haveAnchorRef.current = true // NEW: we now have a safe anchor - } - return - } - - if (frame.type === 'PAGE') { - dispatch({ type: 'APPEND_PAGE', items: frame.items }) - setContToken(frame.continue) - setHasMore(Boolean(frame.continue)) - fetchingRef.current = false - - const batchRV = getMaxRV(frame.items) - if (batchRV && (!latestRVRef.current || compareRV(batchRV, latestRVRef.current) > 0)) { - latestRVRef.current = batchRV - } - maybeAutoScroll() - return - } - - if (frame.type === 'PAGE_ERROR') { - setLastError(frame.error || 'Failed to load next page') - fetchingRef.current = false - return - } - - if (frame.type === 'ADDED' || frame.type === 'MODIFIED' || frame.type === 'DELETED') { - const rv = getRV(frame.item) - if (rv && (!latestRVRef.current || compareRV(rv, latestRVRef.current) > 0)) { - latestRVRef.current = rv - } - } - - if (!pausedRef.current) { - if (frame.type === 'ADDED' || frame.type === 'MODIFIED') { - dispatch({ type: 'UPSERT', item: frame.item }) - return - } - - if (!removeIgnoredRef.current && frame.type === 'DELETED') { - dispatch({ type: 'REMOVE', key: eventKey(frame.item) }) - } - } - } - }, [maybeAutoScroll]) - - const buildWsUrl = useCallback((raw: string) => { - try { - const hasScheme = /^[a-z]+:/i.test(raw) - const base = window.location.origin - let u = hasScheme ? new URL(raw) : new URL(raw.startsWith('/') ? raw : `/${raw}`, base) - if (u.protocol === 'http:') u.protocol = 'ws:' - if (u.protocol === 'https:') u.protocol = 'wss:' - if (u.protocol !== 'ws:' && u.protocol !== 'wss:') { - u = new URL(u.pathname + u.search + u.hash, base) - u.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - } - if (haveAnchorRef.current && latestRVRef.current) { - u.searchParams.set('sinceRV', latestRVRef.current) - } else { - u.searchParams.delete('sinceRV') - } - return u.toString() - } catch { - const origin = window.location.origin.replace(/^http/, 'ws') - const prefix = raw.startsWith('/') ? '' : '/' - const rv = haveAnchorRef.current ? latestRVRef.current : undefined - const sep = raw.includes('?') ? '&' : '?' - return `${origin}${prefix}${raw}${rv ? `${sep}sinceRV=${encodeURIComponent(rv)}` : ''}` - } - }, []) - - // Establish and maintain the WebSocket connection with bounded backoff - const connect = useCallback(() => { - if (!mountedRef.current) return - // Prevent duplicate opens - if (connectingRef.current) return - if ( - wsRef.current && - (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING) - ) { - return - } - connectingRef.current = true - - setConnStatus('connecting') - setLastError(undefined) - - const url = buildWsUrl(urlRef.current) - const ws = new WebSocket(url) - wsRef.current = ws - - ws.addEventListener('open', () => { - if (!mountedRef.current) return - backoffRef.current = 750 - fetchingRef.current = false - setConnStatus('open') - connectingRef.current = false - }) - - ws.addEventListener('message', ev => onMessageRef.current(ev)) - - const scheduleReconnect = () => { - if (wsRef.current === ws) wsRef.current = null - setConnStatus('closed') - connectingRef.current = false - // Bounded exponential backoff with jitter to avoid herding - const base = Math.min(backoffRef.current, 8000) - const jitter = Math.random() * 0.4 + 0.8 // 0.8x–1.2x - const wait = Math.floor(base * jitter) - const next = Math.min(base * 2, 12000) - backoffRef.current = next - if (reconnectTimerRef.current) { - window.clearTimeout(reconnectTimerRef.current) - reconnectTimerRef.current = null - } - reconnectTimerRef.current = window.setTimeout(() => { - if (!mountedRef.current) return - connect() - }, wait) - } - - ws.addEventListener('close', scheduleReconnect) - ws.addEventListener('error', () => { - setLastError('WebSocket error') - scheduleReconnect() - }) - }, [buildWsUrl]) - - // Kick off initial connection on mount; clean up on unmount - useEffect(() => { - if (startedRef.current) return undefined // StrictMode double-invoke guard - startedRef.current = true - - mountedRef.current = true - connect() - - return () => { - mountedRef.current = false - startedRef.current = false - if (reconnectTimerRef.current) { - window.clearTimeout(reconnectTimerRef.current) - reconnectTimerRef.current = null - } - closeWS() - wsRef.current = null - connectingRef.current = false - } - // INTENTIONALLY EMPTY DEPS – do not reopen on state changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // IntersectionObserver to trigger SCROLL when sentinel becomes visible - useEffect(() => { - // Get the current DOM element referenced by sentinelRef - const el = sentinelRef.current - - // If the sentinel element is not mounted yet, exit early - if (!el) return undefined - - // Create a new IntersectionObserver to watch visibility changes of the sentinel - const io = new IntersectionObserver(entries => { - // Determine if any observed element is currently visible in the viewport - const visible = entries.some(e => e.isIntersecting) - - // Store the current visibility status in a ref (no re-render triggered) - wantMoreRef.current = visible - - // If sentinel is visible and there are more pages available, request the next page - if (visible && hasMore) sendScroll() - }) - - // Start observing the sentinel element for intersection events - io.observe(el) - - // Cleanup: disconnect the observer when component unmounts or dependencies change - return () => io.disconnect() - - // Dependencies: re-run this effect if hasMore or sendScroll changes - }, [hasMore, sendScroll]) - - // Fallback: if user scrolls near bottom manually, also try to fetch - const onScroll = useCallback(() => { - if (!listRef.current) return - const nearBottom = listRef.current.scrollTop + listRef.current.clientHeight >= listRef.current.scrollHeight - 24 - if (nearBottom && hasMore) sendScroll() - }, [hasMore, sendScroll]) - - const total = state.order.length - - const getPlural = kindsWithVersion ? pluralByKind(kindsWithVersion) : undefined - - return ( - - - - - { - if (isPaused) { - setIsPaused(false) - } else { - setIsPaused(true) - } - }} - > - {isPaused ? : } - - - {isPaused && 'Streaming paused'} - {!isPaused && connStatus === 'connecting' && 'Connecting…'} - {!isPaused && connStatus === 'open' && 'Streaming events...'} - {!isPaused && connStatus === 'closed' && 'Reconnecting…'} - - - - - {!hasMore &&
No more events ·
} - {typeof total === 'number' ?
Loaded {total} events
: ''} - {lastError && · {lastError}} - -
{isRemoveIgnored ? 'Handle REMOVE signals' : 'Ignore REMOVE signals'}
- Locked means ignore -
- } - placement="left" - > - setIsRemoveIgnored(!isRemoveIgnored)}> - {isRemoveIgnored ? : } - - - - - - {/* Scrollable list of event rows */} - - {state.order.length > 0 ? ( - state.order.map(k => ( - - )) - ) : ( - - )} - {/* Infinite scroll sentinel */} - - - - {state.order.length > 0 && } - - ) -} diff --git a/src/components/organisms/Events/index.ts b/src/components/organisms/Events/index.ts deleted file mode 100644 index b4d4184..0000000 --- a/src/components/organisms/Events/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Events' diff --git a/src/components/organisms/Events/molecules/EventRow/EventRow.tsx b/src/components/organisms/Events/molecules/EventRow/EventRow.tsx deleted file mode 100644 index 824c191..0000000 --- a/src/components/organisms/Events/molecules/EventRow/EventRow.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React, { FC } from 'react' -import { useNavigate } from 'react-router-dom' -import { theme as antdtheme, Flex, Typography } from 'antd' -import { EarthIcon, getUppercase, hslFromString, Spacer } from '@prorobotech/openapi-k8s-toolkit' -import { useSelector } from 'react-redux' -import { RootState } from 'store/store' -import { TEventsV1Event } from '../../types' -import { eventText, timeAgo, getResourceLink, getNamespaceLink, formatEventSummary } from './utils' -import { Styled } from './styled' - -type TEventRowProps = { - e: TEventsV1Event - baseprefix?: string - cluster: string - getPlural?: (kind: string, apiVersion?: string) => string | undefined -} - -export const EventRow: FC = ({ e, baseprefix, cluster, getPlural }) => { - const { token } = antdtheme.useToken() - const navigate = useNavigate() - const theme = useSelector((state: RootState) => state.openapiTheme.theme) - - const abbr = e.regarding?.kind ? getUppercase(e.regarding.kind) : undefined - const bgColor = e.regarding?.kind && abbr ? hslFromString(e.regarding?.kind, theme) : 'initial' - const bgColorNamespace = hslFromString('Namespace', theme) - - const regardingKind: string | undefined = e.regarding?.kind - const regardingApiVersion: string = e.regarding?.apiVersion || 'v1' - const pluralName: string | undefined = - regardingKind && regardingApiVersion ? getPlural?.(regardingKind, regardingApiVersion) : undefined - const resourceLink: string | undefined = getResourceLink({ - baseprefix, - cluster, - namespace: e.regarding?.namespace, - apiGroupVersion: regardingApiVersion, - pluralName, - name: e.regarding?.name, - }) - const namespaceLink: string | undefined = getNamespaceLink({ - baseprefix, - cluster, - apiGroupVersion: 'v1', - pluralName: 'namespaces', - namespace: e.regarding?.namespace, - }) - - return ( - - - - - {abbr} - {resourceLink ? ( - { - e.preventDefault() - navigate(resourceLink) - }} - > - {e.regarding?.name} - - ) : ( - {e.regarding?.name} - )} - - {e.regarding?.namespace && ( - - NS - {namespaceLink ? ( - { - e.preventDefault() - navigate(namespaceLink) - }} - > - {e.regarding?.namespace} - - ) : ( - {e.regarding?.namespace} - )} - - )} - - {e.metadata?.creationTimestamp && ( - -
- -
- {timeAgo(e.metadata?.creationTimestamp)} -
- )} -
- - - -
- {e.deprecatedSource?.component && ( - - - Generated by - {e.deprecatedSource?.component} - -
- -
-
- )} -
- {e.reason || e.action || 'Event'} -
- - {formatEventSummary(e)} - -
- - {eventText(e) &&
{eventText(e)}
} -
- ) -} diff --git a/src/components/organisms/Events/molecules/EventRow/index.ts b/src/components/organisms/Events/molecules/EventRow/index.ts deleted file mode 100644 index 1f3ecc6..0000000 --- a/src/components/organisms/Events/molecules/EventRow/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EventRow } from './EventRow' diff --git a/src/components/organisms/Events/molecules/EventRow/styled.ts b/src/components/organisms/Events/molecules/EventRow/styled.ts deleted file mode 100644 index 315b71e..0000000 --- a/src/components/organisms/Events/molecules/EventRow/styled.ts +++ /dev/null @@ -1,73 +0,0 @@ -import styled from 'styled-components' - -type TCardProps = { - $mainColor: string - $bigBorder?: boolean -} - -const Card = styled.div` - border-radius: 6px; - padding: 16px 8px; - border: ${({ $bigBorder }) => ($bigBorder ? 2 : 1)}px solid ${({ $mainColor }) => $mainColor}; - gap: 12px; - margin-bottom: 16px; - position: relative; - - &:before { - position: absolute; - content: ''; - width: 36px; - height: ${({ $bigBorder }) => ($bigBorder ? 2 : 1)}px; - background: ${({ $mainColor }) => $mainColor}; - left: -37px; - top: 50%; /* halfway down parent */ - transform: translateY(-50%); /* center vertically */ - } - - &:after { - position: absolute; - content: ''; - width: ${({ $bigBorder }) => ($bigBorder ? 7 : 6)}px; - height: ${({ $bigBorder }) => ($bigBorder ? 7 : 6)}px; - border-radius: 50%; - background: ${({ $mainColor }) => $mainColor}; - left: ${({ $bigBorder }) => ($bigBorder ? -41 : -39)}px; - top: 50%; - transform: translateY(-50%); - } -` - -type TAbbrProps = { - $bgColor: string -} - -const Abbr = styled.span` - background-color: ${({ $bgColor }) => $bgColor}; - border-radius: 13px; - padding: 1px 5px; - font-size: 13px; - height: min-content; - margin-right: 4px; -` - -const TimeStamp = styled.div` - font-weight: 400; - font-size: 12px; - line-height: 20px; -` - -const Title = styled.div` - font-weight: 700; -` - -const TimesInPeriod = styled.div` - margin-top: -16px; -` - -export const Styled = { - Card, - Abbr, - TimeStamp, - Title, - TimesInPeriod, -} diff --git a/src/components/organisms/Events/molecules/EventRow/utils.ts b/src/components/organisms/Events/molecules/EventRow/utils.ts deleted file mode 100644 index 739bff9..0000000 --- a/src/components/organisms/Events/molecules/EventRow/utils.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - BASE_FACTORY_NAMESPACED_API_KEY, - BASE_FACTORY_CLUSTERSCOPED_API_KEY, - BASE_FACTORY_NAMESPACED_BUILTIN_KEY, - BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY, - BASE_NAMESPACE_FACTORY_KEY, -} from 'constants/customizationApiGroupAndVersion' -import { TEventsV1Event } from '../../types' - -// Prefer modern `note`, fallback to legacy `message` -export const eventText = (e: TEventsV1Event) => e.note || e.message || '' - -// Friendly relative time formatter; returns locale string for >24h -export const timeAgo = (iso?: string) => { - if (!iso) { - return '' - } - const dt = new Date(iso).getTime() - - const diff = Date.now() - dt - - if (diff < 60_000) { - return `${Math.max(0, Math.floor(diff / 1000))}s ago` - } - if (diff < 3_600_000) { - return `${Math.floor(diff / 60_000)}m ago` - } - if (diff < 86_400_000) { - return `${Math.floor(diff / 3_600_000)}h ago` - } - - return new Date(iso).toLocaleString() -} - -export const getResourceLink = ({ - baseprefix, - cluster, - namespace, - apiGroupVersion, - pluralName, - name, -}: { - baseprefix?: string - cluster: string - namespace?: string - apiGroupVersion: string - pluralName?: string - name?: string -}): string | undefined => { - if (!pluralName || !name) { - return undefined - } - - if (apiGroupVersion === 'v1') { - return `${baseprefix}/${cluster}${namespace ? `/${namespace}` : ''}/factory/${ - namespace ? BASE_FACTORY_NAMESPACED_BUILTIN_KEY : BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY - }/${apiGroupVersion}/${pluralName}/${name}` - } - - return `${baseprefix}/${cluster}${namespace ? `/${namespace}` : ''}/factory/${ - namespace ? BASE_FACTORY_NAMESPACED_API_KEY : BASE_FACTORY_CLUSTERSCOPED_API_KEY - }/${apiGroupVersion}/${pluralName}/${name}` -} - -export const getNamespaceLink = ({ - baseprefix, - cluster, - apiGroupVersion, - pluralName, - namespace, -}: { - baseprefix?: string - cluster: string - pluralName: string - apiGroupVersion: string - namespace?: string -}): string | undefined => { - if (!namespace) { - return undefined - } - - return `${baseprefix}/${cluster}/factory/${BASE_NAMESPACE_FACTORY_KEY}/${apiGroupVersion}/${pluralName}/${namespace}` -} - -export const formatEventSummary = (event: TEventsV1Event): string | undefined => { - if (!event.deprecatedCount || !event.deprecatedFirstTimestamp) { - return undefined - } - - const now = new Date() - const first = new Date(event.deprecatedFirstTimestamp) - const days = Math.floor((now.getTime() - first.getTime()) / (1000 * 60 * 60 * 24)) - - return `${event.deprecatedCount} times ${days === 0 ? 'today' : `in the last ${days} days`}` -} diff --git a/src/components/organisms/Events/molecules/index.ts b/src/components/organisms/Events/molecules/index.ts deleted file mode 100644 index 1f3ecc6..0000000 --- a/src/components/organisms/Events/molecules/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EventRow } from './EventRow' diff --git a/src/components/organisms/Events/reducer.ts b/src/components/organisms/Events/reducer.ts deleted file mode 100644 index e30c701..0000000 --- a/src/components/organisms/Events/reducer.ts +++ /dev/null @@ -1,60 +0,0 @@ -// ------------------------------------------------------------ -// Reducer to maintain a keyed list of events, supporting ADDED/MODIFIED/DELETED -// ------------------------------------------------------------ -// We keep an `order` array for display order (newest first) and a `byKey` map -// for O(1) updates/reads. Pages append to the END (older items), while live -// UPSERTs (ADDED/MODIFIED) unshift to the START if new. -import { TEventsV1Event } from './types' -import { eventKey } from './utils' - -type TState = { - order: string[] // list of keys (newest first) - byKey: Record -} - -type TAction = - | { type: 'RESET'; items: TEventsV1Event[] } - | { type: 'APPEND_PAGE'; items: TEventsV1Event[] } // for older pages (append to end) - | { type: 'UPSERT'; item: TEventsV1Event } // ADDED/MODIFIED - | { type: 'REMOVE'; key: string } // DELETED - -export const reducer = (state: TState, action: TAction): TState => { - switch (action.type) { - case 'RESET': { - // Replace everything with the initial payload (usually newest N) - const order = action.items.map(eventKey) - const byKey: TState['byKey'] = {} - // eslint-disable-next-line no-return-assign - action.items.forEach(it => (byKey[eventKey(it)] = it)) - return { order, byKey } - } - case 'APPEND_PAGE': { - // Append only truly new keys to the end; update any items that already exist - const next = { ...state.byKey } - const addKeys: string[] = [] - action.items.forEach(it => { - const k = eventKey(it) - if (!next[k]) addKeys.push(k) - next[k] = it - }) - return { order: [...state.order, ...addKeys], byKey: next } - } - case 'UPSERT': { - // Insert new items at the front; replace existing in-place - const k = eventKey(action.item) - const exists = Boolean(state.byKey[k]) - const byKey = { ...state.byKey, [k]: action.item } - const order = exists ? state.order : [k, ...state.order] - return { order, byKey } - } - case 'REMOVE': { - // Remove from map and order if present - if (!state.byKey[action.key]) return state - const byKey = { ...state.byKey } - delete byKey[action.key] - return { order: state.order.filter(k => k !== action.key), byKey } - } - default: - return state - } -} diff --git a/src/components/organisms/Events/styled.ts b/src/components/organisms/Events/styled.ts deleted file mode 100644 index 1ac1e29..0000000 --- a/src/components/organisms/Events/styled.ts +++ /dev/null @@ -1,100 +0,0 @@ -import styled from 'styled-components' - -type TRootProps = { - $maxHeight: number -} - -const Root = styled.div` - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - max-height: ${({ $maxHeight }) => $maxHeight}px; - border-radius: 12px; - overflow: hidden; - position: relative; -` - -const Header = styled.div` - display: flex; - align-items: center; - gap: 16px; - align-self: stretch; - margin-bottom: 16px; - padding-left: 19px; -` - -const HeaderLeftSide = styled.div` - display: flex; - align-items: center; - gap: 10px; - flex: 1 0 0; -` - -const CursorPointerDiv = styled.div` - cursor: pointer; - user-select: none; -` - -const StatusText = styled.div` - font-size: 16px; - line-height: 24px; /* 150% */ -` - -type THeaderRightSideProps = { - $colorTextDescription: string -} - -const HeaderRightSide = styled.div` - display: flex; - gap: 4px; - text-align: right; - color: ${({ $colorTextDescription }) => $colorTextDescription}; -` - -const List = styled.div` - flex: 1; - overflow-y: auto; - padding: 8px 8px 8px 72px; - z-index: 2; -` - -type TTimelineProps = { - $colorText: string - $maxHeight: number -} - -const Timeline = styled.div` - width: 100%; - height: ${({ $maxHeight }) => $maxHeight}px; - position: absolute; - top: 40px; - left: 36px; - z-index: 1; - - &:before { - content: ''; - position: absolute; - top: -2px; - width: 1px; - background: ${({ $colorText }) => $colorText}; - pointer-events: none; - height: 100%; - } -` - -const Sentinel = styled.div` - height: 1px; -` - -export const Styled = { - Root, - Header, - HeaderLeftSide, - CursorPointerDiv, - StatusText, - HeaderRightSide, - Timeline, - List, - Sentinel, -} diff --git a/src/components/organisms/Events/types.ts b/src/components/organisms/Events/types.ts deleted file mode 100644 index 15008de..0000000 --- a/src/components/organisms/Events/types.ts +++ /dev/null @@ -1,80 +0,0 @@ -// ========================= Types ============================ -// Messages are intentionally permissive (no k8s deps). Adjust to your API as needed. - -type TWatchPhase = 'ADDED' | 'MODIFIED' | 'DELETED' | 'BOOKMARK' - -// Shape of an events.k8s.io/v1 Event (subset) -// Note: Both modern `note` and legacy `message` are supported for text. -// Only the fields we render / key on are listed here. - -export type TEventsV1Event = { - metadata?: { - name?: string - namespace?: string - resourceVersion?: string - creationTimestamp?: string - } - type?: string // Normal | Warning - reason?: string - note?: string // message text in events.k8s.io/v1 - message?: string // legacy fallback - reportingController?: string - reportingInstance?: string - deprecatedCount?: number - deprecatedFirstTimestamp?: Date - action?: string - eventTime?: string - regarding?: { - apiVersion?: string - kind?: string - name?: string - namespace?: string - } - deprecatedSource?: { - component?: string - host?: string - } -} - -// ====================== Server Frames ======================= -// Incoming frames from the server. Your backend should emit one of these. -// INITIAL: first page (newest events) + a `continue` token -// PAGE: older page fetched via SCROLL -// PAGE_ERROR: pagination failed (keep live stream running) -// ADDED/MODIFIED/DELETED: watch-style deltas for live updates - -type TInitialFrame = { - type: 'INITIAL' - items: TEventsV1Event[] - continue?: string - remainingItemCount?: number - resourceVersion?: string -} - -type TPageFrame = { - type: 'PAGE' - items: TEventsV1Event[] - continue?: string - remainingItemCount?: number -} - -type TPageErrorFrame = { - type: 'PAGE_ERROR' - error: string -} - -type TDeltaFrame = { - type: TWatchPhase // ADDED | MODIFIED | DELETED - item: TEventsV1Event -} - -export type TServerFrame = TInitialFrame | TPageFrame | TPageErrorFrame | TDeltaFrame - -// Outgoing scroll request to server -// Sent when the bottom sentinel intersects view and `continue` exists. - -export type TScrollMsg = { - type: 'SCROLL' - continue: string - limit?: number -} diff --git a/src/components/organisms/Events/utils.ts b/src/components/organisms/Events/utils.ts deleted file mode 100644 index 59db057..0000000 --- a/src/components/organisms/Events/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { TEventsV1Event } from './types' - -// Unique key per event for stable list rendering and updates -export const eventKey = (e: TEventsV1Event) => { - const n = e.metadata?.name ?? '' - const ns = e.metadata?.namespace ?? '' - return `${ns}/${n}` -} - -// Compare resourceVersions safely (string-based) -export const compareRV = (a: string, b: string): number => { - if (a.length !== b.length) return a.length > b.length ? 1 : -1 - // eslint-disable-next-line no-nested-ternary - return a > b ? 1 : a < b ? -1 : 0 -} - -type WithRV = { metadata?: { resourceVersion?: string } } - -export const getRV = (item: WithRV): string | undefined => item?.metadata?.resourceVersion - -// ✅ Pure functional + no restricted syntax -export const getMaxRV = (items: ReadonlyArray): string | undefined => { - const rvs = items - .map(getRV) - .filter((v): v is string => Boolean(v)) - .sort(compareRV) - return rvs.length ? rvs[rvs.length - 1] : undefined -} diff --git a/src/components/organisms/index.ts b/src/components/organisms/index.ts index 7042caa..e79f7bc 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -10,4 +10,3 @@ export * from './HeaderSecond' export * from './Sidebar' export * from './Footer' export * from './Search' -export * from './Events' diff --git a/src/pages/EventsPage/EventsPage.tsx b/src/pages/EventsPage/EventsPage.tsx deleted file mode 100644 index 2264b65..0000000 --- a/src/pages/EventsPage/EventsPage.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React, { FC } from 'react' -import { useParams } from 'react-router-dom' -import { ManageableBreadcrumbs, ManageableSidebar, Events, NavigationContainer } from 'components' -import { getBreadcrumbsIdPrefix } from 'utils/getBreadcrumbsIdPrefix' -import { getSidebarIdPrefix } from 'utils/getSidebarIdPrefix' -import { BaseTemplate } from 'templates' - -export const EventsPage: FC = () => { - const { clusterName, namespace, syntheticProject, key } = useParams() - - const possibleProject = syntheticProject && namespace ? syntheticProject : namespace - const possibleInstance = syntheticProject && namespace ? namespace : undefined - - const breadcrumbsId = `${getBreadcrumbsIdPrefix({ - instance: !!syntheticProject, - project: !!namespace, - })}factory-${key}` - - const sidebarId = `${getSidebarIdPrefix({ - instance: !!syntheticProject, - project: !!namespace, - })}factory-${key}` - - return ( - - } - // withNoCluster - > - - - - {clusterName && ( - - )} - - ) -} diff --git a/src/pages/EventsPage/index.ts b/src/pages/EventsPage/index.ts deleted file mode 100644 index 1fe46c6..0000000 --- a/src/pages/EventsPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EventsPage } from './EventsPage' diff --git a/src/pages/index.ts b/src/pages/index.ts index a1b7138..82d8723 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -19,5 +19,3 @@ export { FactoryPage } from './FactoryPage' export { FactoryAdminPage } from './FactoryAdminPage' /* search */ export { SearchPage } from './SearchPage' -/* events */ -export { EventsPage } from './EventsPage' From 7850e2e8e645e61071aeb75a8d1388fa27cc12e2 Mon Sep 17 00:00:00 2001 From: typescreep Date: Sat, 1 Nov 2025 17:48:10 +0300 Subject: [PATCH 18/33] fix package --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index ae7697c..32dd19f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.152", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.153", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.152", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.152.tgz", - "integrity": "sha512-bfOD3cTkfqc5C+4FR1LNvB7I7JzXdOUf9BhoEb1QD9bGH4jel89ZAScvqRhB1HichogXO21NnZNGSmco2oYOfQ==", + "version": "0.0.1-alpha.153", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.153.tgz", + "integrity": "sha512-F8E/5495rZiU4s8W+IEJ1ZSBnnyoxWE6tTtjC55xRfzmkjfcI6mAjcRjI9Kw4tOvryHBzxabSttQmFyrJJiIjw==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index e599830..6294df2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.152", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.153", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", From e10333865d84e955d2abde82fb76f53dff76c7a4 Mon Sep 17 00:00:00 2001 From: typescreep Date: Sat, 1 Nov 2025 19:01:59 +0300 Subject: [PATCH 19/33] new field selector/rename props in tables and events --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 32dd19f..9871879 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.153", + "@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.154", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", @@ -2804,9 +2804,9 @@ } }, "node_modules/@prorobotech/openapi-k8s-toolkit": { - "version": "0.0.1-alpha.153", - "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.153.tgz", - "integrity": "sha512-F8E/5495rZiU4s8W+IEJ1ZSBnnyoxWE6tTtjC55xRfzmkjfcI6mAjcRjI9Kw4tOvryHBzxabSttQmFyrJJiIjw==", + "version": "0.0.1-alpha.154", + "resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.154.tgz", + "integrity": "sha512-m/xi/HWTiDj8nNRoym0GQpMbxtIdoq32/z/r7lYGrYxQmq15VvF5FhRbB2utY1J1tK6WnzMqXnFOM0J19qJ/Ag==", "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", diff --git a/package.json b/package.json index 6294df2..2fb9303 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ant-design/icons": "5.6.0", "@monaco-editor/react": "4.6.0", "@originjs/vite-plugin-federation": "1.3.6", - "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.153", + "@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.154", "@readme/openapi-parser": "4.0.0", "@reduxjs/toolkit": "2.2.5", "@tanstack/react-query": "5.62.2", From 2cafd2e0314bbf4382fd223c7062424189314238 Mon Sep 17 00:00:00 2001 From: typescreep Date: Sat, 1 Nov 2025 20:22:21 +0300 Subject: [PATCH 20/33] fix border radius --- src/components/molecules/ManageableSidebar/styled.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/molecules/ManageableSidebar/styled.ts b/src/components/molecules/ManageableSidebar/styled.ts index 7297439..4c811f5 100644 --- a/src/components/molecules/ManageableSidebar/styled.ts +++ b/src/components/molecules/ManageableSidebar/styled.ts @@ -18,6 +18,7 @@ const Container = styled.div` direction: rtl; max-height: ${({ $maxHeight }) => $maxHeight || 'initial'}; user-select: none; + border-top-right-radius: 12px; & ul { direction: ltr; From fe5c5cbad144307a495a2e3d9ad05d3133f663e4 Mon Sep 17 00:00:00 2001 From: typescreep Date: Sat, 1 Nov 2025 22:32:46 +0300 Subject: [PATCH 21/33] white labels --- .env | 10 ++ .env.options.dist | 10 ++ README.md | 67 ++++++----- server/getDynamicIndex.ts | 20 +++- server/index.ts | 39 +++++++ src/components/organisms/Footer/Footer.tsx | 5 +- .../organisms/Header/organisms/Logo/Logo.tsx | 27 +++-- .../organisms/Header/organisms/Logo/utils.tsx | 17 +++ .../ListInsideClusterAndNs.tsx | 108 +++++++++++++----- .../customizationApiGroupAndVersion.ts | 33 ++++++ 10 files changed, 272 insertions(+), 64 deletions(-) create mode 100644 src/components/organisms/Header/organisms/Logo/utils.tsx diff --git a/.env b/.env index 4bed8ba..b60c478 100644 --- a/.env +++ b/.env @@ -1,3 +1,9 @@ +VITE_TITLE_TEXT="OpenAPI UI" +VITE_LOGO_TEXT="In-Cloud" +VITE_FOOTER_TEXT="PRO Robotech" +VITE_CUSTOM_LOGO_SVG="PHN2ZyB3aWR0aD0iMjYiIGhlaWdodD0iMzEiIHZpZXdCb3g9IjAgMCAyNiAzMSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4=ICAgICAgICAgIDxwYXRoICAgICAgICAgICAgZD0iTTE4Ljc0NjcgMTkuMzY0MUMxOC43NDY3IDIwLjgwOCAxNy42MDY5IDIxLjk3ODQgMTYuMjAwNyAyMS45Nzg0SDguODI1OTdDNy43MTkxMiAyMS45Nzg0IDYuODIxNyAyMS4wNTcgNi44MjE3IDE5LjkyMDRDNi44MjE3IDE4Ljc4MzcgNy43MTkxMiAxNy44NjIzIDguODI1OTcgMTcuODYyM0M4LjgzNTA3IDE3Ljg2MjMgOC44NDQxMiAxNy44NjI2IDguODUzMTcgMTcuODYyN0M5LjAxMDUyIDE2Ljg4NzMgOS44MzU3MiAxNi4xNDM1IDEwLjgzMDIgMTYuMTQzNUMxMC45MzgxIDE2LjE0MzUgMTEuMDQ0MSAxNi4xNTI0IDExLjE0NzQgMTYuMTY5MkMxMS42MjYxIDE1LjEzNzIgMTIuNjUxMiAxNC40MjM5IDEzLjgzODYgMTQuNDIzOUMxNS4yNTg3IDE0LjQyMzkgMTYuNDQ2NyAxNS40NDQzIDE2Ljc0NTMgMTYuODFDMTcuODg5OCAxNy4wNjYyIDE4Ljc0NjYgMTguMTEyMyAxOC43NDY3IDE5LjM2NDFaTTE0Ljc2OCAxMy45MTk0QzE1LjgwNzEgMTQuMTM2NyAxNi44OTQ0IDEzLjA3OCAxNy4xOTYzIDExLjU1NDhDMTcuNDk4NCAxMC4wMzE1IDE2LjkwMDggOC42MjA2MiAxNS44NjE2IDguNDAzMzdDMTQuODIyNSA4LjE4NjEzIDEzLjczNTMgOS4yNDQ4OCAxMy40MzMzIDEwLjc2ODFDMTMuMTMxMyAxMi4yOTEzIDEzLjcyODggMTMuNzAyMiAxNC43NjggMTMuOTE5NFpNMTEuMjcxIDEzLjkxOTRDMTIuMzEwMiAxMy43MDIyIDEyLjkwNzggMTIuMjkxMyAxMi42MDU3IDEwLjc2OEMxMi4zMDM3IDkuMjQ0NzkgMTEuMjE2NSA4LjE4NjA4IDEwLjE3NzQgOC40MDMzMkM5LjEzODI1IDguNjIwNTcgOC41NDA2OCAxMC4wMzE1IDguODQyNyAxMS41NTQ3QzkuMTQ0NjggMTMuMDc3OSAxMC4yMzE5IDE0LjEzNjYgMTEuMjcxIDEzLjkxOTRaTTguNDU0MTcgMTYuNDU1MkM5LjI1NDEyIDE2LjA2NTIgOS40NjMwNyAxNC43OTg0IDguOTIwODcgMTMuNjI1OEM4LjM3ODY2IDEyLjQ1MzEgNy4yOTA1NyAxMS44MTg3IDYuNDkwNTcgMTIuMjA4N0M1LjY5MDYyIDEyLjU5ODggNS40ODE2NyAxMy44NjU2IDYuMDIzODcgMTUuMDM4MkM2LjU2NjEyIDE2LjIxMDggNy42NTQxNyAxNi44NDUzIDguNDU0MTcgMTYuNDU1MlpNMTkuMjk2IDEyLjE1MzlDMTguNDU2IDExLjg2NTggMTcuNDUwNCAxMi42MzA0IDE3LjA0OTkgMTMuODYxN0MxNi42NDk0IDE1LjA5MjkgMTcuMDA1NyAxNi4zMjQ2IDE3Ljg0NTcgMTYuNjEyOEMxOC42ODU3IDE2LjkwMDggMTkuNjkxMyAxNi4xMzYyIDIwLjA5MTggMTQuOTA1QzIwLjQ5MjIgMTMuNjczNyAyMC4xMzYgMTIuNDQyIDE5LjI5NiAxMi4xNTM5Wk0yNiAyMS42NTc4QzI2LjAwMDEgMjIuNjE1MiAyNS41MDI3IDIzLjQ5OTkgMjQuNjk1NCAyMy45Nzg5TDE0LjMwNzIgMzAuMTQwNkMxMy40OTk5IDMwLjYxOTcgMTIuNTA0OCAzMC42MTk4IDExLjY5NyAzMC4xNDFMMS4zMDcxNiAyMy45ODI3QzAuNDk5NjYxIDIzLjUwMzkgMC4wMDIwNDA3NyAyMi42MTkzIDAuMDAxODE4OTYgMjEuNjYxOUw3LjQxNTEzZS0wOCA5LjM0MjA1Qy0wLjAwMDIyMTc0MSA4LjM4NDU2IDAuNDk3MjIxIDcuNDk5OTUgMS4zMDQ1NCA3LjAyMDgyTDExLjY5MjUgMC44NTkyNTJDMTIuNTAwMiAwLjM4MDM5NCAxMy40OTUyIDAuMzgwMjU3IDE0LjMwMjcgMC44NTg3OTdMMjQuNjkyOCA3LjAxNzA5QzI1LjUwMDMgNy40OTU4NSAyNS45OTc5IDguMzgwNDEgMjUuOTk4MiA5LjMzNzg2TDI2IDIxLjY1NzhaTTI0LjYwNjUgMjAuOTk3NkwyNC42MDQ4IDkuOTk4MzhDMjQuNjA0NyA5LjE0MzU3IDI0LjE2MDMgOC4zNTQwOCAyMy40Mzk0IDcuOTI2NDdMMTQuMTYzMSAyLjQyODQzQzEzLjQ0MjIgMi4wMDExIDEyLjU1MzcgMi4wMDEyMyAxMS44MzI4IDIuNDI4ODRMMi41NTgxOSA3LjkyOTg5QzEuODM3NiA4LjM1NzY4IDEuMzkzNCA5LjE0NzMgMS4zOTM1NyAxMC4wMDIxTDEuMzk1MjIgMjEuMDAxNEMxLjM5NTMxIDIxLjg1NjIgMS44Mzk3OCAyMi42NDU3IDIuNTYwNTQgMjMuMDczM0wxMS44MzY3IDI4LjU3MTRDMTIuNTU3OCAyOC45OTg4IDEzLjQ0NjQgMjguOTk4NyAxNC4xNjcxIDI4LjU3MUwyMy40NDE3IDIzLjA2OThDMjQuMTYyNSAyMi42NDIxIDI0LjYwNjYgMjEuODUyNCAyNC42MDY1IDIwLjk5NzZaIg==ICAgICAgICAgICAgZmlsbD0iY3VycmVudENvbG9yIg==ICAgICAgICAgIC8+ICAgICAgICA8L3N2Zz4=" +VITE_CUSTOM_TENANT_TEXT= + VITE_CUSTOMIZATION_API_GROUP=incloud.io VITE_CUSTOMIZATION_API_VERSION=v1alpha @@ -39,3 +45,7 @@ VITE_BASE_FACTORY_CLUSTERSCOPED_API_KEY=base-factory-clusterscoped-api VITE_BASE_FACTORY_NAMESPACED_BUILTIN_KEY=base-factory-namespaced-builtin VITE_BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY=base-factory-clusterscoped-builtin VITE_BASE_NAMESPACE_FACTORY_KEY=base-factory-clusterscoped-builtin + +VITE_CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP= +VITE_CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION= +VITE_CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME= diff --git a/.env.options.dist b/.env.options.dist index 4efefbc..504093f 100644 --- a/.env.options.dist +++ b/.env.options.dist @@ -1,3 +1,9 @@ +TITLE_TEXT= +LOGO_TEXT= +FOOTER_TEXT= +CUSTOM_LOGO_SVG= +CUSTOM_TENANT_TEXT= + KUBE_API_URL= CUSTOMIZATION_API_GROUP= @@ -41,3 +47,7 @@ BASE_FACTORY_CLUSTERSCOPED_API_KEY= BASE_FACTORY_NAMESPACED_BUILTIN_KEY= BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY= BASE_NAMESPACE_FACTORY_KEY= + +CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP= +CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION= +CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME= diff --git a/README.md b/README.md index e9e09cd..7c73b3f 100644 --- a/README.md +++ b/README.md @@ -9,30 +9,43 @@ Define interfaces in YAML; the app discovers CRDs, watches their objects, and bu This app can be configured through environment variables. -| Variable | Type | Description | -| ---------------------------------------- | --------- | --------------------------------------------------------------------------------------- | -| `BASEPREFIX` | `string` | Base URL for the app. `/openapi-ui` | -| `KUBE_API_URL` | `string` | URL for the Kubernetes API. `http://api.incloud-web.svc.default.in-cloud.internal:8081` | -| `BFF_URL` | `string` | URL for the BFF | -| `LOGIN_URL` | `string` | Login endpoint. `/oauth/token` | -| `LOGOUT_URL` | `string` | Logout endpoint. `/oauth/logout` | -| `LOGIN_USERNAME_FIELD` | `string` | Field from login endpoint response. `name` | -| `CUSTOMIZATION_API_GROUP` | `string` | API group for customization resources. `front.in-cloud.io` | -| `CUSTOMIZATION_API_VERSION` | `string` | API version for customization resources. `v1alpha1` | -| `CUSTOMIZATION_NAVIGATION_RESOURCE_NAME` | `string` | Resource plural name for navigation settings. `navigations` | -| `CUSTOMIZATION_NAVIGATION_RESOURCE` | `string` | Resource name for navigation settings. `navigation` | -| `USE_NAMESPACE_NAV` | `boolean` | Use namespaces instead of project/instances. `true` | -| `NAVIGATE_FROM_CLUSTERLIST` | `string` | Location to be navigated after selecting cluster. `/openapi-ui/clusters/~recordValue~` | -| `PROJECTS_API_GROUP` | `string` | API group for projects resources. If not using namespace nav. | -| `PROJECTS_VERSION` | `string` | API version for projects resources. If not using namespace nav. | -| `PROJECTS_RESOURCE_NAME` | `string` | Plural name for projects resources. If not using namespace nav. | -| `INSTANCES_API_GROUP` | `string` | API group for instances resources. If not using namespace nav. | -| `INSTANCES_VERSION` | `string` | API version for instances resources. If not using namespace nav. | -| `INSTANCES_RESOURCE_NAME` | `string` | Plural name for instances resources. If not using namespace nav. | -| `MARKETPLACE_RESOURCE_NAME` | `string` | Plural name for marketplace resources for related factory component. | -| `MARKETPLACE_KIND` | `string` | Kind name for marketplace resources for related factory component. | -| `NODE_TERMINAL_DEFAULT_PROFILE` | `string` | Default profile for node terminal component. `baseline` | -| `REMOVE_BACKLINK` | `boolean` | Remove backlink arrow from right-side navigation | -| `REMOVE_BACKLINK_TEXT` | `boolean` | Remove backlink text from right-side navigation | -| `DOCS_URL` | `string` | URL to navigate from question mark | -| `SEARCH_TABLE_CUSTOMIZATION_PREFIX` | `string` | Search tables Customization id prefix | +| Variable | Type | Description | +| --------------------------------------------- | --------- | --------------------------------------------------------------------------------------- | +| `BASEPREFIX` | `string` | Base URL for the app. `/openapi-ui` | +| `KUBE_API_URL` | `string` | URL for the Kubernetes API. `http://api.incloud-web.svc.default.in-cloud.internal:8081` | +| `BFF_URL` | `string` | URL for the BFF | +| `TITLE_TEXT` | `string` | Page title | +| `LOGO_TEXT` | `string` | Logo text | +| `FOOTER_TEXT` | `string` | Footer text | +| `CUSTOM_LOGO_SVG` | `string` | Base64 encoded svg | +| `CUSTOM_TENANT_TEXT` | `string` | Custom tenant text override | +| `LOGIN_URL` | `string` | Login endpoint. `/oauth/token` | +| `LOGOUT_URL` | `string` | Logout endpoint. `/oauth/logout` | +| `LOGIN_USERNAME_FIELD` | `string` | Field from login endpoint response. `name` | +| `CUSTOMIZATION_API_GROUP` | `string` | API group for customization resources. `front.in-cloud.io` | +| `CUSTOMIZATION_API_VERSION` | `string` | API version for customization resources. `v1alpha1` | +| `CUSTOMIZATION_NAVIGATION_RESOURCE_NAME` | `string` | Resource plural name for navigation settings. `navigations` | +| `CUSTOMIZATION_NAVIGATION_RESOURCE` | `string` | Resource name for navigation settings. `navigation` | +| `USE_NAMESPACE_NAV` | `boolean` | Use namespaces instead of project/instances. `true` | +| `NAVIGATE_FROM_CLUSTERLIST` | `string` | Location to be navigated after selecting cluster. `/openapi-ui/clusters/~recordValue~` | +| `PROJECTS_API_GROUP` | `string` | API group for projects resources. If not using namespace nav. | +| `PROJECTS_VERSION` | `string` | API version for projects resources. If not using namespace nav. | +| `PROJECTS_RESOURCE_NAME` | `string` | Plural name for projects resources. If not using namespace nav. | +| `INSTANCES_API_GROUP` | `string` | API group for instances resources. If not using namespace nav. | +| `INSTANCES_VERSION` | `string` | API version for instances resources. If not using namespace nav. | +| `INSTANCES_RESOURCE_NAME` | `string` | Plural name for instances resources. If not using namespace nav. | +| `MARKETPLACE_RESOURCE_NAME` | `string` | Plural name for marketplace resources for related factory component. | +| `MARKETPLACE_KIND` | `string` | Kind name for marketplace resources for related factory component. | +| `NODE_TERMINAL_DEFAULT_PROFILE` | `string` | Default profile for node terminal component. `baseline` | +| `REMOVE_BACKLINK` | `boolean` | Remove backlink arrow from right-side navigation | +| `REMOVE_BACKLINK_TEXT` | `boolean` | Remove backlink text from right-side navigation | +| `DOCS_URL` | `string` | URL to navigate from question mark | +| `SEARCH_TABLE_CUSTOMIZATION_PREFIX` | `string` | Search tables Customization id prefix | +| `BASE_FACTORY_NAMESPACED_API_KEY` | `string` | Base factory key for namespaced API resources | +| `BASE_FACTORY_CLUSTERSCOPED_API_KEY` | `string` | Base factory key for clusterscoped API resources | +| `BASE_FACTORY_NAMESPACED_BUILTIN_KEY` | `string` | Base factory key for namespaced builtin (v1) resources | +| `BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY` | `string` | Base factory key for clusterscoped builtin (v1) resources | +| `BASE_NAMESPACE_FACTORY_KEY` | `string` | Base factory key for namespaces | +| `CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP` | `string` | Custom namespace resource: api group | +| `CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION` | `string` | Custom namespace resource: api version | +| `CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME` | `string` | Custom namespace resource: resource name | diff --git a/server/getDynamicIndex.ts b/server/getDynamicIndex.ts index 3be5d00..eee0311 100644 --- a/server/getDynamicIndex.ts +++ b/server/getDynamicIndex.ts @@ -2,6 +2,23 @@ export const getDynamicIndex = (baseprefix: string): string => { try { const mainJs = 'index-react.js' const mainCss = 'style.css' + const titleText = process.env.TITLE_TEXT || 'OpenAPI UI' + const iconSvg = process.env.ICON_SVG || '' + + // Generate favicon from SVG if provided + const generateFavicon = (): string => { + if (!iconSvg) { + return '' + } + try { + const decodedSvg = Buffer.from(iconSvg, 'base64').toString('utf-8') + const dataUri = `data:image/svg+xml;base64,${decodedSvg}` + return `` + } catch (error) { + console.error('Error processing icon SVG:', error) + return '' + } + } return ` @@ -14,7 +31,8 @@ export const getDynamicIndex = (baseprefix: string): string => { href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap" rel="stylesheet" /> - OpenAPI UI + ${titleText} + ${generateFavicon()} diff --git a/server/index.ts b/server/index.ts index d1bf62f..cb69d3d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -18,6 +18,12 @@ if (process.env.LOCAL === 'true') { const KUBE_API_URL = process.env.LOCAL === 'true' ? options?.KUBE_API_URL : process.env.KUBE_API_URL +const TITLE_TEXT = process.env.LOCAL === 'true' ? options?.TITLE_TEXT : process.env.TITLE_TEXT +const LOGO_TEXT = process.env.LOCAL === 'true' ? options?.LOGO_TEXT : process.env.LOGO_TEXT +const FOOTER_TEXT = process.env.LOCAL === 'true' ? options?.FOOTER_TEXT : process.env.FOOTER_TEXT +const CUSTOM_LOGO_SVG = process.env.LOCAL === 'true' ? options?.CUSTOM_LOGO_SVG : process.env.CUSTOM_LOGO_SVG +const CUSTOM_TENANT_TEXT = process.env.LOCAL === 'true' ? options?.CUSTOM_TENANT_TEXT : process.env.CUSTOM_TENANT_TEXT + const CUSTOMIZATION_API_GROUP = process.env.LOCAL === 'true' ? options?.CUSTOMIZATION_API_GROUP : process.env.CUSTOMIZATION_API_GROUP const CUSTOMIZATION_API_VERSION = @@ -90,6 +96,19 @@ const BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY = const BASE_NAMESPACE_FACTORY_KEY = process.env.LOCAL === 'true' ? options?.BASE_NAMESPACE_FACTORY_KEY : process.env.BASE_NAMESPACE_FACTORY_KEY +const CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP = + process.env.LOCAL === 'true' + ? options?.CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP + : process.env.CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP +const CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION = + process.env.LOCAL === 'true' + ? options?.CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION + : process.env.CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION +const CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME = + process.env.LOCAL === 'true' + ? options?.CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME + : process.env.CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME + const healthcheck = require('express-healthcheck') const promBundle = require('express-prom-bundle') @@ -193,11 +212,31 @@ app.get(`${basePrefix ? basePrefix : ''}/env.js`, (_, res) => { ` window._env_ = { ${basePrefix ? ` BASEPREFIX: "${basePrefix}",` : ''} + TITLE_TEXT: ${JSON.stringify(TITLE_TEXT) || '"check envs"'}, + LOGO_TEXT: ${JSON.stringify(LOGO_TEXT) || '"check envs"'}, + FOOTER_TEXT: ${JSON.stringify(FOOTER_TEXT) || '"check envs"'}, + ${CUSTOM_LOGO_SVG ? ` CUSTOM_LOGO_SVG: "${CUSTOM_LOGO_SVG}",` : ''} + ${CUSTOM_TENANT_TEXT ? ` CUSTOM_TENANT_TEXT: "${CUSTOM_TENANT_TEXT}",` : ''} CUSTOMIZATION_API_GROUP: ${JSON.stringify(CUSTOMIZATION_API_GROUP) || '"check envs"'}, CUSTOMIZATION_API_VERSION: ${JSON.stringify(CUSTOMIZATION_API_VERSION) || '"check envs"'}, CUSTOMIZATION_NAVIGATION_RESOURCE_NAME: ${ JSON.stringify(CUSTOMIZATION_NAVIGATION_RESOURCE_NAME) || '"check envs"' }, + ${ + CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP + ? ` CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP: "${CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP}",` + : '' + } + ${ + CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION + ? ` CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION: "${CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION}",` + : '' + } + ${ + CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME + ? ` CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME: "${CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME}",` + : '' + } CUSTOMIZATION_NAVIGATION_RESOURCE: ${JSON.stringify(CUSTOMIZATION_NAVIGATION_RESOURCE) || '"check envs"'}, USE_NAMESPACE_NAV: ${USE_NAMESPACE_NAV ? JSON.stringify(USE_NAMESPACE_NAV).toLowerCase() : '"false"'}, NAVIGATE_FROM_CLUSTERLIST: ${JSON.stringify(NAVIGATE_FROM_CLUSTERLIST) || '"check envs"'}, diff --git a/src/components/organisms/Footer/Footer.tsx b/src/components/organisms/Footer/Footer.tsx index c302c01..f36d824 100644 --- a/src/components/organisms/Footer/Footer.tsx +++ b/src/components/organisms/Footer/Footer.tsx @@ -1,11 +1,14 @@ import React, { FC } from 'react' import { Typography } from 'antd' +import { FOOTER_TEXT } from 'constants/customizationApiGroupAndVersion' import { Styled } from './styled' export const Footer: FC = () => { return ( - PRO Robotech © {new Date().getFullYear()} + + {FOOTER_TEXT} © {new Date().getFullYear()} + ) } diff --git a/src/components/organisms/Header/organisms/Logo/Logo.tsx b/src/components/organisms/Header/organisms/Logo/Logo.tsx index 7823147..1adf2a0 100644 --- a/src/components/organisms/Header/organisms/Logo/Logo.tsx +++ b/src/components/organisms/Header/organisms/Logo/Logo.tsx @@ -3,6 +3,8 @@ import { Flex, theme as antdtheme } from 'antd' import { useNavigate } from 'react-router-dom' import { useSelector } from 'react-redux' import { RootState } from 'store/store' +import { LOGO_TEXT, CUSTOM_LOGO_SVG, CUSTOM_TENANT_TEXT } from 'constants/customizationApiGroupAndVersion' +import { renderLogo } from './utils' import { Styled } from './styled' export const Logo: FC = () => { @@ -17,14 +19,23 @@ export const Logo: FC = () => { return ( - - - - navigate(`${baseprefix}`)}>In-Cloud - {tenant} + {CUSTOM_LOGO_SVG && typeof CUSTOM_LOGO_SVG === 'string' && CUSTOM_LOGO_SVG.length > 0 ? ( + renderLogo(CUSTOM_LOGO_SVG, token.colorText) + ) : ( + + + + )} + + navigate(`${baseprefix}`)}>{LOGO_TEXT} + + {CUSTOM_TENANT_TEXT && typeof CUSTOM_TENANT_TEXT === 'string' && CUSTOM_TENANT_TEXT.length > 0 + ? CUSTOM_TENANT_TEXT + : tenant} + ) diff --git a/src/components/organisms/Header/organisms/Logo/utils.tsx b/src/components/organisms/Header/organisms/Logo/utils.tsx new file mode 100644 index 0000000..1d04d21 --- /dev/null +++ b/src/components/organisms/Header/organisms/Logo/utils.tsx @@ -0,0 +1,17 @@ +export const renderLogo = (customLogo: string, colorText: string): JSX.Element | null => { + if (customLogo) { + // Decode base64 SVG and replace all fill placeholders + try { + const decodedSvg = atob(customLogo) + // Replace all instances of {token.colorText} with actual color + const svgWithFill = decodedSvg.replace(/\{token\.colorText\}/g, `"${colorText}"`) + // eslint-disable-next-line react/no-danger + return
+ } catch (error) { + // eslint-disable-next-line no-console + console.error('Error decoding custom logo:', error) + return null + } + } + return null +} diff --git a/src/components/organisms/ListInsideClusterAndNs/ListInsideClusterAndNs.tsx b/src/components/organisms/ListInsideClusterAndNs/ListInsideClusterAndNs.tsx index b6fb99f..ac56e5f 100644 --- a/src/components/organisms/ListInsideClusterAndNs/ListInsideClusterAndNs.tsx +++ b/src/components/organisms/ListInsideClusterAndNs/ListInsideClusterAndNs.tsx @@ -1,10 +1,15 @@ import React, { FC, useState } from 'react' import { Button, Alert, Spin, Typography } from 'antd' -import { filterSelectOptions, Spacer, useBuiltinResources } from '@prorobotech/openapi-k8s-toolkit' +import { filterSelectOptions, Spacer, useBuiltinResources, useApiResources } from '@prorobotech/openapi-k8s-toolkit' import { useNavigate } from 'react-router-dom' import { useSelector, useDispatch } from 'react-redux' import { RootState } from 'store/store' import { setCluster } from 'store/cluster/cluster/cluster' +import { + CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP, + CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION, + CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME, +} from 'constants/customizationApiGroupAndVersion' import { Styled } from './styled' export const ListInsideClusterAndNs: FC = () => { @@ -17,11 +22,31 @@ export const ListInsideClusterAndNs: FC = () => { const [selectedCluster, setSelectedCluster] = useState() const [selectedNamespace, setSelectedNamespace] = useState() + const isCustomNamespaceResource = + CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP && + typeof CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP === 'string' && + CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP.length > 0 && + CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION && + typeof CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION === 'string' && + CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION.length > 0 && + CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME && + typeof CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME === 'string' && + CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME.length > 0 + const namespacesData = useBuiltinResources({ clusterName: selectedCluster || '', typeName: 'namespaces', limit: null, - isEnabled: selectedCluster !== undefined, + isEnabled: selectedCluster !== undefined && !isCustomNamespaceResource, + }) + + const namespacesDataCustom = useApiResources({ + clusterName: selectedCluster || '', + apiGroup: CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP, + apiVersion: CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION, + typeName: CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME, + limit: null, + isEnabled: selectedCluster !== undefined && isCustomNamespaceResource, }) return ( @@ -55,33 +80,62 @@ export const ListInsideClusterAndNs: FC = () => { /> )} - {selectedCluster && namespacesData.isPending && } - {selectedCluster && namespacesData.error && ( - + {selectedCluster && (isCustomNamespaceResource ? namespacesDataCustom.isPending : namespacesData.isPending) && ( + )} - {selectedCluster && selectedCluster.length > 0 && namespacesData.data && namespacesData.data.items.length > 0 && ( - <> - Namespace - - ({ - label: ns.metadata.name, - value: ns.metadata.name, - }))} - filterOption={filterSelectOptions} - allowClear - showSearch - onSelect={value => { - if (typeof value === 'string') { - setSelectedNamespace(value) - } - }} - onClear={() => setSelectedNamespace(undefined)} - /> - - + {selectedCluster && (isCustomNamespaceResource ? namespacesDataCustom.error : namespacesData.error) && ( + )} + {selectedCluster && + selectedCluster.length > 0 && + ((!isCustomNamespaceResource && namespacesData.data && namespacesData.data.items.length > 0) || + (isCustomNamespaceResource && namespacesDataCustom.data && namespacesDataCustom.data.items.length > 0)) && ( + <> + Namespace + + {isCustomNamespaceResource ? ( + ({ + label: ns.metadata.name, + value: ns.metadata.name, + }))} + filterOption={filterSelectOptions} + allowClear + showSearch + onSelect={value => { + if (typeof value === 'string') { + setSelectedNamespace(value) + } + }} + onClear={() => setSelectedNamespace(undefined)} + /> + ) : ( + ({ + label: ns.metadata.name, + value: ns.metadata.name, + }))} + filterOption={filterSelectOptions} + allowClear + showSearch + onSelect={value => { + if (typeof value === 'string') { + setSelectedNamespace(value) + } + }} + onClear={() => setSelectedNamespace(undefined)} + /> + )} + + + )}