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
This commit is contained in:
parent
7f66f00008
commit
03ed67db89
37 changed files with 1426 additions and 0 deletions
|
@ -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"
|
||||
|
|
|
@ -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;
|
|
@ -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
kafka-ui-react-app/src/components/KsqlDb/KsqlDb.tsx
Normal file
22
kafka-ui-react-app/src/components/KsqlDb/KsqlDb.tsx
Normal file
|
@ -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
kafka-ui-react-app/src/components/KsqlDb/List/List.tsx
Normal file
93
kafka-ui-react-app/src/components/KsqlDb/List/List.tsx
Normal file
|
@ -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
kafka-ui-react-app/src/components/KsqlDb/List/ListItem.tsx
Normal file
40
kafka-ui-react-app/src/components/KsqlDb/List/ListItem.tsx
Normal file
|
@ -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;
|
|
@ -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
kafka-ui-react-app/src/components/KsqlDb/Query/Query.tsx
Normal file
114
kafka-ui-react-app/src/components/KsqlDb/Query/Query.tsx
Normal file
|
@ -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;
|
|
@ -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;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Result Renderer Matches snapshot 1`] = `
|
||||
<ResultRenderer
|
||||
result={Object {}}
|
||||
/>
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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
kafka-ui-react-app/src/components/common/Tabs/Tabs.tsx
Normal file
55
kafka-ui-react-app/src/components/common/Tabs/Tabs.tsx
Normal file
|
@ -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;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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
kafka-ui-react-app/src/custom.d.ts
vendored
Normal file
1
kafka-ui-react-app/src/custom.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
type Dictionary<T> = Record<string, T>;
|
|
@ -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`;
|
||||
|
|
|
@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
|
|
|
@ -4,3 +4,4 @@ export * from './consumerGroups';
|
|||
export * from './schemas';
|
||||
export * from './topics';
|
||||
export * from './connectors';
|
||||
export * from './ksqlDb';
|
||||
|
|
85
kafka-ui-react-app/src/redux/actions/thunks/ksqlDb.ts
Normal file
85
kafka-ui-react-app/src/redux/actions/thunks/ksqlDb.ts
Normal file
|
@ -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 }));
|
||||
}
|
||||
};
|
|
@ -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
kafka-ui-react-app/src/redux/interfaces/ksqlDb.ts
Normal file
14
kafka-ui-react-app/src/redux/interfaces/ksqlDb.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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 [],
|
||||
}
|
||||
`;
|
|
@ -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'],
|
||||
],
|
||||
},
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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
kafka-ui-react-app/src/redux/reducers/ksqlDb/reducer.ts
Normal file
34
kafka-ui-react-app/src/redux/reducers/ksqlDb/reducer.ts
Normal file
|
@ -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
kafka-ui-react-app/src/redux/reducers/ksqlDb/selectors.ts
Normal file
32
kafka-ui-react-app/src/redux/reducers/ksqlDb/selectors.ts
Normal file
|
@ -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',
|
||||
})
|
||||
);
|
Loading…
Add table
Reference in a new issue