瀏覽代碼

Issue#207 Requests to KsqlDb from UI (#786)

* Add ksql page

* Add streams to table

Add tables and streams count

* Add query execution modal

* Add tabs tests

* Adjust SQLEditor test

* Remove unused command

* Add QueryModal tests

* Remove excess box wrapper

* Add KsqlDb component tests

* Refactor SQLEditor displayName

* Refactor setIsShown naming

* Add empty placeholder for table

* Refactor use shortcut for Dictionary interface

* Refactor raw data to object transformation

* Add KsqlDb List component tests

* Remove excess import

* Add ksqlDb reducer tests

* Add ksqlDb fetch tables error alert

* Refactor remove code smell

* Add ksqlDb actions tests

* Add KsqlDb List component test for non-empty store

* Add streamProperties JSON field

* Adjust QueryModal test to new field

* Fix query with empty streamProperties

* Refactor query to modal to page form

* Add expanding to tables and streams table

* Refactor ksql execution to redux store

* Add Query component tests

* Add Breadcrumbs component tests

* Add ksqldb reducer tests

* Add result renderer test

* Add ksqlDb thunk tests

* Resolve PR comments
Azat Belgibayev 3 年之前
父節點
當前提交
03ed67db89
共有 37 個文件被更改,包括 1426 次插入0 次删除
  1. 6 0
      kafka-ui-react-app/src/components/Cluster/Cluster.tsx
  2. 30 0
      kafka-ui-react-app/src/components/KsqlDb/BreadCrumbs/BreadCrumbs.tsx
  3. 33 0
      kafka-ui-react-app/src/components/KsqlDb/BreadCrumbs/__test__/BreadCrumbs.spec.tsx
  4. 22 0
      kafka-ui-react-app/src/components/KsqlDb/KsqlDb.tsx
  5. 93 0
      kafka-ui-react-app/src/components/KsqlDb/List/List.tsx
  6. 40 0
      kafka-ui-react-app/src/components/KsqlDb/List/ListItem.tsx
  7. 63 0
      kafka-ui-react-app/src/components/KsqlDb/List/__test__/List.spec.tsx
  8. 114 0
      kafka-ui-react-app/src/components/KsqlDb/Query/Query.tsx
  9. 61 0
      kafka-ui-react-app/src/components/KsqlDb/Query/ResultRenderer.tsx
  10. 67 0
      kafka-ui-react-app/src/components/KsqlDb/Query/__test__/Query.spec.tsx
  11. 14 0
      kafka-ui-react-app/src/components/KsqlDb/Query/__test__/ResultRenderer.spec.tsx
  12. 7 0
      kafka-ui-react-app/src/components/KsqlDb/Query/__test__/__snapshots__/ResultRenderer.spec.tsx.snap
  13. 20 0
      kafka-ui-react-app/src/components/KsqlDb/__test__/KsqlDb.spec.tsx
  14. 43 0
      kafka-ui-react-app/src/components/KsqlDb/__test__/__snapshots__/KsqlDb.spec.tsx.snap
  15. 12 0
      kafka-ui-react-app/src/components/Nav/ClusterMenu.tsx
  16. 34 0
      kafka-ui-react-app/src/components/common/SQLEditor/SQLEditor.tsx
  17. 20 0
      kafka-ui-react-app/src/components/common/SQLEditor/__tests__/SQLEditor.spec.tsx
  18. 126 0
      kafka-ui-react-app/src/components/common/SQLEditor/__tests__/__snapshots__/SQLEditor.spec.tsx.snap
  19. 55 0
      kafka-ui-react-app/src/components/common/Tabs/Tabs.tsx
  20. 43 0
      kafka-ui-react-app/src/components/common/Tabs/__tests__/Tabs.spec.tsx
  21. 55 0
      kafka-ui-react-app/src/components/common/Tabs/__tests__/__snapshots__/Tabs.spec.tsx.snap
  22. 1 0
      kafka-ui-react-app/src/custom.d.ts
  23. 8 0
      kafka-ui-react-app/src/lib/paths.ts
  24. 23 0
      kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts
  25. 53 0
      kafka-ui-react-app/src/redux/actions/__test__/thunks/ksqlDb.spec.ts
  26. 22 0
      kafka-ui-react-app/src/redux/actions/actions.ts
  27. 1 0
      kafka-ui-react-app/src/redux/actions/thunks/index.ts
  28. 85 0
      kafka-ui-react-app/src/redux/actions/thunks/ksqlDb.ts
  29. 2 0
      kafka-ui-react-app/src/redux/interfaces/index.ts
  30. 14 0
      kafka-ui-react-app/src/redux/interfaces/ksqlDb.ts
  31. 2 0
      kafka-ui-react-app/src/redux/reducers/index.ts
  32. 51 0
      kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/__snapshots__/reducer.spec.ts.snap
  33. 65 0
      kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/fixtures.ts
  34. 35 0
      kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/reducer.spec.ts
  35. 40 0
      kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/selectors.spec.ts
  36. 34 0
      kafka-ui-react-app/src/redux/reducers/ksqlDb/reducer.ts
  37. 32 0
      kafka-ui-react-app/src/redux/reducers/ksqlDb/selectors.ts

+ 6 - 0
kafka-ui-react-app/src/components/Cluster/Cluster.tsx

@@ -11,6 +11,7 @@ import {
   clusterConnectorsPath,
   clusterConnectsPath,
   clusterConsumerGroupsPath,
+  clusterKsqlDbPath,
   clusterSchemasPath,
   clusterTopicsPath,
 } from 'lib/paths';
@@ -20,6 +21,7 @@ import Connect from 'components/Connect/Connect';
 import ClusterContext from 'components/contexts/ClusterContext';
 import BrokersContainer from 'components/Brokers/BrokersContainer';
 import ConsumersGroupsContainer from 'components/ConsumerGroups/ConsumersGroupsContainer';
