ソースを参照

Format dates in a single place, Display build date instead of full commit hash in version info (#2590)

* message

* if tag contains -SNAPSHOT - display formatted timestamp

* create Time format context

* fix pull request commits

* change pull request commits

* add fetchTimeFormat function

* add fetchTimeFormat function

* chnage test run

* fix testing error

* covered global context with tests

* removed unused import statement

* fixed smell

* pull master

* fixed code smeils

* covered Version component, hooks with tests, fixed code review comments

* converted outdated to boolean

* remove tag condition from return
Hrant Abrahamyan 2 年 前
コミット
eb03a12233
32 ファイル変更504 行追加226 行削除
  1. 80 78
      kafka-ui-react-app/src/components/App.tsx
  2. 5 3
      kafka-ui-react-app/src/components/Topics/Topic/Messages/Message.tsx
  3. 10 2
      kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/MessageContent.tsx
  4. 2 1
      kafka-ui-react-app/src/components/Topics/Topic/Messages/__test__/Message.spec.tsx
  5. 30 26
      kafka-ui-react-app/src/components/Topics/Topic/Statistics/Indicators/Total.tsx
  6. 4 2
      kafka-ui-react-app/src/components/Topics/Topic/Statistics/Metrics.tsx
  7. 5 3
      kafka-ui-react-app/src/components/Topics/Topic/Statistics/PartitionInfoRow.tsx
  8. 25 23
      kafka-ui-react-app/src/components/Version/Version.tsx
  9. 94 13
      kafka-ui-react-app/src/components/Version/__tests__/Version.spec.tsx
  10. 13 0
      kafka-ui-react-app/src/components/__tests__/App.spec.tsx
  11. 2 2
      kafka-ui-react-app/src/components/contexts/ConfirmContext.tsx
  12. 30 0
      kafka-ui-react-app/src/components/contexts/GlobalSettingsContext.tsx
  13. 11 9
      kafka-ui-react-app/src/lib/api.ts
  14. 7 2
      kafka-ui-react-app/src/lib/constants.ts
  15. 1 1
      kafka-ui-react-app/src/lib/dateTimeHelpers.ts
  16. 12 0
      kafka-ui-react-app/src/lib/fixtures/actuatorInfo.ts
  17. 3 0
      kafka-ui-react-app/src/lib/fixtures/latestVersion.ts
  18. 3 0
      kafka-ui-react-app/src/lib/fixtures/timeFormat.ts
  19. 17 0
      kafka-ui-react-app/src/lib/hooks/api/__tests__/actuatorInfo.spec.ts
  20. 1 12
      kafka-ui-react-app/src/lib/hooks/api/__tests__/brokers.spec.ts
  21. 1 12
      kafka-ui-react-app/src/lib/hooks/api/__tests__/clusters.spec.ts
  22. 5 11
      kafka-ui-react-app/src/lib/hooks/api/__tests__/kafkaConnect.spec.ts
  23. 19 0
      kafka-ui-react-app/src/lib/hooks/api/__tests__/latestVersion.spec.ts
  24. 17 0
      kafka-ui-react-app/src/lib/hooks/api/__tests__/timeFormat.spec.ts
  25. 5 11
      kafka-ui-react-app/src/lib/hooks/api/__tests__/topics.spec.ts
  26. 18 0
      kafka-ui-react-app/src/lib/hooks/api/actuatorInfo.ts
  27. 1 0
      kafka-ui-react-app/src/lib/hooks/api/kafkaConnect.ts
  28. 21 0
      kafka-ui-react-app/src/lib/hooks/api/latestVersion.ts
  29. 11 0
      kafka-ui-react-app/src/lib/hooks/api/timeFormat.ts
  30. 10 0
      kafka-ui-react-app/src/lib/hooks/useTimeFormat.ts
  31. 36 15
      kafka-ui-react-app/src/lib/testHelpers.tsx
  32. 5 0
      kafka-ui-react-app/vite.config.ts

+ 80 - 78
kafka-ui-react-app/src/components/App.tsx

@@ -1,6 +1,5 @@
 import React, { Suspense, useCallback } from 'react';
 import { Routes, Route, useLocation } from 'react-router-dom';
-import { GIT_TAG, GIT_COMMIT } from 'lib/constants';
 import { clusterPath, getNonExactPath } from 'lib/paths';
 import Nav from 'components/Nav/Nav';
 import PageLoader from 'components/common/PageLoader/PageLoader';
@@ -18,8 +17,9 @@ import Logo from 'components/common/Logo/Logo';
 import GitIcon from 'components/common/Icons/GitIcon';
 import DiscordIcon from 'components/common/Icons/DiscordIcon';
 
-import { ConfirmContextProvider } from './contexts/ConfirmContext';
 import ConfirmationModal from './common/ConfirmationModal/ConfirmationModal';
+import { ConfirmContextProvider } from './contexts/ConfirmContext';
+import { GlobalSettingsProvider } from './contexts/GlobalSettingsContext';
 
 const queryClient = new QueryClient({
   defaultOptions: {
@@ -46,89 +46,91 @@ const App: React.FC = () => {
 
   return (
     <QueryClientProvider client={queryClient}>
-      <ThemeProvider theme={theme}>
-        <ConfirmContextProvider>
-          <GlobalCSS />
-          <S.Layout>
-            <S.Navbar role="navigation" aria-label="Page Header">
-              <S.NavbarBrand>
+      <GlobalSettingsProvider>
+        <ThemeProvider theme={theme}>
+          <ConfirmContextProvider>
+            <GlobalCSS />
+            <S.Layout>
+              <S.Navbar role="navigation" aria-label="Page Header">
                 <S.NavbarBrand>
-                  <S.NavbarBurger
-                    onClick={onBurgerClick}
-                    onKeyDown={onBurgerClick}
-                    role="button"
-                    tabIndex={0}
-                    aria-label="burger"
-                  >
-                    <S.Span role="separator" />
-                    <S.Span role="separator" />
-                    <S.Span role="separator" />
-                  </S.NavbarBurger>
+                  <S.NavbarBrand>
+                    <S.NavbarBurger
+                      onClick={onBurgerClick}
+                      onKeyDown={onBurgerClick}
+                      role="button"
+                      tabIndex={0}
+                      aria-label="burger"
+                    >
+                      <S.Span role="separator" />
+                      <S.Span role="separator" />
+                      <S.Span role="separator" />
+                    </S.NavbarBurger>
 
-                  <S.Hyperlink to="/">
-                    <Logo />
-                    UI for Apache Kafka
-                  </S.Hyperlink>
+                    <S.Hyperlink to="/">
+                      <Logo />
+                      UI for Apache Kafka
+                    </S.Hyperlink>
 
-                  <S.NavbarItem>
-                    {GIT_TAG && <Version tag={GIT_TAG} commit={GIT_COMMIT} />}
-                  </S.NavbarItem>
+                    <S.NavbarItem>
+                      <Version />
+                    </S.NavbarItem>
+                  </S.NavbarBrand>
                 </S.NavbarBrand>
-              </S.NavbarBrand>
-              <S.NavbarSocial>
-                <S.LogoutLink href="/logout">
-                  <S.LogoutButton buttonType="primary" buttonSize="M">
-                    Log out
-                  </S.LogoutButton>
-                </S.LogoutLink>
-                <S.SocialLink
-                  href="https://github.com/provectus/kafka-ui"
-                  target="_blank"
-                >
-                  <GitIcon />
-                </S.SocialLink>
-                <S.SocialLink
-                  href="https://discord.com/invite/4DWzD7pGE5"
-                  target="_blank"
-                >
-                  <DiscordIcon />
-                </S.SocialLink>
-              </S.NavbarSocial>
-            </S.Navbar>
+                <S.NavbarSocial>
+                  <S.LogoutLink href="/logout">
+                    <S.LogoutButton buttonType="primary" buttonSize="M">
+                      Log out
+                    </S.LogoutButton>
+                  </S.LogoutLink>
+                  <S.SocialLink
+                    href="https://github.com/provectus/kafka-ui"
+                    target="_blank"
+                  >
+                    <GitIcon />
+                  </S.SocialLink>
+                  <S.SocialLink
+                    href="https://discord.com/invite/4DWzD7pGE5"
+                    target="_blank"
+                  >
+                    <DiscordIcon />
+                  </S.SocialLink>
+                </S.NavbarSocial>
+              </S.Navbar>
 
-            <S.Container>
-              <S.Sidebar aria-label="Sidebar" $visible={isSidebarVisible}>
-                <Suspense fallback={<PageLoader />}>
-                  <Nav />
-                </Suspense>
-              </S.Sidebar>
-              <S.Overlay
-                $visible={isSidebarVisible}
-                onClick={closeSidebar}
-                onKeyDown={closeSidebar}
-                tabIndex={-1}
-                aria-hidden="true"
-                aria-label="Overlay"
-              />
-              <Routes>
-                {['/', '/ui', '/ui/clusters'].map((path) => (
+              <S.Container>
+                <S.Sidebar aria-label="Sidebar" $visible={isSidebarVisible}>
+                  <Suspense fallback={<PageLoader />}>
+                    <Nav />
+                  </Suspense>
+                </S.Sidebar>
+                <S.Overlay
+                  $visible={isSidebarVisible}
+                  onClick={closeSidebar}
+                  onKeyDown={closeSidebar}
+                  tabIndex={-1}
+                  aria-hidden="true"
+                  aria-label="Overlay"
+                />
+                <Routes>
+                  {['/', '/ui', '/ui/clusters'].map((path) => (
+                    <Route
+                      key="Home" // optional: avoid full re-renders on route changes
+                      path={path}
+                      element={<Dashboard />}
+                    />
+                  ))}
                   <Route
-                    key="Home" // optional: avoid full re-renders on route changes
-                    path={path}
-                    element={<Dashboard />}
+                    path={getNonExactPath(clusterPath())}
+                    element={<ClusterPage />}
                   />
-                ))}
-                <Route
-                  path={getNonExactPath(clusterPath())}
-                  element={<ClusterPage />}
-                />
-              </Routes>
-            </S.Container>
-            <Toaster position="bottom-right" />
-          </S.Layout>
-          <ConfirmationModal />
-        </ConfirmContextProvider>
-      </ThemeProvider>
+                </Routes>
+              </S.Container>
+              <Toaster position="bottom-right" />
+            </S.Layout>
+            <ConfirmationModal />
+          </ConfirmContextProvider>
+        </ThemeProvider>
+      </GlobalSettingsProvider>
     </QueryClientProvider>
   );
 };

+ 5 - 3
kafka-ui-react-app/src/components/Topics/Topic/Messages/Message.tsx

@@ -1,11 +1,11 @@
 import React from 'react';
-import { TopicMessage } from 'generated-sources';
+import styled from 'styled-components';
 import useDataSaver from 'lib/hooks/useDataSaver';
+import { TopicMessage } from 'generated-sources';
+import { useTimeFormat } from 'lib/hooks/useTimeFormat';
 import MessageToggleIcon from 'components/common/Icons/MessageToggleIcon';
 import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper';
-import styled from 'styled-components';
 import { Dropdown, DropdownItem } from 'components/common/Dropdown';
-import { formatTimestamp } from 'lib/dateTimeHelpers';
 
 import MessageContent from './MessageContent/MessageContent';
 import * as S from './MessageContent/MessageContent.styled';
@@ -48,6 +48,8 @@ const Message: React.FC<Props> = ({
     Headers: headers,
     Timestamp: timestamp,
   };
+  const formatTimestamp = useTimeFormat();
+
   const savedMessage = JSON.stringify(savedMessageJson, null, '\t');
   const { copyToClipboard, saveFile } = useDataSaver(
     'topic-message',

+ 10 - 2
kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/MessageContent.tsx

@@ -1,8 +1,8 @@
-import { TopicMessageTimestampTypeEnum, SchemaType } from 'generated-sources';
 import React from 'react';
 import EditorViewer from 'components/common/EditorViewer/EditorViewer';
 import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
-import { formatTimestamp } from 'lib/dateTimeHelpers';
+import { useTimeFormat } from 'lib/hooks/useTimeFormat';
+import { SchemaType, TopicMessageTimestampTypeEnum } from 'generated-sources';
 
 import * as S from './MessageContent.styled';
 
@@ -27,7 +27,10 @@ const MessageContent: React.FC<MessageContentProps> = ({
   timestamp,
   timestampType,
 }) => {
+  const formatTimestamp = useTimeFormat();
+
   const [activeTab, setActiveTab] = React.useState<Tab>('content');
+
   const activeTabContent = () => {
     switch (activeTab) {
       case 'content':
@@ -38,24 +41,29 @@ const MessageContent: React.FC<MessageContentProps> = ({
         return JSON.stringify(headers);
     }
   };
+
   const handleKeyTabClick = (e: React.MouseEvent) => {
     e.preventDefault();
     setActiveTab('key');
   };
+
   const handleContentTabClick = (e: React.MouseEvent) => {
     e.preventDefault();
     setActiveTab('content');
   };
+
   const handleHeadersTabClick = (e: React.MouseEvent) => {
     e.preventDefault();
     setActiveTab('headers');
   };
+
   const keySize = new TextEncoder().encode(messageKey).length;
   const contentSize = new TextEncoder().encode(messageContent).length;
   const contentType =
     messageContent && messageContent.trim().startsWith('{')
       ? SchemaType.JSON
       : SchemaType.PROTOBUF;
+
   return (
     <S.Wrapper>
       <td colSpan={10}>

+ 2 - 1
kafka-ui-react-app/src/components/Topics/Topic/Messages/__test__/Message.spec.tsx

@@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
 import { formatTimestamp } from 'lib/dateTimeHelpers';
 
 const messageContentText = 'messageContentText';
+const format = 'DD.MM.YYYY HH:mm:ss';
 
 jest.mock(
   'components/Topics/Topic/Messages/MessageContent/MessageContent',
@@ -48,7 +49,7 @@ describe('Message component', () => {
     expect(screen.getByText(mockMessage.content as string)).toBeInTheDocument();
     expect(screen.getByText(mockMessage.key as string)).toBeInTheDocument();
     expect(
-      screen.getByText(formatTimestamp(mockMessage.timestamp))
+      screen.getByText(formatTimestamp(mockMessage.timestamp, format))
     ).toBeInTheDocument();
     expect(screen.getByText(mockMessage.offset.toString())).toBeInTheDocument();
     expect(

+ 30 - 26
kafka-ui-react-app/src/components/Topics/Topic/Statistics/Indicators/Total.tsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import * as Metrics from 'components/common/Metrics';
+import { useTimeFormat } from 'lib/hooks/useTimeFormat';
 import { TopicAnalysisStats } from 'generated-sources';
-import { formatTimestamp } from 'lib/dateTimeHelpers';
 
 const Total: React.FC<TopicAnalysisStats> = ({
   totalMsgs,
@@ -13,30 +13,34 @@ const Total: React.FC<TopicAnalysisStats> = ({
   nullValues,
   approxUniqKeys,
   approxUniqValues,
-}) => (
-  <Metrics.Section title="Messages">
-    <Metrics.Indicator label="Total number">{totalMsgs}</Metrics.Indicator>
-    <Metrics.Indicator label="Offsets min-max">
-      {`${minOffset} - ${maxOffset}`}
-    </Metrics.Indicator>
-    <Metrics.Indicator label="Timestamp min-max">
-      {`${formatTimestamp(minTimestamp)} - ${formatTimestamp(maxTimestamp)}`}
-    </Metrics.Indicator>
-    <Metrics.Indicator label="Null keys">{nullKeys}</Metrics.Indicator>
-    <Metrics.Indicator
-      label="Unique keys"
-      title="Approximate number of unique keys"
-    >
-      {approxUniqKeys}
-    </Metrics.Indicator>
-    <Metrics.Indicator label="Null values">{nullValues}</Metrics.Indicator>
-    <Metrics.Indicator
-      label="Unique values"
-      title="Approximate number of unique values"
-    >
-      {approxUniqValues}
-    </Metrics.Indicator>
-  </Metrics.Section>
-);
+}) => {
+  const formatTimestamp = useTimeFormat();
+
+  return (
+    <Metrics.Section title="Messages">
+      <Metrics.Indicator label="Total number">{totalMsgs}</Metrics.Indicator>
+      <Metrics.Indicator label="Offsets min-max">
+        {`${minOffset} - ${maxOffset}`}
+      </Metrics.Indicator>
+      <Metrics.Indicator label="Timestamp min-max">
+        {`${formatTimestamp(minTimestamp)} - ${formatTimestamp(maxTimestamp)}`}
+      </Metrics.Indicator>
+      <Metrics.Indicator label="Null keys">{nullKeys}</Metrics.Indicator>
+      <Metrics.Indicator
+        label="Unique keys"
+        title="Approximate number of unique keys"
+      >
+        {approxUniqKeys}
+      </Metrics.Indicator>
+      <Metrics.Indicator label="Null values">{nullValues}</Metrics.Indicator>
+      <Metrics.Indicator
+        label="Unique values"
+        title="Approximate number of unique values"
+      >
+        {approxUniqValues}
+      </Metrics.Indicator>
+    </Metrics.Section>
+  );
+};
 
 export default Total;

+ 4 - 2
kafka-ui-react-app/src/components/Topics/Topic/Statistics/Metrics.tsx

@@ -14,7 +14,7 @@ import {
   Label,
 } from 'components/common/PropertiesList/PropertiesList.styled';
 import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
-import { formatTimestamp } from 'lib/dateTimeHelpers';
+import { useTimeFormat } from 'lib/hooks/useTimeFormat';
 
 import * as S from './Statistics.styles';
 import Total from './Indicators/Total';
@@ -22,6 +22,8 @@ import SizeStats from './Indicators/SizeStats';
 import PartitionTable from './PartitionTable';
 
 const Metrics: React.FC = () => {
+  const formatTimestamp = useTimeFormat();
+
   const params = useAppParams<RouteParamsClusterTopic>();
   const [isAnalyzing, setIsAnalyzing] = React.useState(true);
   const analyzeTopic = useAnalyzeTopic(params);
@@ -76,7 +78,7 @@ const Metrics: React.FC = () => {
   return (
     <>
       <S.ActionsBar>
-        <S.CreatedAt>{formatTimestamp(data.result.finishedAt)}</S.CreatedAt>
+        <S.CreatedAt>{formatTimestamp(data?.result?.finishedAt)}</S.CreatedAt>
         <Button
           onClick={async () => {
             await analyzeTopic.mutateAsync();

+ 5 - 3
kafka-ui-react-app/src/components/Topics/Topic/Statistics/PartitionInfoRow.tsx

@@ -1,19 +1,21 @@
+import React from 'react';
 import { Row } from '@tanstack/react-table';
-import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
 import Heading from 'components/common/heading/Heading.styled';
+import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
 import {
   List,
   Label,
 } from 'components/common/PropertiesList/PropertiesList.styled';
+import { useTimeFormat } from 'lib/hooks/useTimeFormat';
 import { TopicAnalysisStats } from 'generated-sources';
-import { formatTimestamp } from 'lib/dateTimeHelpers';
-import React from 'react';
 
 import * as S from './Statistics.styles';
 
 const PartitionInfoRow: React.FC<{ row: Row<TopicAnalysisStats> }> = ({
   row,
 }) => {
+  const formatTimestamp = useTimeFormat();
+
   const {
     totalMsgs,
     minTimestamp,

+ 25 - 23
kafka-ui-react-app/src/components/Version/Version.tsx

@@ -1,7 +1,10 @@
-import React, { useEffect, useState } from 'react';
-import { gitCommitPath } from 'lib/paths';
-import { GIT_REPO_LATEST_RELEASE_LINK } from 'lib/constants';
+import React from 'react';
 import WarningIcon from 'components/common/Icons/WarningIcon';
+import { gitCommitPath } from 'lib/paths';
+import { useTimeFormat } from 'lib/hooks/useTimeFormat';
+import { useActuatorInfo } from 'lib/hooks/api/actuatorInfo';
+import { BUILD_VERSION_PATTERN } from 'lib/constants';
+import { useLatestVersion } from 'lib/hooks/api/latestVersion';
 
 import * as S from './Version.styled';
 import compareVersions from './compareVersions';
@@ -11,29 +14,28 @@ export interface VesionProps {
   commit?: string;
 }
 
-const Version: React.FC<VesionProps> = ({ tag, commit }) => {
-  const [latestVersionInfo, setLatestVersionInfo] = useState({
-    outdated: false,
-    latestTag: '',
-  });
-
-  useEffect(() => {
-    fetch(GIT_REPO_LATEST_RELEASE_LINK)
-      .then((response) => response.json())
-      .then((data) => {
-        setLatestVersionInfo({
-          outdated: compareVersions(tag, data.tag_name) === -1,
-          latestTag: data.tag_name,
-        });
-      });
-  }, [tag]);
-
-  const { outdated, latestTag } = latestVersionInfo;
+const Version: React.FC = () => {
+  const formatTimestamp = useTimeFormat();
+  const { data: actuatorInfo = {} } = useActuatorInfo();
+  const { data: latestVersionInfo = {} } = useLatestVersion();
+
+  const tag = actuatorInfo?.build?.version;
+  const commit = actuatorInfo?.git?.commit.id;
+  const { tag_name: latestTag } = latestVersionInfo;
+
+  const outdated = compareVersions(tag, latestTag);
+
+  const currentVersion = tag?.match(BUILD_VERSION_PATTERN)
+    ? tag
+    : formatTimestamp(actuatorInfo?.build?.time);
+
+  if (!tag) return null;
+
   return (
     <S.Wrapper>
-      <S.CurrentVersion>{tag}</S.CurrentVersion>
+      <S.CurrentVersion>{currentVersion}</S.CurrentVersion>
 
-      {outdated && (
+      {!!outdated && (
         <S.OutdatedWarning
           title={`Your app version is outdated. Current latest version is ${latestTag}`}
         >

+ 94 - 13
kafka-ui-react-app/src/components/Version/__tests__/Version.spec.tsx

@@ -1,22 +1,103 @@
 import React from 'react';
-import Version, { VesionProps } from 'components/Version/Version';
-import { screen } from '@testing-library/react';
+import { screen } from '@testing-library/dom';
+import Version from 'components/Version/Version';
 import { render } from 'lib/testHelpers';
+import { formatTimestamp } from 'lib/dateTimeHelpers';
+import { useTimeFormat } from 'lib/hooks/api/timeFormat';
+import { useActuatorInfo } from 'lib/hooks/api/actuatorInfo';
+import { useLatestVersion } from 'lib/hooks/api/latestVersion';
+import { actuatorInfoPayload } from 'lib/fixtures/actuatorInfo';
+import { latestVersionPayload } from 'lib/fixtures/latestVersion';
+import { defaultGlobalSettingsValue } from 'components/contexts/GlobalSettingsContext';
 
-const tag = 'v1.0.1-SHAPSHOT';
-const commit = '123sdf34';
+jest.mock('lib/hooks/api/timeFormat', () => ({
+  useTimeFormat: jest.fn(),
+}));
+jest.mock('lib/hooks/api/actuatorInfo', () => ({
+  useActuatorInfo: jest.fn(),
+}));
+jest.mock('lib/hooks/api/latestVersion', () => ({
+  useLatestVersion: jest.fn(),
+}));
 
-describe('Version', () => {
-  const setupComponent = (props: VesionProps) => render(<Version {...props} />);
+describe('Version Component', () => {
+  const { timeStampFormat } = defaultGlobalSettingsValue;
 
-  it('renders', () => {
-    setupComponent({ tag });
-    expect(screen.getByText(tag)).toBeInTheDocument();
+  const versionTag = 'v0.5.0';
+  const snapshotTag = 'test-SNAPSHOT';
+  const commitTag = 'befd3b328e2c9c7df57b0c5746561b2f7fee8813';
+
+  const actuatorVersionPayload = actuatorInfoPayload(versionTag);
+  const formattedTimestamp = formatTimestamp(
+    actuatorVersionPayload.build.time,
+    timeStampFormat
+  );
+
+  beforeEach(() => {
+    (useTimeFormat as jest.Mock).mockImplementation(() => ({
+      data: {
+        timeStampFormat,
+      },
+    }));
+    (useActuatorInfo as jest.Mock).mockImplementation(() => ({
+      data: actuatorVersionPayload,
+    }));
+    (useLatestVersion as jest.Mock).mockImplementation(() => ({
+      data: latestVersionPayload,
+    }));
+  });
+
+  describe('tag does not exist', () => {
+    it('does not render component', async () => {
+      (useActuatorInfo as jest.Mock).mockImplementation(() => ({
+        data: null,
+      }));
+      const { container } = render(<Version />);
+      expect(container.firstChild).toBeEmptyDOMElement();
+    });
+  });
+
+  describe('renders current version', () => {
+    it('renders release build version as current version', async () => {
+      render(<Version />);
+      expect(screen.getByText(versionTag)).toBeInTheDocument();
+    });
+    it('renders formatted timestamp as current version when version is commit', async () => {
+      (useActuatorInfo as jest.Mock).mockImplementation(() => ({
+        data: actuatorInfoPayload(commitTag),
+      }));
+      render(<Version />);
+      expect(screen.getByText(formattedTimestamp)).toBeInTheDocument();
+    });
+    it('renders formatted timestamp as current version when version contains -SNAPSHOT', async () => {
+      (useActuatorInfo as jest.Mock).mockImplementation(() => ({
+        data: actuatorInfoPayload(snapshotTag),
+      }));
+      render(<Version />);
+      expect(screen.getByText(formattedTimestamp)).toBeInTheDocument();
+    });
+  });
+
+  describe('outdated build version', () => {
+    it('renders warning message', async () => {
+      (useActuatorInfo as jest.Mock).mockImplementation(() => ({
+        data: actuatorInfoPayload('v0.3.0'),
+      }));
+      render(<Version />);
+      expect(
+        screen.getByTitle(
+          `Your app version is outdated. Current latest version is ${latestVersionPayload.tag_name}`
+        )
+      ).toBeInTheDocument();
+    });
   });
 
-  it('shows current tag and commit', () => {
-    setupComponent({ tag, commit });
-    expect(screen.getByText(tag)).toBeInTheDocument();
-    expect(screen.getByText(commit)).toBeInTheDocument();
+  describe('current commit id with link', () => {
+    it('renders', async () => {
+      render(<Version />);
+      expect(
+        screen.getByText(actuatorVersionPayload.git.commit.id)
+      ).toBeInTheDocument();
+    });
   });
 });

+ 13 - 0
kafka-ui-react-app/src/components/__tests__/App.spec.tsx

@@ -3,14 +3,27 @@ import { screen, within } from '@testing-library/react';
 import App from 'components/App';
 import { render } from 'lib/testHelpers';
 import userEvent from '@testing-library/user-event';
+import { useTimeFormat } from 'lib/hooks/api/timeFormat';
+import { defaultGlobalSettingsValue } from 'components/contexts/GlobalSettingsContext';
 
 const burgerButtonOptions = { name: 'burger' };
 const logoutButtonOptions = { name: 'Log out' };
 
 jest.mock('components/Nav/Nav', () => () => <div>Navigation</div>);
 
+jest.mock('components/Version/Version', () => () => <div>Version</div>);
+
+jest.mock('lib/hooks/api/timeFormat', () => ({
+  ...jest.requireActual('lib/hooks/api/timeFormat'),
+  useTimeFormat: jest.fn(),
+}));
+
 describe('App', () => {
   beforeEach(() => {
+    (useTimeFormat as jest.Mock).mockImplementation(() => ({
+      data: defaultGlobalSettingsValue,
+    }));
+
     render(<App />, {
       initialEntries: ['/'],
     });

+ 2 - 2
kafka-ui-react-app/src/components/contexts/ConfirmContext.tsx

@@ -1,12 +1,12 @@
 import React, { useState } from 'react';
 
-type ConfirmContextType = {
+interface ConfirmContextType {
   content: React.ReactNode;
   confirm?: () => void;
   setContent: React.Dispatch<React.SetStateAction<React.ReactNode>>;
   setConfirm: React.Dispatch<React.SetStateAction<(() => void) | undefined>>;
   cancel: () => void;
-};
+}
 
 export const ConfirmContext = React.createContext<ConfirmContextType | null>(
   null

+ 30 - 0
kafka-ui-react-app/src/components/contexts/GlobalSettingsContext.tsx

@@ -0,0 +1,30 @@
+import React from 'react';
+import { useTimeFormat } from 'lib/hooks/api/timeFormat';
+
+interface GlobalSettingsContextValue {
+  timeStampFormat: string;
+}
+
+export const defaultGlobalSettingsValue = {
+  timeStampFormat: 'DD.MM.YYYY HH:mm:ss',
+};
+
+export const GlobalSettingsContext =
+  React.createContext<GlobalSettingsContextValue>(defaultGlobalSettingsValue);
+
+export const GlobalSettingsProvider: React.FC<
+  React.PropsWithChildren<unknown>
+> = ({ children }) => {
+  const { data } = useTimeFormat();
+
+  return (
+    <GlobalSettingsContext.Provider
+      value={{
+        timeStampFormat:
+          data?.timeStampFormat || defaultGlobalSettingsValue.timeStampFormat,
+      }}
+    >
+      {children}
+    </GlobalSettingsContext.Provider>
+  );
+};

+ 11 - 9
kafka-ui-react-app/src/lib/api.ts

@@ -1,23 +1,25 @@
 import {
+  KsqlApi,
+  TopicsApi,
+  SchemasApi,
   BrokersApi,
+  MessagesApi,
   ClustersApi,
   Configuration,
-  ConsumerGroupsApi,
   KafkaConnectApi,
-  KsqlApi,
-  MessagesApi,
-  SchemasApi,
-  TopicsApi,
+  ConsumerGroupsApi,
+  TimeStampFormatApi,
 } from 'generated-sources';
 import { BASE_PARAMS } from 'lib/constants';
 
 const apiClientConf = new Configuration(BASE_PARAMS);
 
+export const ksqlDbApiClient = new KsqlApi(apiClientConf);
+export const topicsApiClient = new TopicsApi(apiClientConf);
 export const brokersApiClient = new BrokersApi(apiClientConf);
+export const schemasApiClient = new SchemasApi(apiClientConf);
+export const timerStampFormatApiClient = new TimeStampFormatApi(apiClientConf);
+export const messagesApiClient = new MessagesApi(apiClientConf);
 export const clustersApiClient = new ClustersApi(apiClientConf);
 export const kafkaConnectApiClient = new KafkaConnectApi(apiClientConf);
 export const consumerGroupsApiClient = new ConsumerGroupsApi(apiClientConf);
-export const ksqlDbApiClient = new KsqlApi(apiClientConf);
-export const topicsApiClient = new TopicsApi(apiClientConf);
-export const messagesApiClient = new MessagesApi(apiClientConf);
-export const schemasApiClient = new SchemasApi(apiClientConf);

+ 7 - 2
kafka-ui-react-app/src/lib/constants.ts

@@ -49,6 +49,7 @@ export const MILLISECONDS_IN_SECOND = 1_000;
 
 export const NOT_SET = -1;
 export const BYTES_IN_GB = 1_073_741_824;
+export const BUILD_VERSION_PATTERN = /v\d.\d.\d/;
 
 export const PER_PAGE = 25;
 export const MESSAGES_PER_PAGE = '100';
@@ -56,8 +57,6 @@ export const MESSAGES_PER_PAGE = '100';
 export const GIT_REPO_LINK = 'https://github.com/provectus/kafka-ui';
 export const GIT_REPO_LATEST_RELEASE_LINK =
   'https://api.github.com/repos/provectus/kafka-ui/releases/latest';
-export const GIT_TAG = process.env.VITE_TAG;
-export const GIT_COMMIT = process.env.VITE_COMMIT;
 
 export const LOCAL_STORAGE_KEY_PREFIX = 'kafka-ui';
 
@@ -67,3 +66,9 @@ export enum AsyncRequestStatus {
   fulfilled = 'fulfilled',
   rejected = 'rejected',
 }
+
+export const QUERY_REFETCH_OFF_OPTIONS = {
+  refetchOnMount: false,
+  refetchOnWindowFocus: false,
+  refetchIntervalInBackground: false,
+};

+ 1 - 1
kafka-ui-react-app/src/lib/dateTimeHelpers.ts

@@ -2,7 +2,7 @@ import dayjs from 'dayjs';
 
 export const formatTimestamp = (
   timestamp: number | string | Date | undefined,
-  format = 'MM.DD.YY hh:mm:ss a'
+  format?: string
 ): string => {
   if (!timestamp) {
     return '';

+ 12 - 0
kafka-ui-react-app/src/lib/fixtures/actuatorInfo.ts

@@ -0,0 +1,12 @@
+export const actuatorInfoPayload = (
+  version = 'befd3b328e2c9c7df57b0c5746561b2f7fee8813'
+) => ({
+  git: { commit: { id: 'befd3b3' } },
+  build: {
+    artifact: 'kafka-ui-api',
+    name: 'kafka-ui-api',
+    time: '2022-09-15T09:52:21.753Z',
+    version,
+    group: 'com.provectus',
+  },
+});

+ 3 - 0
kafka-ui-react-app/src/lib/fixtures/latestVersion.ts

@@ -0,0 +1,3 @@
+export const latestVersionPayload = {
+  tag_name: 'v0.4.0',
+};

+ 3 - 0
kafka-ui-react-app/src/lib/fixtures/timeFormat.ts

@@ -0,0 +1,3 @@
+export const timeFormatPayload = {
+  timeStampFormat: 'dd.MM.YYYY HH:mm:ss',
+};

+ 17 - 0
kafka-ui-react-app/src/lib/hooks/api/__tests__/actuatorInfo.spec.ts

@@ -0,0 +1,17 @@
+import fetchMock from 'fetch-mock';
+import * as hooks from 'lib/hooks/api/actuatorInfo';
+import { expectQueryWorks, renderQueryHook } from 'lib/testHelpers';
+import { actuatorInfoPayload } from 'lib/fixtures/actuatorInfo';
+
+const actuatorInfoPath = '/actuator/info';
+
+describe('Actuator info hooks', () => {
+  beforeEach(() => fetchMock.restore());
+  describe('useActuatorInfo', () => {
+    it('returns the correct data', async () => {
+      const mock = fetchMock.getOnce(actuatorInfoPath, actuatorInfoPayload());
+      const { result } = renderQueryHook(() => hooks.useActuatorInfo());
+      await expectQueryWorks(mock, result);
+    });
+  });
+});

+ 1 - 12
kafka-ui-react-app/src/lib/hooks/api/__tests__/brokers.spec.ts

@@ -1,23 +1,12 @@
-import { waitFor } from '@testing-library/react';
-import { renderQueryHook } from 'lib/testHelpers';
+import { expectQueryWorks, renderQueryHook } from 'lib/testHelpers';
 import * as hooks from 'lib/hooks/api/brokers';
 import fetchMock from 'fetch-mock';
-import { UseQueryResult } from '@tanstack/react-query';
 
 const clusterName = 'test-cluster';
 const brokerId = 1;
 const brokersPath = `/api/clusters/${clusterName}/brokers`;
 const brokerPath = `${brokersPath}/${brokerId}`;
 
-const expectQueryWorks = async (
-  mock: fetchMock.FetchMockStatic,
-  result: { current: UseQueryResult<unknown, unknown> }
-) => {
-  await waitFor(() => expect(result.current.isFetched).toBeTruthy());
-  expect(mock.calls()).toHaveLength(1);
-  expect(result.current.data).toBeDefined();
-};
-
 describe('Brokers hooks', () => {
   beforeEach(() => fetchMock.restore());
   describe('useBrokers', () => {

+ 1 - 12
kafka-ui-react-app/src/lib/hooks/api/__tests__/clusters.spec.ts

@@ -1,21 +1,10 @@
-import { waitFor } from '@testing-library/react';
-import { renderQueryHook } from 'lib/testHelpers';
+import { expectQueryWorks, renderQueryHook } from 'lib/testHelpers';
 import * as hooks from 'lib/hooks/api/clusters';
 import fetchMock from 'fetch-mock';
-import { UseQueryResult } from '@tanstack/react-query';
 import { clustersPayload } from 'lib/fixtures/clusters';
 
 const clusterName = 'test-cluster';
 
-const expectQueryWorks = async (
-  mock: fetchMock.FetchMockStatic,
-  result: { current: UseQueryResult<unknown, unknown> }
-) => {
-  await waitFor(() => expect(result.current.isFetched).toBeTruthy());
-  expect(mock.calls()).toHaveLength(1);
-  expect(result.current.data).toBeDefined();
-};
-
 describe('Clusters hooks', () => {
   beforeEach(() => fetchMock.restore());
   describe('useClusters', () => {

+ 5 - 11
kafka-ui-react-app/src/lib/hooks/api/__tests__/kafkaConnect.spec.ts

@@ -1,9 +1,12 @@
 import { act, renderHook, waitFor } from '@testing-library/react';
-import { renderQueryHook, TestQueryClientProvider } from 'lib/testHelpers';
+import {
+  expectQueryWorks,
+  renderQueryHook,
+  TestQueryClientProvider,
+} from 'lib/testHelpers';
 import * as hooks from 'lib/hooks/api/kafkaConnect';
 import fetchMock from 'fetch-mock';
 import { connectors, connects, tasks } from 'lib/fixtures/kafkaConnect';
-import { UseQueryResult } from '@tanstack/react-query';
 import { ConnectorAction } from 'generated-sources';
 
 const clusterName = 'test-cluster';
@@ -20,15 +23,6 @@ const connectorProps = {
   connectorName,
 };
 
-const expectQueryWorks = async (
-  mock: fetchMock.FetchMockStatic,
-  result: { current: UseQueryResult<unknown, unknown> }
-) => {
-  await waitFor(() => expect(result.current.isFetched).toBeTruthy());
-  expect(mock.calls()).toHaveLength(1);
-  expect(result.current.data).toBeDefined();
-};
-
 describe('kafkaConnect hooks', () => {
   beforeEach(() => fetchMock.restore());
   describe('useConnects', () => {

+ 19 - 0
kafka-ui-react-app/src/lib/hooks/api/__tests__/latestVersion.spec.ts

@@ -0,0 +1,19 @@
+import fetchMock from 'fetch-mock';
+import { expectQueryWorks, renderQueryHook } from 'lib/testHelpers';
+import * as hooks from 'lib/hooks/api/latestVersion';
+import { GIT_REPO_LATEST_RELEASE_LINK } from 'lib/constants';
+import { latestVersionPayload } from 'lib/fixtures/latestVersion';
+
+describe('Latest version hooks', () => {
+  beforeEach(() => fetchMock.restore());
+  describe('useLatestVersion', () => {
+    it('returns the correct data', async () => {
+      const mock = fetchMock.getOnce(
+        GIT_REPO_LATEST_RELEASE_LINK,
+        latestVersionPayload
+      );
+      const { result } = renderQueryHook(() => hooks.useLatestVersion());
+      await expectQueryWorks(mock, result);
+    });
+  });
+});

+ 17 - 0
kafka-ui-react-app/src/lib/hooks/api/__tests__/timeFormat.spec.ts

@@ -0,0 +1,17 @@
+import fetchMock from 'fetch-mock';
+import { expectQueryWorks, renderQueryHook } from 'lib/testHelpers';
+import * as hooks from 'lib/hooks/api/timeFormat';
+import { timeFormatPayload } from 'lib/fixtures/timeFormat';
+
+const timeFormatPath = '/api/info/timestampformat';
+
+describe('Time format hooks', () => {
+  beforeEach(() => fetchMock.restore());
+  describe('useTimeFormat', () => {
+    it('returns the correct data', async () => {
+      const mock = fetchMock.getOnce(timeFormatPath, timeFormatPayload);
+      const { result } = renderQueryHook(() => hooks.useTimeFormat());
+      await expectQueryWorks(mock, result);
+    });
+  });
+});

+ 5 - 11
kafka-ui-react-app/src/lib/hooks/api/__tests__/topics.spec.ts

@@ -1,8 +1,11 @@
 import { act, renderHook, waitFor } from '@testing-library/react';
-import { renderQueryHook, TestQueryClientProvider } from 'lib/testHelpers';
+import {
+  expectQueryWorks,
+  renderQueryHook,
+  TestQueryClientProvider,
+} from 'lib/testHelpers';
 import * as hooks from 'lib/hooks/api/topics';
 import fetchMock from 'fetch-mock';
-import { UseQueryResult } from '@tanstack/react-query';
 import { externalTopicPayload, topicConfigPayload } from 'lib/fixtures/topics';
 import { TopicFormData, TopicFormDataRaw } from 'redux/interfaces';
 import { CreateTopicMessage } from 'generated-sources';
@@ -10,15 +13,6 @@ import { CreateTopicMessage } from 'generated-sources';
 const clusterName = 'test-cluster';
 const topicName = 'test-topic';
 
-const expectQueryWorks = async (
-  mock: fetchMock.FetchMockStatic,
-  result: { current: UseQueryResult<unknown, unknown> }
-) => {
-  await waitFor(() => expect(result.current.isFetched).toBeTruthy());
-  expect(mock.calls()).toHaveLength(1);
-  expect(result.current.data).toBeDefined();
-};
-
 const topicsPath = `/api/clusters/${clusterName}/topics`;
 const topicPath = `${topicsPath}/${topicName}`;
 

+ 18 - 0
kafka-ui-react-app/src/lib/hooks/api/actuatorInfo.ts

@@ -0,0 +1,18 @@
+import { useQuery } from '@tanstack/react-query';
+import { BASE_PARAMS, QUERY_REFETCH_OFF_OPTIONS } from 'lib/constants';
+
+const fetchActuatorInfo = async () => {
+  const data = await fetch('/actuator/info', BASE_PARAMS).then((res) =>
+    res.json()
+  );
+
+  return data;
+};
+
+export function useActuatorInfo() {
+  return useQuery(
+    ['actuatorInfo'],
+    fetchActuatorInfo,
+    QUERY_REFETCH_OFF_OPTIONS
+  );
+}

+ 1 - 0
kafka-ui-react-app/src/lib/hooks/api/kafkaConnect.ts

@@ -121,6 +121,7 @@ export function useCreateConnector(clusterName: ClusterName) {
 }
 export function useDeleteConnector(props: UseConnectorProps) {
   const client = useQueryClient();
+
   return useMutation(() => api.deleteConnector(props), {
     onSuccess: () => client.invalidateQueries(connectorsKey(props.clusterName)),
   });

+ 21 - 0
kafka-ui-react-app/src/lib/hooks/api/latestVersion.ts

@@ -0,0 +1,21 @@
+import { useQuery } from '@tanstack/react-query';
+import {
+  QUERY_REFETCH_OFF_OPTIONS,
+  GIT_REPO_LATEST_RELEASE_LINK,
+} from 'lib/constants';
+
+const fetchLatestVersion = async () => {
+  const data = await fetch(GIT_REPO_LATEST_RELEASE_LINK).then((res) =>
+    res.json()
+  );
+
+  return data;
+};
+
+export function useLatestVersion() {
+  return useQuery(
+    ['latestVersion'],
+    fetchLatestVersion,
+    QUERY_REFETCH_OFF_OPTIONS
+  );
+}

+ 11 - 0
kafka-ui-react-app/src/lib/hooks/api/timeFormat.ts

@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query';
+import { timerStampFormatApiClient as api } from 'lib/api';
+import { QUERY_REFETCH_OFF_OPTIONS } from 'lib/constants';
+
+export function useTimeFormat() {
+  return useQuery(
+    ['settings', 'timestampformat'],
+    () => api.getTimeStampFormat(),
+    QUERY_REFETCH_OFF_OPTIONS
+  );
+}

+ 10 - 0
kafka-ui-react-app/src/lib/hooks/useTimeFormat.ts

@@ -0,0 +1,10 @@
+import { useContext } from 'react';
+import { formatTimestamp } from 'lib/dateTimeHelpers';
+import { GlobalSettingsContext } from 'components/contexts/GlobalSettingsContext';
+
+export const useTimeFormat = () => {
+  const { timeStampFormat } = useContext(GlobalSettingsContext);
+
+  return (timestamp?: number | string | Date, format?: string) =>
+    formatTimestamp(timestamp, format || timeStampFormat);
+};

+ 36 - 15
kafka-ui-react-app/src/lib/testHelpers.tsx

@@ -5,10 +5,16 @@ import {
   Route,
   Routes,
 } from 'react-router-dom';
+import fetchMock from 'fetch-mock';
 import { Provider } from 'react-redux';
 import { ThemeProvider } from 'styled-components';
 import theme from 'theme/theme';
-import { render, renderHook, RenderOptions } from '@testing-library/react';
+import {
+  render,
+  renderHook,
+  RenderOptions,
+  waitFor,
+} from '@testing-library/react';
 import { AnyAction, Store } from 'redux';
 import { RootState } from 'redux/interfaces';
 import { configureStore } from '@reduxjs/toolkit';
@@ -20,6 +26,10 @@ import {
 } from '@tanstack/react-query';
 import { ConfirmContextProvider } from 'components/contexts/ConfirmContext';
 import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
+import {
+  defaultGlobalSettingsValue,
+  GlobalSettingsContext,
+} from 'components/contexts/GlobalSettingsContext';
 
 interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
   preloadedState?: Partial<RootState>;
@@ -32,6 +42,15 @@ interface WithRouteProps {
   path: string;
 }
 
+export const expectQueryWorks = async (
+  mock: fetchMock.FetchMockStatic,
+  result: { current: UseQueryResult<unknown, unknown> }
+) => {
+  await waitFor(() => expect(result.current.isFetched).toBeTruthy());
+  expect(mock.calls()).toHaveLength(1);
+  expect(result.current.data).toBeDefined();
+};
+
 export const WithRoute: React.FC<WithRouteProps> = ({ children, path }) => {
   return (
     <Routes>
@@ -68,20 +87,22 @@ const customRender = (
   const AllTheProviders: React.FC<PropsWithChildren<unknown>> = ({
     children,
   }) => (
-    <ThemeProvider theme={theme}>
-      <ConfirmContextProvider>
-        <Provider store={store}>
-          <TestQueryClientProvider>
-            <MemoryRouter initialEntries={initialEntries}>
-              <div>
-                {children}
-                <ConfirmationModal />
-              </div>
-            </MemoryRouter>
-          </TestQueryClientProvider>
-        </Provider>
-      </ConfirmContextProvider>
-    </ThemeProvider>
+    <TestQueryClientProvider>
+      <GlobalSettingsContext.Provider value={defaultGlobalSettingsValue}>
+        <ThemeProvider theme={theme}>
+          <ConfirmContextProvider>
+            <Provider store={store}>
+              <MemoryRouter initialEntries={initialEntries}>
+                <div>
+                  {children}
+                  <ConfirmationModal />
+                </div>
+              </MemoryRouter>
+            </Provider>
+          </ConfirmContextProvider>
+        </ThemeProvider>
+      </GlobalSettingsContext.Provider>
+    </TestQueryClientProvider>
   );
   return render(ui, { wrapper: AllTheProviders, ...renderOptions });
 };

+ 5 - 0
kafka-ui-react-app/vite.config.ts

@@ -38,6 +38,11 @@ export default defineConfig(({ mode }) => {
             changeOrigin: true,
             secure: false,
           },
+          '/actuator/info': {
+            target: proxy,
+            changeOrigin: true,
+            secure: false,
+          },
         },
       },
     };