+import KsqlDb from 'components/KsqlDb/KsqlDb';
 
 const Cluster: React.FC = () => {
   const { clusterName } = useParams<{ clusterName: string }>();
@@ -32,6 +34,7 @@ const Cluster: React.FC = () => {
   const hasSchemaRegistryConfigured = features.includes(
     ClusterFeaturesEnum.SCHEMA_REGISTRY
   );
+  const hasKsqlDbConfigured = features.includes(ClusterFeaturesEnum.KSQL_DB);
 
   const contextValue = React.useMemo(
     () => ({
@@ -72,6 +75,9 @@ const Cluster: React.FC = () => {
             component={Connect}
           />
         )}
+        {hasKsqlDbConfigured && (
+          <Route path={clusterKsqlDbPath(':clusterName')} component={KsqlDb} />
+        )}
         <Redirect
           from="/ui/clusters/:clusterName"
           to="/ui/clusters/:clusterName/brokers"

+ 30 - 0
kafka-ui-react-app/src/components/KsqlDb/BreadCrumbs/BreadCrumbs.tsx

@@ -0,0 +1,30 @@
+import React from 'react';
+import Breadcrumb, {
+  BreadcrumbItem,
+} from 'components/common/Breadcrumb/Breadcrumb';
+import { clusterKsqlDbPath, clusterKsqlDbQueryPath } from 'lib/paths';
+import { useParams, useRouteMatch } from 'react-router';
+
+interface RouteParams {
+  clusterName: string;
+}
+
+const Breadcrumbs: React.FC = () => {
+  const { clusterName } = useParams<RouteParams>();
+  const isQuery = useRouteMatch(clusterKsqlDbQueryPath(clusterName));
+
+  if (!isQuery) {
+    return <Breadcrumb>KSQLDB</Breadcrumb>;
+  }
+
+  const links: BreadcrumbItem[] = [
+    {
+      label: 'KSQLDB',
+      href: clusterKsqlDbPath(clusterName),
+    },
+  ];
+
+  return <Breadcrumb links={links}>Query</Breadcrumb>;
+};
+
+export default Breadcrumbs;

+ 33 - 0
kafka-ui-react-app/src/components/KsqlDb/BreadCrumbs/__test__/BreadCrumbs.spec.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+import { StaticRouter } from 'react-router';
+import Breadcrumbs from 'components/KsqlDb/BreadCrumbs/BreadCrumbs';
+import { mount } from 'enzyme';
+import { clusterKsqlDbPath, clusterKsqlDbQueryPath } from 'lib/paths';
+
+describe('BreadCrumbs', () => {
+  const clusterName = 'local';
+  const rootPathname = clusterKsqlDbPath(clusterName);
+  const queryPathname = clusterKsqlDbQueryPath(clusterName);
+
+  const setupComponent = (pathname: string) => (
+    <StaticRouter location={{ pathname }} context={{}}>
+      <Breadcrumbs />
+    </StaticRouter>
+  );
+
+  it('Renders root path', () => {
+    const component = mount(setupComponent(rootPathname));
+
+    expect(component.find({ children: 'KSQLDB' }).exists()).toBeTruthy();
+    expect(component.find({ children: 'Query' }).exists()).toBeFalsy();
+  });
+
+  it('Renders query path', () => {
+    const component = mount(setupComponent(queryPathname));
+
+    expect(
+      component.find('a').find({ children: 'KSQLDB' }).exists()
+    ).toBeTruthy();
+    expect(component.find({ children: 'Query' }).exists()).toBeTruthy();
+  });
+});

+ 22 - 0
kafka-ui-react-app/src/components/KsqlDb/KsqlDb.tsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import { Switch, Route } from 'react-router-dom';
+import { clusterKsqlDbPath, clusterKsqlDbQueryPath } from 'lib/paths';
+import List from 'components/KsqlDb/List/List';
+import Query from 'components/KsqlDb/Query/Query';
+import Breadcrumbs from 'components/KsqlDb/BreadCrumbs/BreadCrumbs';
+
+const KsqlDb: React.FC = () => {
+  return (
+    <div className="section">
+      <Switch>
+        <Route path={clusterKsqlDbPath()} component={Breadcrumbs} />
+      </Switch>
+      <Switch>
+        <Route exact path={clusterKsqlDbPath()} component={List} />
+        <Route exact path={clusterKsqlDbQueryPath()} component={Query} />
+      </Switch>
+    </div>
+  );
+};
+
+export default KsqlDb;

+ 93 - 0
kafka-ui-react-app/src/components/KsqlDb/List/List.tsx

@@ -0,0 +1,93 @@
+import Indicator from 'components/common/Dashboard/Indicator';
+import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
+import PageLoader from 'components/common/PageLoader/PageLoader';
+import ListItem from 'components/KsqlDb/List/ListItem';
+import React, { FC, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useParams } from 'react-router';
+import { fetchKsqlDbTables } from 'redux/actions/thunks/ksqlDb';
+import { getKsqlDbTables } from 'redux/reducers/ksqlDb/selectors';
+import { Link } from 'react-router-dom';
+import { clusterKsqlDbQueryPath } from 'lib/paths';
+
+const headers = [
+  { Header: 'Type', accessor: 'type' },
+  { Header: 'Name', accessor: 'name' },
+  { Header: 'Topic', accessor: 'topic' },
+  { Header: 'Key Format', accessor: 'keyFormat' },
+  { Header: 'Value Format', accessor: 'valueFormat' },
+];
+
+const accessors = headers.map((header) => header.accessor);
+
+const List: FC = () => {
+  const dispatch = useDispatch();
+
+  const { clusterName } = useParams<{ clusterName: string }>();
+
+  const { rows, fetching, tablesCount, streamsCount } =
+    useSelector(getKsqlDbTables);
+
+  useEffect(() => {
+    dispatch(fetchKsqlDbTables(clusterName));
+  }, []);
+
+  return (
+    <>
+      <MetricsWrapper wrapperClassName="is-justify-content-space-between">
+        <div className="column is-flex m-0 p-0">
+          <Indicator
+            className="level-left is-one-third mr-3"
+            label="Tables"
+            title="Tables"
+            fetching={fetching}
+          >
+            {tablesCount}
+          </Indicator>
+          <Indicator
+            className="level-left is-one-third ml-3"
+            label="Streams"
+            title="Streams"
+            fetching={fetching}
+          >
+            {streamsCount}
+          </Indicator>
+        </div>
+        <Link
+          to={clusterKsqlDbQueryPath(clusterName)}
+          className="button is-primary"
+        >
+          Execute ksql
+        </Link>
+      </MetricsWrapper>
+      <div className="box">
+        {fetching ? (
+          <PageLoader />
+        ) : (
+          <table className="table is-fullwidth">
+            <thead>
+              <tr>
+                <th> </th>
+                {headers.map(({ Header, accessor }) => (
+                  <th key={accessor}>{Header}</th>
+                ))}
+              </tr>
+            </thead>
+            <tbody>
+              {rows.map((row) => (
+                <ListItem key={row.name} accessors={accessors} data={row} />
+              ))}
+              {rows.length === 0 && (
+                <tr>
+                  <td colSpan={headers.length}>No tables or streams found</td>
+                </tr>
+              )}
+            </tbody>
+          </table>
+        )}
+      </div>
+    </>
+  );
+};
+
+export default List;

+ 40 - 0
kafka-ui-react-app/src/components/KsqlDb/List/ListItem.tsx

@@ -0,0 +1,40 @@
+import React from 'react';
+
+interface Props {
+  accessors: string[];
+  data: Record<string, string>;
+}
+
+const ListItem: React.FC<Props> = ({ accessors, data }) => {
+  const [isOpen, setIsOpen] = React.useState(false);
+
+  const toggleIsOpen = React.useCallback(() => {
+    setIsOpen((prevState) => !prevState);
+  }, []);
+
+  return (
+    <>
+      <tr>
+        <td>
+          <span
+            className="icon has-text-link is-size-7 is-small is-clickable"
+            onClick={toggleIsOpen}
+            aria-hidden
+          >
+            <i className={`fas fa-${isOpen ? 'minus' : 'plus'}`} />
+          </span>
+        </td>
+        {accessors.map((accessor) => (
+          <td key={accessor}>{data[accessor]}</td>
+        ))}
+      </tr>
+      {isOpen && (
+        <tr>
+          <td colSpan={accessors.length + 1}>Expanding content</td>
+        </tr>
+      )}
+    </>
+  );
+};
+
+export default ListItem;

+ 63 - 0
kafka-ui-react-app/src/components/KsqlDb/List/__test__/List.spec.tsx

@@ -0,0 +1,63 @@
+import React from 'react';
+import List from 'components/KsqlDb/List/List';
+import { mount } from 'enzyme';
+import { StaticRouter } from 'react-router';
+import configureStore from 'redux-mock-store';
+import { Provider } from 'react-redux';
+import { RootState } from 'redux/interfaces';
+import { fetchKsqlDbTablesPayload } from 'redux/reducers/ksqlDb/__test__/fixtures';
+
+const emptyPlaceholder = 'No tables or streams found';
+
+const mockStore = configureStore();
+
+describe('KsqlDb List', () => {
+  const pathname = `ui/clusters/local/ksql-db`;
+
+  it('Renders placeholder on empty data', () => {
+    const initialState: Partial<RootState> = {
+      ksqlDb: {
+        tables: [],
+        streams: [],
+        executionResult: null,
+      },
+      loader: {
+        GET_KSQL_DB_TABLES_AND_STREAMS: 'fetched',
+      },
+    };
+    const store = mockStore(initialState);
+
+    const component = mount(
+      <StaticRouter location={{ pathname }} context={{}}>
+        <Provider store={store}>
+          <List />
+        </Provider>
+      </StaticRouter>
+    );
+
+    expect(
+      component.find({ children: emptyPlaceholder }).exists()
+    ).toBeTruthy();
+  });
+
+  it('Renders rows', () => {
+    const initialState: Partial<RootState> = {
+      ksqlDb: { ...fetchKsqlDbTablesPayload, executionResult: null },
+      loader: {
+        GET_KSQL_DB_TABLES_AND_STREAMS: 'fetched',
+      },
+    };
+    const store = mockStore(initialState);
+
+    const component = mount(
+      <StaticRouter location={{ pathname }} context={{}}>
+        <Provider store={store}>
+          <List />
+        </Provider>
+      </StaticRouter>
+    );
+
+    // 2 streams, 2 tables and 1 head tr
+    expect(component.find('tr').length).toEqual(5);
+  });
+});

+ 114 - 0
kafka-ui-react-app/src/components/KsqlDb/Query/Query.tsx

@@ -0,0 +1,114 @@
+import React, { useCallback, useEffect, FC } from 'react';
+import { yupResolver } from '@hookform/resolvers/yup';
+import JSONEditor from 'components/common/JSONEditor/JSONEditor';
+import SQLEditor from 'components/common/SQLEditor/SQLEditor';
+import yup from 'lib/yupExtended';
+import { useForm, Controller } from 'react-hook-form';
+import { useParams } from 'react-router';
+import { executeKsql } from 'redux/actions/thunks/ksqlDb';
+import ResultRenderer from 'components/KsqlDb/Query/ResultRenderer';
+import { useDispatch, useSelector } from 'react-redux';
+import { getKsqlExecution } from 'redux/reducers/ksqlDb/selectors';
+import { resetExecutionResult } from 'redux/actions';
+
+type FormValues = {
+  ksql: string;
+  streamsProperties: string;
+};
+
+const validationSchema = yup.object({
+  ksql: yup.string().trim().required(),
+});
+
+const Query: FC = () => {
+  const { clusterName } = useParams<{ clusterName: string }>();
+  const dispatch = useDispatch();
+
+  const { executionResult, fetching } = useSelector(getKsqlExecution);
+
+  const reset = useCallback(() => {
+    dispatch(resetExecutionResult());
+  }, [dispatch]);
+
+  useEffect(() => {
+    return reset;
+  }, []);
+
+  const { handleSubmit, control } = useForm<FormValues>({
+    mode: 'onTouched',
+    resolver: yupResolver(validationSchema),
+    defaultValues: {
+      ksql: '',
+      streamsProperties: '',
+    },
+  });
+
+  const submitHandler = useCallback(async (values: FormValues) => {
+    dispatch(
+      executeKsql({
+        clusterName,
+        ksqlCommand: {
+          ...values,
+          streamsProperties: values.streamsProperties
+            ? JSON.parse(values.streamsProperties)
+            : undefined,
+        },
+      })
+    );
+  }, []);
+
+  return (
+    <>
+      <div className="box">
+        <form onSubmit={handleSubmit(submitHandler)}>
+          <div className="columns">
+            <div className="control column m-0">
+              <label className="label">KSQL</label>
+              <Controller
+                control={control}
+                name="ksql"
+                render={({ field }) => (
+                  <SQLEditor {...field} readOnly={fetching} />
+                )}
+              />
+            </div>
+            <div className="control column m-0">
+              <label className="label">Stream properties</label>
+              <Controller
+                control={control}
+                name="streamsProperties"
+                render={({ field }) => (
+                  <JSONEditor {...field} readOnly={fetching} />
+                )}
+              />
+            </div>
+          </div>
+          <div className="columns">
+            <div className="column is-flex-grow-0">
+              <button
+                className="button is-primary"
+                type="submit"
+                disabled={fetching}
+              >
+                Execute
+              </button>
+            </div>
+            <div className="column is-flex-grow-0">
+              <button
+                className="button is-danger"
+                type="button"
+                disabled={!executionResult}
+                onClick={reset}
+              >
+                Clear
+              </button>
+            </div>
+          </div>
+        </form>
+      </div>
+      <ResultRenderer result={executionResult} />
+    </>
+  );
+};
+
+export default Query;

+ 61 - 0
kafka-ui-react-app/src/components/KsqlDb/Query/ResultRenderer.tsx

@@ -0,0 +1,61 @@
+import React from 'react';
+import { KsqlCommandResponse, Table } from 'generated-sources';
+
+const ResultRenderer: React.FC<{ result: KsqlCommandResponse | null }> = ({
+  result,
+}) => {
+  if (!result) return null;
+
+  const isMessage = !!result.message;
+
+  if (isMessage) return <div className="box">{result.message}</div>;
+
+  const isTable = result.data !== undefined;
+
+  if (!isTable) return null;
+
+  const rawTable = result.data as Table;
+
+  const { headers, rows } = rawTable;
+
+  const transformedRows = React.useMemo(
+    () =>
+      rows.map((row) =>
+        row.reduce((res, acc, index) => {
+          res[rawTable.headers[index]] = acc;
+          return res;
+        }, {} as Dictionary<string>)
+      ),
+    []
+  );
+
+  return (
+    <div className="box">
+      <table className="table is-fullwidth">
+        <thead>
+          <tr>
+            {headers.map((header) => (
+              <th key={header}>{header}</th>
+            ))}
+          </tr>
+        </thead>
+        <tbody>
+          {transformedRows.map((row) => (
+            <tr key={row.name}>
+              {headers.map((header) => (
+                <td key={header}>{row[header]}</td>
+              ))}
+            </tr>
+          ))}
+          {rows.length === 0 && (
+            <tr>
+              <td colSpan={headers.length}>No tables or streams found</td>
+            </tr>
+          )}
+        </tbody>
+      </table>
+    </div>
+  );
+};
+
+export default ResultRenderer;

+ 67 - 0
kafka-ui-react-app/src/components/KsqlDb/Query/__test__/Query.spec.tsx

@@ -0,0 +1,67 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import Query from 'components/KsqlDb/Query/Query';
+import { StaticRouter } from 'react-router';
+import configureStore from 'redux-mock-store';
+import { RootState } from 'redux/interfaces';
+import { ksqlCommandResponse } from 'redux/reducers/ksqlDb/__test__/fixtures';
+import { Provider } from 'react-redux';
+
+const mockStore = configureStore();
+
+describe('KsqlDb Query Component', () => {
+  const pathname = `ui/clusters/local/ksql-db/query`;
+
+  it('Renders result', () => {
+    const initialState: Partial<RootState> = {
+      ksqlDb: {
+        streams: [],
+        tables: [],
+        executionResult: ksqlCommandResponse,
+      },
+      loader: {
+        EXECUTE_KSQL: 'fetched',
+      },
+    };
+    const store = mockStore(initialState);
+
+    const component = mount(
+      <StaticRouter location={{ pathname }} context={{}}>
+        <Provider store={store}>
+          <Query />
+        </Provider>
+      </StaticRouter>
+    );
+
+    // 2 streams and 1 head tr
+    expect(component.find('tr').length).toEqual(3);
+  });
+
+  it('Renders result message', () => {
+    const initialState: Partial<RootState> = {
+      ksqlDb: {
+        streams: [],
+        tables: [],
+        executionResult: {
+          message: 'No available data',
+        },
+      },
+      loader: {
+        EXECUTE_KSQL: 'fetched',
+      },
+    };
+    const store = mockStore(initialState);
+
+    const component = mount(
+      <StaticRouter location={{ pathname }} context={{}}>
+        <Provider store={store}>
+          <Query />
+        </Provider>
+      </StaticRouter>
+    );
+
+    expect(
+      component.find({ children: 'No available data' }).exists()
+    ).toBeTruthy();
+  });
+});

+ 14 - 0
kafka-ui-react-app/src/components/KsqlDb/Query/__test__/ResultRenderer.spec.tsx

@@ -0,0 +1,14 @@
+import { mount } from 'enzyme';
+import { KsqlCommandResponse } from 'generated-sources';
+import React from 'react';
+import ResultRenderer from 'components/KsqlDb/Query/ResultRenderer';
+
+describe('Result Renderer', () => {
+  const result: KsqlCommandResponse = {};
+
+  it('Matches snapshot', () => {
+    const component = mount(<ResultRenderer result={result} />);
+
+    expect(component).toMatchSnapshot();
+  });
+});

+ 7 - 0
kafka-ui-react-app/src/components/KsqlDb/Query/__test__/__snapshots__/ResultRenderer.spec.tsx.snap

@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Result Renderer Matches snapshot 1`] = `
+<ResultRenderer
+  result={Object {}}
+/>
+`;

+ 20 - 0
kafka-ui-react-app/src/components/KsqlDb/__test__/KsqlDb.spec.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import KsqlDb from 'components/KsqlDb/KsqlDb';
+import { StaticRouter } from 'react-router';
+
+describe('KsqlDb Component', () => {
+  const pathname = `ui/clusters/local/ksql-db`;
+
+  describe('KsqlDb', () => {
+    const setupComponent = () => (
+      <StaticRouter location={{ pathname }} context={{}}>
+        <KsqlDb />
+      </StaticRouter>
+    );
+
+    it('matches snapshot', () => {
+      expect(mount(setupComponent())).toMatchSnapshot();
+    });
+  });
+});

+ 43 - 0
kafka-ui-react-app/src/components/KsqlDb/__test__/__snapshots__/KsqlDb.spec.tsx.snap

@@ -0,0 +1,43 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`KsqlDb Component KsqlDb matches snapshot 1`] = `
+<StaticRouter
+  context={Object {}}
+  location={
+    Object {
+      "pathname": "ui/clusters/local/ksql-db",
+    }
+  }
+>
+  <Router
+    history={
+      Object {
+        "action": "POP",
+        "block": [Function],
+        "createHref": [Function],
+        "go": [Function],
+        "goBack": [Function],
+        "goForward": [Function],
+        "listen": [Function],
+        "location": Object {
+          "hash": "",
+          "pathname": "ui/clusters/local/ksql-db",
+          "search": "",
+        },
+        "push": [Function],
+        "replace": [Function],
+      }
+    }
+    staticContext={Object {}}
+  >
+    <KsqlDb>
+      <div
+        className="section"
+      >
+        <Switch />
+        <Switch />
+      </div>
+    </KsqlDb>
+  </Router>
+</StaticRouter>
+`;

+ 12 - 0
kafka-ui-react-app/src/components/Nav/ClusterMenu.tsx

@@ -8,6 +8,7 @@ import {
   clusterSchemasPath,
   clusterConnectorsPath,
   clusterConnectsPath,
+  clusterKsqlDbPath,
 } from 'lib/paths';
 
 import DefaultClusterIcon from './DefaultClusterIcon';
@@ -92,6 +93,17 @@ const ClusterMenu: React.FC<Props> = ({
               </NavLink>
             </li>
           )}
+          {hasFeatureConfigured(ClusterFeaturesEnum.KSQL_DB) && (
+            <li>
+              <NavLink
+                to={clusterKsqlDbPath(name)}
+                activeClassName="is-active"
+                title="KSQL DB"
+              >
+                KSQL DB
+              </NavLink>
+            </li>
+          )}
         </ul>
       </li>
     </ul>

+ 34 - 0
kafka-ui-react-app/src/components/common/SQLEditor/SQLEditor.tsx

@@ -0,0 +1,34 @@
+/* eslint-disable react/jsx-props-no-spreading */
+import AceEditor, { IAceEditorProps } from 'react-ace';
+import 'ace-builds/src-noconflict/mode-sql';
+import 'ace-builds/src-noconflict/theme-textmate';
+import React from 'react';
+import ReactAce from 'react-ace/lib/ace';
+
+interface SQLEditorProps extends IAceEditorProps {
+  isFixedHeight?: boolean;
+}
+
+const SQLEditor = React.forwardRef<ReactAce | null, SQLEditorProps>(
+  (props, ref) => {
+    const { isFixedHeight, ...rest } = props;
+    return (
+      <AceEditor
+        ref={ref}
+        mode="sql"
+        theme="textmate"
+        tabSize={2}
+        width="100%"
+        height={
+          isFixedHeight
+            ? `${(props.value?.split('\n').length || 32) * 16}px`
+            : '500px'
+        }
+        wrapEnabled
+        {...rest}
+      />
+    );
+  }
+);
+
+export default SQLEditor;

+ 20 - 0
kafka-ui-react-app/src/components/common/SQLEditor/__tests__/SQLEditor.spec.tsx

@@ -0,0 +1,20 @@
+import { shallow } from 'enzyme';
+import React from 'react';
+import SQLEditor from 'components/common/SQLEditor/SQLEditor';
+
+describe('JSONEditor component', () => {
+  it('matches the snapshot', () => {
+    const component = shallow(<SQLEditor value="" name="name" />);
+    expect(component).toMatchSnapshot();
+  });
+
+  it('matches the snapshot with fixed height', () => {
+    const component = shallow(<SQLEditor value="" name="name" isFixedHeight />);
+    expect(component).toMatchSnapshot();
+  });
+
+  it('matches the snapshot with fixed height with no value', () => {
+    const component = shallow(<SQLEditor name="name" isFixedHeight />);
+    expect(component).toMatchSnapshot();
+  });
+});

+ 126 - 0
kafka-ui-react-app/src/components/common/SQLEditor/__tests__/__snapshots__/SQLEditor.spec.tsx.snap

@@ -0,0 +1,126 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`JSONEditor component matches the snapshot 1`] = `
+<ReactAce
+  cursorStart={1}
+  editorProps={Object {}}
+  enableBasicAutocompletion={false}
+  enableLiveAutocompletion={false}
+  enableSnippets={false}
+  focus={false}
+  fontSize={12}
+  height="500px"
+  highlightActiveLine={true}
+  maxLines={null}
+  minLines={null}
+  mode="sql"
+  name="name"
+  navigateToFileEnd={true}
+  onChange={null}
+  onLoad={null}
+  onPaste={null}
+  onScroll={null}
+  placeholder={null}
+  readOnly={false}
+  scrollMargin={
+    Array [
+      0,
+      0,
+      0,
+      0,
+    ]
+  }
+  setOptions={Object {}}
+  showGutter={true}
+  showPrintMargin={true}
+  style={Object {}}
+  tabSize={2}
+  theme="textmate"
+  value=""
+  width="100%"
+  wrapEnabled={true}
+/>
+`;
+
+exports[`JSONEditor component matches the snapshot with fixed height 1`] = `
+<ReactAce
+  cursorStart={1}
+  editorProps={Object {}}
+  enableBasicAutocompletion={false}
+  enableLiveAutocompletion={false}
+  enableSnippets={false}
+  focus={false}
+  fontSize={12}
+  height="16px"
+  highlightActiveLine={true}
+  maxLines={null}
+  minLines={null}
+  mode="sql"
+  name="name"
+  navigateToFileEnd={true}
+  onChange={null}
+  onLoad={null}
+  onPaste={null}
+  onScroll={null}
+  placeholder={null}
+  readOnly={false}
+  scrollMargin={
+    Array [
+      0,
+      0,
+      0,
+      0,
+    ]
+  }
+  setOptions={Object {}}
+  showGutter={true}
+  showPrintMargin={true}
+  style={Object {}}
+  tabSize={2}
+  theme="textmate"
+  value=""
+  width="100%"
+  wrapEnabled={true}
+/>
+`;
+
+exports[`JSONEditor component matches the snapshot with fixed height with no value 1`] = `
+<ReactAce
+  cursorStart={1}
+  editorProps={Object {}}
+  enableBasicAutocompletion={false}
+  enableLiveAutocompletion={false}
+  enableSnippets={false}
+  focus={false}
+  fontSize={12}
+  height="512px"
+  highlightActiveLine={true}
+  maxLines={null}
+  minLines={null}
+  mode="sql"
+  name="name"
+  navigateToFileEnd={true}
+  onChange={null}
+  onLoad={null}
+  onPaste={null}
+  onScroll={null}
+  placeholder={null}
+  readOnly={false}
+  scrollMargin={
+    Array [
+      0,
+      0,
+      0,
+      0,
+    ]
+  }
+  setOptions={Object {}}
+  showGutter={true}
+  showPrintMargin={true}
+  style={Object {}}
+  tabSize={2}
+  theme="textmate"
+  width="100%"
+  wrapEnabled={true}
+/>
+`;

+ 55 - 0
kafka-ui-react-app/src/components/common/Tabs/Tabs.tsx

@@ -0,0 +1,55 @@
+/* eslint-disable jsx-a11y/anchor-is-valid */
+import React from 'react';
+import classNames from 'classnames';
+
+interface TabsProps {
+  tabs: string[];
+  defaultSelectedIndex?: number;
+  onChange?(index: number): void;
+}
+
+const Tabs: React.FC<TabsProps> = ({
+  tabs,
+  defaultSelectedIndex = 0,
+  onChange,
+  children,
+}) => {
+  const [selectedIndex, setSelectedIndex] =
+    React.useState(defaultSelectedIndex);
+
+  React.useEffect(() => {
+    setSelectedIndex(defaultSelectedIndex);
+  }, [defaultSelectedIndex]);
+
+  const handleChange = React.useCallback((index: number) => {
+    setSelectedIndex(index);
+    onChange?.(index);
+  }, []);
+
+  return (
+    <>
+      <div className="tabs">
+        <ul>
+          {tabs.map((tab, index) => (
+            <li
+              key={tab}
+              className={classNames({ 'is-active': index === selectedIndex })}
+            >
+              <a
+                role="button"
+                tabIndex={index}
+                onClick={() => handleChange(index)}
+                onKeyDown={() => handleChange(index)}
+              >
+                {tab}
+              </a>
+            </li>
+          ))}
+        </ul>
+      </div>
+      {React.Children.toArray(children)[selectedIndex]}
+    </>
+  );
+};
+
+export default Tabs;

+ 43 - 0
kafka-ui-react-app/src/components/common/Tabs/__tests__/Tabs.spec.tsx

@@ -0,0 +1,43 @@
+import { mount, shallow } from 'enzyme';
+import React from 'react';
+import Tabs from 'components/common/Tabs/Tabs';
+
+describe('Tabs component', () => {
+  const tabs: string[] = ['Tab 1', 'Tab 2', 'Tab 3'];
+
+  const child1 = <div className="child_1" />;
+  const child2 = <div className="child_2" />;
+  const child3 = <div className="child_3" />;
+
+  const component = mount(
+    <Tabs tabs={tabs}>
+      {child1}
+      {child2}
+      {child3}
+    </Tabs>
+  );
+
+  it('renders the tabs with default index 0', () =>
+    expect(component.find(`li`).at(0).hasClass('is-active')).toBeTruthy());
+  it('renders the list of tabs', () => {
+    component.find(`a`).forEach((link, idx) => {
+      expect(link.contains(tabs[idx])).toBeTruthy();
+    });
+  });
+  it('renders the children', () => {
+    component.find(`a`).forEach((link, idx) => {
+      link.simulate('click');
+      expect(component.find(`.child_${idx + 1}`).exists()).toBeTruthy();
+    });
+  });
+  it('matches the snapshot', () => {
+    const shallowComponent = shallow(
+      <Tabs tabs={tabs}>
+        {child1}
+        {child2}
+        {child3}
+      </Tabs>
+    );
+    expect(shallowComponent).toMatchSnapshot();
+  });
+});

+ 55 - 0
kafka-ui-react-app/src/components/common/Tabs/__tests__/__snapshots__/Tabs.spec.tsx.snap

@@ -0,0 +1,55 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Tabs component matches the snapshot 1`] = `
+<Fragment>
+  <div
+    className="tabs"
+  >
+    <ul>
+      <li
+        className="is-active"
+        key="Tab 1"
+      >
+        <a
+          onClick={[Function]}
+          onKeyDown={[Function]}
+          role="button"
+          tabIndex={0}
+        >
+          Tab 1
+        </a>
+      </li>
+      <li
+        className=""
+        key="Tab 2"
+      >
+        <a
+          onClick={[Function]}
+          onKeyDown={[Function]}
+          role="button"
+          tabIndex={1}
+        >
+          Tab 2
+        </a>
+      </li>
+      <li
+        className=""
+        key="Tab 3"
+      >
+        <a
+          onClick={[Function]}
+          onKeyDown={[Function]}
+          role="button"
+          tabIndex={2}
+        >
+          Tab 3
+        </a>
+      </li>
+    </ul>
+  </div>
+  <div
+    className="child_1"
+    key=".0"
+  />
+</Fragment>
+`;

+ 1 - 0
kafka-ui-react-app/src/custom.d.ts

@@ -0,0 +1 @@
+type Dictionary<T> = Record<string, T>;

+ 8 - 0
kafka-ui-react-app/src/lib/paths.ts

@@ -119,3 +119,11 @@ export const clusterConnectConnectorConfigPath = (
     connectName,
     connectorName
   )}/config`;
+
+// KsqlDb
+export const clusterKsqlDbPath = (clusterName: ClusterName = ':clusterName') =>
+  `${clusterPath(clusterName)}/ksql-db`;
+
+export const clusterKsqlDbQueryPath = (
+  clusterName: ClusterName = ':clusterName'
+) => `${clusterPath(clusterName)}/ksql-db/query`;

+ 23 - 0
kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts

@@ -13,6 +13,7 @@ import {
   topicMessagePayload,
   topicMessagesMetaPayload,
 } from 'redux/reducers/topicMessages/__test__/fixtures';
+import { fetchKsqlDbTablesPayload } from 'redux/reducers/ksqlDb/__test__/fixtures';
 
 import { mockTopicsState } from './fixtures';
 
@@ -326,3 +327,25 @@ describe('Actions', () => {
     });
   });
 });
+
+describe('ksqlDb', () => {
+  it('creates GET_KSQL_DB_TABLES_AND_STREAMS__REQUEST', () => {
+    expect(actions.fetchKsqlDbTablesAction.request()).toEqual({
+      type: 'GET_KSQL_DB_TABLES_AND_STREAMS__REQUEST',
+    });
+  });
+  it('creates GET_KSQL_DB_TABLES_AND_STREAMS__SUCCESS', () => {
+    expect(
+      actions.fetchKsqlDbTablesAction.success(fetchKsqlDbTablesPayload)
+    ).toEqual({
+      type: 'GET_KSQL_DB_TABLES_AND_STREAMS__SUCCESS',
+      payload: fetchKsqlDbTablesPayload,
+    });
+  });
+  it('creates GET_KSQL_DB_TABLES_AND_STREAMS__FAILURE', () => {
+    expect(actions.fetchKsqlDbTablesAction.failure({})).toEqual({
+      type: 'GET_KSQL_DB_TABLES_AND_STREAMS__FAILURE',
+      payload: {},
+    });
+  });
+});

+ 53 - 0
kafka-ui-react-app/src/redux/actions/__test__/thunks/ksqlDb.spec.ts

@@ -0,0 +1,53 @@
+import fetchMock from 'fetch-mock-jest';
+import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
+import * as thunks from 'redux/actions/thunks';
+import * as actions from 'redux/actions';
+import { ksqlCommandResponse } from 'redux/reducers/ksqlDb/__test__/fixtures';
+import { transformKsqlResponse } from 'redux/actions/thunks';
+
+const store = mockStoreCreator;
+const clusterName = 'local';
+
+describe('Thunks', () => {
+  afterEach(() => {
+    fetchMock.restore();
+    store.clearActions();
+  });
+
+  describe('fetchKsqlDbTables', () => {
+    it('creates GET_KSQL_DB_TABLES_AND_STREAMS__SUCCESS when fetching streams', async () => {
+      fetchMock.post(`/api/clusters/${clusterName}/ksql`, ksqlCommandResponse);
+
+      await store.dispatch(thunks.fetchKsqlDbTables(clusterName));
+
+      expect(store.getActions()).toEqual([
+        actions.fetchKsqlDbTablesAction.request(),
+        actions.fetchKsqlDbTablesAction.success({
+          streams: transformKsqlResponse(ksqlCommandResponse.data),
+          tables: transformKsqlResponse(ksqlCommandResponse.data),
+        }),
+      ]);
+    });
+
+    it('creates GET_KSQL_DB_TABLES_AND_STREAMS__FAILURE', async () => {
+      fetchMock.post(`/api/clusters/${clusterName}/ksql`, 422);
+
+      await store.dispatch(thunks.fetchKsqlDbTables(clusterName));
+
+      expect(store.getActions()).toEqual([
+        actions.fetchKsqlDbTablesAction.request(),
+        actions.fetchKsqlDbTablesAction.failure({
+          alert: {
+            subject: 'ksqlDb',
+            title: 'Failed to fetch tables and streams',
+            response: {
+              status: 422,
+              statusText: 'Unprocessable Entity',
+              body: undefined,
+            },
+          },
+        }),
+      ]);
+    });
+  });
+});

+ 22 - 0
kafka-ui-react-app/src/redux/actions/actions.ts

@@ -25,6 +25,7 @@ import {
   TopicMessage,
   TopicMessageConsuming,
   TopicMessageSchema,
+  KsqlCommandResponse,
 } from 'generated-sources';
 
 export const fetchClusterStatsAction = createAsyncAction(
@@ -295,6 +296,27 @@ export const updateTopicReplicationFactorAction = createAsyncAction(
   'UPDATE_REPLICATION_FACTOR__FAILURE'
 )<undefined, undefined, { alert?: FailurePayload }>();
 
+export const fetchKsqlDbTablesAction = createAsyncAction(
+  'GET_KSQL_DB_TABLES_AND_STREAMS__REQUEST',
+  'GET_KSQL_DB_TABLES_AND_STREAMS__SUCCESS',
+  'GET_KSQL_DB_TABLES_AND_STREAMS__FAILURE'
+)<
+  undefined,
+  {
+    tables: Dictionary<string>[];
+    streams: Dictionary<string>[];
+  },
+  { alert?: FailurePayload }
+>();
+
+export const executeKsqlAction = createAsyncAction(
+  'EXECUTE_KSQL__REQUEST',
+  'EXECUTE_KSQL__SUCCESS',
+  'EXECUTE_KSQL__FAILURE'
+)<undefined, KsqlCommandResponse, { alert?: FailurePayload }>();
+
+export const resetExecutionResult = createAction('RESET_EXECUTE_KSQL')();
+
 export const resetConsumerGroupOffsetsAction = createAsyncAction(
   'RESET_OFFSETS__REQUEST',
   'RESET_OFFSETS__SUCCESS',

+ 1 - 0
kafka-ui-react-app/src/redux/actions/thunks/index.ts

@@ -4,3 +4,4 @@ export * from './consumerGroups';
 export * from './schemas';
 export * from './topics';
 export * from './connectors';
+export * from './ksqlDb';

+ 85 - 0
kafka-ui-react-app/src/redux/actions/thunks/ksqlDb.ts

@@ -0,0 +1,85 @@
+import {
+  Configuration,
+  ExecuteKsqlCommandRequest,
+  KsqlApi,
+  Table as KsqlTable,
+} from 'generated-sources';
+import {
+  PromiseThunkResult,
+  ClusterName,
+  FailurePayload,
+} from 'redux/interfaces';
+import { BASE_PARAMS } from 'lib/constants';
+import * as actions from 'redux/actions/actions';
+import { getResponse } from 'lib/errorHandling';
+
+const apiClientConf = new Configuration(BASE_PARAMS);
+export const ksqlDbApiClient = new KsqlApi(apiClientConf);
+
+export const transformKsqlResponse = (
+  rawTable: Required<KsqlTable>
+): Dictionary<string>[] =>
+  rawTable.rows.map((row) =>
+    row.reduce((res, acc, index) => {
+      res[rawTable.headers[index]] = acc;
+      return res;
+    }, {} as Dictionary<string>)
+  );
+
+const getTables = (clusterName: ClusterName) =>
+  ksqlDbApiClient.executeKsqlCommand({
+    clusterName,
+    ksqlCommand: { ksql: 'SHOW TABLES;' },
+  });
+
+const getStreams = (clusterName: ClusterName) =>
+  ksqlDbApiClient.executeKsqlCommand({
+    clusterName,
+    ksqlCommand: { ksql: 'SHOW STREAMS;' },
+  });
+
+export const fetchKsqlDbTables =
+  (clusterName: ClusterName): PromiseThunkResult =>
+  async (dispatch) => {
+    dispatch(actions.fetchKsqlDbTablesAction.request());
+    try {
+      const tables = await getTables(clusterName);
+      const streams = await getStreams(clusterName);
+
+      dispatch(
+        actions.fetchKsqlDbTablesAction.success({
+          tables: tables.data ? transformKsqlResponse(tables.data) : [],
+          streams: streams.data ? transformKsqlResponse(streams.data) : [],
+        })
+      );
+    } catch (error) {
+      const response = await getResponse(error);
+      const alert: FailurePayload = {
+        subject: 'ksqlDb',
+        title: `Failed to fetch tables and streams`,
+        response,
+      };
+
+      dispatch(actions.fetchKsqlDbTablesAction.failure({ alert }));
+    }
+  };
+
+export const executeKsql =
+  (params: ExecuteKsqlCommandRequest): PromiseThunkResult =>
+  async (dispatch) => {
+    dispatch(actions.executeKsqlAction.request());
+    try {
+      const response = await ksqlDbApiClient.executeKsqlCommand(params);
+
+      dispatch(actions.executeKsqlAction.success(response));
+    } catch (error) {
+      const response = await getResponse(error);
+      const alert: FailurePayload = {
+        subject: 'ksql execution',
+        title: `Failed to execute command ${params.ksqlCommand?.ksql}`,
+        response,
+      };
+
+      dispatch(actions.executeKsqlAction.failure({ alert }));
+    }
+  };

+ 2 - 0
kafka-ui-react-app/src/redux/interfaces/index.ts

@@ -10,6 +10,7 @@ import { ConsumerGroupsState } from './consumerGroup';
 import { SchemasState } from './schema';
 import { AlertsState } from './alerts';
 import { ConnectState } from './connect';
+import { KsqlState } from './ksqlDb';
 
 export * from './topic';
 export * from './cluster';
@@ -30,6 +31,7 @@ export interface RootState {
   connect: ConnectState;
   loader: LoaderState;
   alerts: AlertsState;
+  ksqlDb: KsqlState;
 }
 
 export type Action = ActionType<typeof actions>;

+ 14 - 0
kafka-ui-react-app/src/redux/interfaces/ksqlDb.ts

@@ -0,0 +1,14 @@
+import { KsqlCommandResponse } from 'generated-sources';
+
+export interface KsqlTables {
+  data: {
+    headers: string[];
+    rows: string[][];
+  };
+}
+
+export interface KsqlState {
+  tables: Dictionary<string>[];
+  streams: Dictionary<string>[];
+  executionResult: KsqlCommandResponse | null;
+}

+ 2 - 0
kafka-ui-react-app/src/redux/reducers/index.ts

@@ -10,6 +10,7 @@ import schemas from './schemas/reducer';
 import connect from './connect/reducer';
 import loader from './loader/reducer';
 import alerts from './alerts/reducer';
+import ksqlDb from './ksqlDb/reducer';
 
 export default combineReducers<RootState>({
   topics,
@@ -21,4 +22,5 @@ export default combineReducers<RootState>({
   connect,
   loader,
   alerts,
+  ksqlDb,
 });

+ 51 - 0
kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/__snapshots__/reducer.spec.ts.snap

@@ -0,0 +1,51 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`KsqlDb reducer Fetches tables and streams 1`] = `
+Object {
+  "executionResult": null,
+  "streams": Array [
+    Object {
+      "isWindowed": "false",
+      "keyFormat": "KAFKA",
+      "name": "KSQL_PROCESSING_LOG",
+      "topic": "default_ksql_processing_log",
+      "type": "STREAM",
+      "valueFormat": "JSON",
+    },
+    Object {
+      "isWindowed": "false",
+      "keyFormat": "KAFKA",
+      "name": "PAGEVIEWS",
+      "topic": "pageviews",
+      "type": "STREAM",
+      "valueFormat": "AVRO",
+    },
+  ],
+  "tables": Array [
+    Object {
+      "isWindowed": "false",
+      "keyFormat": "KAFKA",
+      "name": "USERS",
+      "topic": "users",
+      "type": "TABLE",
+      "valueFormat": "AVRO",
+    },
+    Object {
+      "isWindowed": "false",
+      "keyFormat": "KAFKA",
+      "name": "USERS2",
+      "topic": "users",
+      "type": "TABLE",
+      "valueFormat": "AVRO",
+    },
+  ],
+}
+`;
+
+exports[`KsqlDb reducer Resets execution result 1`] = `
+Object {
+  "executionResult": null,
+  "streams": Array [],
+  "tables": Array [],
+}
+`;

+ 65 - 0
kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/fixtures.ts

@@ -0,0 +1,65 @@
+export const fetchKsqlDbTablesPayload: {
+  tables: Dictionary<string>[];
+  streams: Dictionary<string>[];
+} = {
+  tables: [
+    {
+      type: 'TABLE',
+      name: 'USERS',
+      topic: 'users',
+      keyFormat: 'KAFKA',
+      valueFormat: 'AVRO',
+      isWindowed: 'false',
+    },
+    {
+      type: 'TABLE',
+      name: 'USERS2',
+      topic: 'users',
+      keyFormat: 'KAFKA',
+      valueFormat: 'AVRO',
+      isWindowed: 'false',
+    },
+  ],
+  streams: [
+    {
+      type: 'STREAM',
+      name: 'KSQL_PROCESSING_LOG',
+      topic: 'default_ksql_processing_log',
+      keyFormat: 'KAFKA',
+      valueFormat: 'JSON',
+      isWindowed: 'false',
+    },
+    {
+      type: 'STREAM',
+      name: 'PAGEVIEWS',
+      topic: 'pageviews',
+      keyFormat: 'KAFKA',
+      valueFormat: 'AVRO',
+      isWindowed: 'false',
+    },
+  ],
+};
+
+export const ksqlCommandResponse = {
+  data: {
+    headers: [
+      'type',
+      'name',
+      'topic',
+      'keyFormat',
+      'valueFormat',
+      'isWindowed',
+    ],
+    rows: [
+      [
+        'STREAM',
+        'KSQL_PROCESSING_LOG',
+        'default_ksql_processing_log',
+        'KAFKA',
+        'JSON',
+        'false',
+      ],
+      ['STREAM', 'PAGEVIEWS', 'pageviews', 'KAFKA', 'AVRO', 'false'],
+    ],
+  },
+};

+ 35 - 0
kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/reducer.spec.ts

@@ -0,0 +1,35 @@
+import { fetchKsqlDbTablesAction, resetExecutionResult } from 'redux/actions';
+import reducer, { initialState } from 'redux/reducers/ksqlDb/reducer';
+
+import { fetchKsqlDbTablesPayload } from './fixtures';
+
+describe('KsqlDb reducer', () => {
+  it('returns the initial state', () => {
+    expect(reducer(undefined, fetchKsqlDbTablesAction.request())).toEqual(
+      initialState
+    );
+  });
+  it('Fetches tables and streams', () => {
+    const state = reducer(
+      undefined,
+      fetchKsqlDbTablesAction.success(fetchKsqlDbTablesPayload)
+    );
+    expect(state.tables.length).toEqual(2);
+    expect(state.streams.length).toEqual(2);
+    expect(state).toMatchSnapshot();
+  });
+  it('Resets execution result', () => {
+    const state = reducer(
+      {
+        tables: [],
+        streams: [],
+        executionResult: {
+          message: 'No available data',
+        },
+      },
+      resetExecutionResult()
+    );
+    expect(state.executionResult).toEqual(null);
+    expect(state).toMatchSnapshot();
+  });
+});

+ 40 - 0
kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/selectors.spec.ts

@@ -0,0 +1,40 @@
+import configureStore from 'redux/store/configureStore';
+import * as selectors from 'redux/reducers/ksqlDb/selectors';
+import { fetchKsqlDbTablesAction } from 'redux/actions';
+
+import { fetchKsqlDbTablesPayload } from './fixtures';
+
+const store = configureStore();
+
+describe('TopicMessages selectors', () => {
+  describe('Initial state', () => {
+    it('Returns empty state', () => {
+      expect(selectors.getKsqlDbTables(store.getState())).toEqual({
+        rows: [],
+        fetched: false,
+        fetching: true,
+        tablesCount: 0,
+        streamsCount: 0,
+      });
+    });
+  });
+
+  describe('State', () => {
+    beforeAll(() => {
+      store.dispatch(fetchKsqlDbTablesAction.success(fetchKsqlDbTablesPayload));
+    });
+
+    it('Returns tables and streams', () => {
+      expect(selectors.getKsqlDbTables(store.getState())).toEqual({
+        rows: [
+          ...fetchKsqlDbTablesPayload.streams,
+          ...fetchKsqlDbTablesPayload.tables,
+        ],
+        fetched: true,
+        fetching: false,
+        tablesCount: 2,
+        streamsCount: 2,
+      });
+    });
+  });
+});

+ 34 - 0
kafka-ui-react-app/src/redux/reducers/ksqlDb/reducer.ts

@@ -0,0 +1,34 @@
+import { Action } from 'redux/interfaces';
+import { getType } from 'typesafe-actions';
+import * as actions from 'redux/actions';
+import { KsqlState } from 'redux/interfaces/ksqlDb';
+
+export const initialState: KsqlState = {
+  streams: [],
+  tables: [],
+  executionResult: null,
+};
+
+const reducer = (state = initialState, action: Action): KsqlState => {
+  switch (action.type) {
+    case getType(actions.fetchKsqlDbTablesAction.success):
+      return {
+        ...state,
+        ...action.payload,
+      };
+    case getType(actions.executeKsqlAction.success):
+      return {
+        ...state,
+        executionResult: action.payload,
+      };
+    case getType(actions.resetExecutionResult):
+      return {
+        ...state,
+        executionResult: null,
+      };
+    default:
+      return state;
+  }
+};
+
+export default reducer;

+ 32 - 0
kafka-ui-react-app/src/redux/reducers/ksqlDb/selectors.ts

@@ -0,0 +1,32 @@
+import { createSelector } from 'reselect';
+import { RootState } from 'redux/interfaces';
+import { createFetchingSelector } from 'redux/reducers/loader/selectors';
+import { KsqlState } from 'redux/interfaces/ksqlDb';
+
+const ksqlDbState = ({ ksqlDb }: RootState): KsqlState => ksqlDb;
+
+const getKsqlDbFetchTablesAndStreamsFetchingStatus = createFetchingSelector(
+  'GET_KSQL_DB_TABLES_AND_STREAMS'
+);
+
+const getKsqlExecutionStatus = createFetchingSelector('EXECUTE_KSQL');
+
+export const getKsqlDbTables = createSelector(
+  [ksqlDbState, getKsqlDbFetchTablesAndStreamsFetchingStatus],
+  (state, status) => ({
+    rows: [...state.streams, ...state.tables],
+    fetched: status === 'fetched',
+    fetching: status === 'fetching' || status === 'notFetched',
+    tablesCount: state.tables.length,
+    streamsCount: state.streams.length,
+  })
+);
+
+export const getKsqlExecution = createSelector(
+  [ksqlDbState, getKsqlExecutionStatus],
+  (state, status) => ({
+    executionResult: state.executionResult,
+    fetched: status === 'fetched',
+    fetching: status === 'fetching',
+  })
+);